summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorLin Jen-Shin <godfat@godfat.org>2016-08-24 21:04:38 +0800
committerLin Jen-Shin <godfat@godfat.org>2016-08-24 21:04:38 +0800
commit4a83cecd706462127da02e2e492daa9d2396d2d7 (patch)
tree02925b2c8e8a9604bde39e1262742065e41ceaf5 /app
parent4384e53ec56fa361014fc8e49a84608536596949 (diff)
parent02591b043052eb4f8041f8cf51546fab272d7b61 (diff)
downloadgitlab-ce-4a83cecd706462127da02e2e492daa9d2396d2d7.tar.gz
Merge remote-tracking branch 'upstream/master' into pipeline-hooks
* upstream/master: (1174 commits) Added CHANGELOG Added unfold test to parallel and added 'diff discussion' context Fix Spinach branches spec Better first match on this MR also Change merge_error column from string to text type Fix typo in gitlab_flow.md entities: make Environment inherit EnvironmentBasic Updated to optimized specs from !5864 Added 'with an unfolded line should not allow commenting' scenario (line 125) Added addtional 'renderable' validator to check 'data-note-type' attr exists Allow passing an index to selectRowAtIndex Fixed enter key in search input not working Fix file links on project page Files view Fix incorrect "stopped impersonation" log message 8.11 is released, long live 8.12 Also check if Akismet is enabled, before showing the `Submit as spam` button. fix location of markdown help location Fix for update 8.10-to-8.11.md doc. Appease the linter. Add Ruby 2.3 upgrade notes. ...
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/bg-header.pngbin90 -> 0 bytes
-rw-r--r--app/assets/images/bg_fallback.pngbin167 -> 0 bytes
-rw-r--r--app/assets/images/chosen-sprite.pngbin367 -> 0 bytes
-rw-r--r--app/assets/images/diff_note_add.pngbin418 -> 0 bytes
-rw-r--r--app/assets/images/icon-search.pngbin222 -> 0 bytes
-rw-r--r--app/assets/images/icon_sprite.pngbin2636 -> 0 bytes
-rw-r--r--app/assets/images/images.pngbin5806 -> 0 bytes
-rw-r--r--app/assets/images/koding-logo.svg8
-rw-r--r--app/assets/images/move.pngbin197 -> 0 bytes
-rw-r--r--app/assets/images/progress_bar.gifbin494 -> 0 bytes
-rw-r--r--app/assets/images/slider_handles.pngbin1341 -> 0 bytes
-rw-r--r--app/assets/images/switch_icon.pngbin231 -> 0 bytes
-rw-r--r--app/assets/images/trans_bg.gifbin49 -> 0 bytes
-rw-r--r--app/assets/javascripts/abuse_reports.js.es639
-rw-r--r--app/assets/javascripts/api.js51
-rw-r--r--app/assets/javascripts/application.js23
-rw-r--r--app/assets/javascripts/awards_handler.js68
-rw-r--r--app/assets/javascripts/blob/template_selector.js22
-rw-r--r--app/assets/javascripts/blob_edit/blob_edit_bundle.js12
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js (renamed from app/assets/javascripts/blob/edit_blob.js)0
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js.es657
-rw-r--r--app/assets/javascripts/boards/components/board.js.es681
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.js.es649
-rw-r--r--app/assets/javascripts/boards/components/board_card.js.es643
-rw-r--r--app/assets/javascripts/boards/components/board_delete.js.es619
-rw-r--r--app/assets/javascripts/boards/components/board_list.js.es687
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js.es654
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js.es635
-rw-r--r--app/assets/javascripts/boards/models/issue.js.es644
-rw-r--r--app/assets/javascripts/boards/models/label.js.es610
-rw-r--r--app/assets/javascripts/boards/models/list.js.es6125
-rw-r--r--app/assets/javascripts/boards/models/user.js.es68
-rw-r--r--app/assets/javascripts/boards/services/board_service.js.es661
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js.es6112
-rw-r--r--app/assets/javascripts/boards/test_utils/simulate_drag.js119
-rw-r--r--app/assets/javascripts/boards/vue_resource_interceptor.js.es610
-rw-r--r--app/assets/javascripts/build.js37
-rw-r--r--app/assets/javascripts/copy_to_clipboard.js1
-rw-r--r--app/assets/javascripts/create_label.js.es6126
-rw-r--r--app/assets/javascripts/diff.js29
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es649
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6188
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js.es6107
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_count.js.es618
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es660
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js.es635
-rw-r--r--app/assets/javascripts/diff_notes/mixins/discussion.js.es635
-rw-r--r--app/assets/javascripts/diff_notes/mixins/namespace.js.es69
-rw-r--r--app/assets/javascripts/diff_notes/models/discussion.js.es687
-rw-r--r--app/assets/javascripts/diff_notes/models/note.js.es69
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js.es688
-rw-r--r--app/assets/javascripts/diff_notes/stores/comments.js.es653
-rw-r--r--app/assets/javascripts/dispatcher.js24
-rw-r--r--app/assets/javascripts/dropzone_input.js2
-rw-r--r--app/assets/javascripts/files_comment_button.js17
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js.es6 (renamed from app/assets/javascripts/gfm_auto_complete.js)69
-rw-r--r--app/assets/javascripts/gl_dropdown.js148
-rw-r--r--app/assets/javascripts/gl_form.js2
-rw-r--r--app/assets/javascripts/issuable.js11
-rw-r--r--app/assets/javascripts/issuable_form.js24
-rw-r--r--app/assets/javascripts/labels_select.js114
-rw-r--r--app/assets/javascripts/lib/ace.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js38
-rw-r--r--app/assets/javascripts/lib/utils/md5.js211
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js5
-rw-r--r--app/assets/javascripts/lib/utils/utf8_encode.js70
-rw-r--r--app/assets/javascripts/member_expiration_date.js32
-rw-r--r--app/assets/javascripts/merge_conflict_data_provider.js.es6341
-rw-r--r--app/assets/javascripts/merge_conflict_resolver.js.es685
-rw-r--r--app/assets/javascripts/merge_request.js2
-rw-r--r--app/assets/javascripts/merge_request_tabs.js33
-rw-r--r--app/assets/javascripts/merge_request_widget.js4
-rw-r--r--app/assets/javascripts/milestone_select.js8
-rw-r--r--app/assets/javascripts/notes.js142
-rw-r--r--app/assets/javascripts/pipeline.js.es615
-rw-r--r--app/assets/javascripts/preview_markdown.js (renamed from app/assets/javascripts/markdown_preview.js)4
-rw-r--r--app/assets/javascripts/project.js15
-rw-r--r--app/assets/javascripts/project_members.js3
-rw-r--r--app/assets/javascripts/protected_branch_access_dropdown.js.es628
-rw-r--r--app/assets/javascripts/protected_branch_create.js.es654
-rw-r--r--app/assets/javascripts/protected_branch_dropdown.js.es675
-rw-r--r--app/assets/javascripts/protected_branch_edit.js.es666
-rw-r--r--app/assets/javascripts/protected_branch_edit_list.js.es617
-rw-r--r--app/assets/javascripts/protected_branch_select.js72
-rw-r--r--app/assets/javascripts/protected_branches_access_select.js.es663
-rw-r--r--app/assets/javascripts/search_autocomplete.js18
-rw-r--r--app/assets/javascripts/single_file_diff.js16
-rw-r--r--app/assets/javascripts/snippet/snippet_bundle.js12
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js.es651
-rw-r--r--app/assets/javascripts/templates/issuable_template_selectors.js.es629
-rw-r--r--app/assets/javascripts/users_select.js47
-rw-r--r--app/assets/stylesheets/behaviors.scss5
-rw-r--r--app/assets/stylesheets/framework/buttons.scss8
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss24
-rw-r--r--app/assets/stylesheets/framework/files.scss5
-rw-r--r--app/assets/stylesheets/framework/gfm.scss2
-rw-r--r--app/assets/stylesheets/framework/highlight.scss15
-rw-r--r--app/assets/stylesheets/framework/lists.scss11
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss5
-rw-r--r--app/assets/stylesheets/framework/mixins.scss7
-rw-r--r--app/assets/stylesheets/framework/modal.scss1
-rw-r--r--app/assets/stylesheets/framework/nav.scss3
-rw-r--r--app/assets/stylesheets/framework/panels.scss5
-rw-r--r--app/assets/stylesheets/framework/selects.scss3
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss4
-rw-r--r--app/assets/stylesheets/framework/typography.scss10
-rw-r--r--app/assets/stylesheets/framework/variables.scss3
-rw-r--r--app/assets/stylesheets/highlight/dark.scss7
-rw-r--r--app/assets/stylesheets/highlight/monokai.scss7
-rw-r--r--app/assets/stylesheets/highlight/solarized_dark.scss7
-rw-r--r--app/assets/stylesheets/highlight/solarized_light.scss13
-rw-r--r--app/assets/stylesheets/highlight/white.scss13
-rw-r--r--app/assets/stylesheets/mailers/repository_push_email.scss5
-rw-r--r--app/assets/stylesheets/pages/admin.scss42
-rw-r--r--app/assets/stylesheets/pages/boards.scss306
-rw-r--r--app/assets/stylesheets/pages/builds.scss91
-rw-r--r--app/assets/stylesheets/pages/commit.scss9
-rw-r--r--app/assets/stylesheets/pages/commits.scss2
-rw-r--r--app/assets/stylesheets/pages/detail_page.scss7
-rw-r--r--app/assets/stylesheets/pages/diff.scss5
-rw-r--r--app/assets/stylesheets/pages/environments.scss30
-rw-r--r--app/assets/stylesheets/pages/groups.scss8
-rw-r--r--app/assets/stylesheets/pages/issuable.scss9
-rw-r--r--app/assets/stylesheets/pages/labels.scss11
-rw-r--r--app/assets/stylesheets/pages/merge_conflicts.scss238
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss12
-rw-r--r--app/assets/stylesheets/pages/note_form.scss26
-rw-r--r--app/assets/stylesheets/pages/notes.scss77
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss200
-rw-r--r--app/assets/stylesheets/pages/profile.scss6
-rw-r--r--app/assets/stylesheets/pages/projects.scss59
-rw-r--r--app/assets/stylesheets/pages/todos.scss47
-rw-r--r--app/assets/stylesheets/pages/tree.scss4
-rw-r--r--app/controllers/admin/application_settings_controller.rb2
-rw-r--r--app/controllers/admin/groups_controller.rb6
-rw-r--r--app/controllers/admin/impersonations_controller.rb2
-rw-r--r--app/controllers/admin/spam_logs_controller.rb10
-rw-r--r--app/controllers/autocomplete_controller.rb32
-rw-r--r--app/controllers/concerns/diff_for_path.rb6
-rw-r--r--app/controllers/concerns/issuable_collections.rb5
-rw-r--r--app/controllers/concerns/service_params.rb8
-rw-r--r--app/controllers/concerns/spammable_actions.rb25
-rw-r--r--app/controllers/dashboard/todos_controller.rb11
-rw-r--r--app/controllers/groups/group_members_controller.rb9
-rw-r--r--app/controllers/groups_controller.rb4
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb12
-rw-r--r--app/controllers/koding_controller.rb15
-rw-r--r--app/controllers/profiles/passwords_controller.rb1
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb12
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb7
-rw-r--r--app/controllers/projects/application_controller.rb1
-rw-r--r--app/controllers/projects/badges_controller.rb18
-rw-r--r--app/controllers/projects/blob_controller.rb16
-rw-r--r--app/controllers/projects/board_lists_controller.rb65
-rw-r--r--app/controllers/projects/boards/application_controller.rb15
-rw-r--r--app/controllers/projects/boards/issues_controller.rb56
-rw-r--r--app/controllers/projects/boards/lists_controller.rb81
-rw-r--r--app/controllers/projects/boards_controller.rb15
-rw-r--r--app/controllers/projects/branches_controller.rb10
-rw-r--r--app/controllers/projects/builds_controller.rb2
-rw-r--r--app/controllers/projects/commit_controller.rb8
-rw-r--r--app/controllers/projects/compare_controller.rb16
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb20
-rw-r--r--app/controllers/projects/discussions_controller.rb43
-rw-r--r--app/controllers/projects/git_http_client_controller.rb120
-rw-r--r--app/controllers/projects/git_http_controller.rb142
-rw-r--r--app/controllers/projects/group_links_controller.rb4
-rw-r--r--app/controllers/projects/issues_controller.rb28
-rw-r--r--app/controllers/projects/lfs_api_controller.rb94
-rw-r--r--app/controllers/projects/lfs_storage_controller.rb92
-rw-r--r--app/controllers/projects/merge_requests_controller.rb108
-rw-r--r--app/controllers/projects/notes_controller.rb36
-rw-r--r--app/controllers/projects/pipelines_controller.rb2
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb8
-rw-r--r--app/controllers/projects/project_members_controller.rb9
-rw-r--r--app/controllers/projects/protected_branches_controller.rb26
-rw-r--r--app/controllers/projects/templates_controller.rb19
-rw-r--r--app/controllers/projects/wikis_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb34
-rw-r--r--app/controllers/registrations_controller.rb2
-rw-r--r--app/controllers/sessions_controller.rb2
-rw-r--r--app/finders/move_to_project_finder.rb20
-rw-r--r--app/finders/projects_finder.rb3
-rw-r--r--app/finders/todos_finder.rb28
-rw-r--r--app/helpers/appearances_helper.rb2
-rw-r--r--app/helpers/application_helper.rb12
-rw-r--r--app/helpers/application_settings_helper.rb4
-rw-r--r--app/helpers/avatars_helper.rb4
-rw-r--r--app/helpers/blob_helper.rb58
-rw-r--r--app/helpers/ci_status_helper.rb26
-rw-r--r--app/helpers/commits_helper.rb49
-rw-r--r--app/helpers/diff_helper.rb46
-rw-r--r--app/helpers/explore_helper.rb2
-rw-r--r--app/helpers/issuables_helper.rb9
-rw-r--r--app/helpers/issues_helper.rb46
-rw-r--r--app/helpers/lfs_helper.rb67
-rw-r--r--app/helpers/members_helper.rb6
-rw-r--r--app/helpers/nav_helper.rb1
-rw-r--r--app/helpers/notes_helper.rb14
-rw-r--r--app/helpers/projects_helper.rb58
-rw-r--r--app/helpers/search_helper.rb2
-rw-r--r--app/helpers/sorting_helper.rb8
-rw-r--r--app/helpers/time_helper.rb17
-rw-r--r--app/helpers/todos_helper.rb4
-rw-r--r--app/helpers/tree_helper.rb18
-rw-r--r--app/mailers/emails/issues.rb5
-rw-r--r--app/mailers/emails/merge_requests.rb12
-rw-r--r--app/models/ability.rb65
-rw-r--r--app/models/application_setting.rb6
-rw-r--r--app/models/blob.rb7
-rw-r--r--app/models/board.rb7
-rw-r--r--app/models/ci/build.rb54
-rw-r--r--app/models/ci/pipeline.rb161
-rw-r--r--app/models/commit.rb28
-rw-r--r--app/models/commit_status.rb50
-rw-r--r--app/models/compare.rb66
-rw-r--r--app/models/concerns/expirable.rb15
-rw-r--r--app/models/concerns/faster_cache_keys.rb16
-rw-r--r--app/models/concerns/issuable.rb25
-rw-r--r--app/models/concerns/note_on_diff.rb4
-rw-r--r--app/models/concerns/protected_branch_access.rb7
-rw-r--r--app/models/concerns/sortable.rb14
-rw-r--r--app/models/concerns/spammable.rb52
-rw-r--r--app/models/concerns/statuseable.rb46
-rw-r--r--app/models/deployment.rb6
-rw-r--r--app/models/diff_note.rb91
-rw-r--r--app/models/discussion.rb90
-rw-r--r--app/models/environment.rb6
-rw-r--r--app/models/group.rb24
-rw-r--r--app/models/issue.rb9
-rw-r--r--app/models/key.rb5
-rw-r--r--app/models/label.rb2
-rw-r--r--app/models/legacy_diff_note.rb20
-rw-r--r--app/models/list.rb34
-rw-r--r--app/models/member.rb4
-rw-r--r--app/models/members/project_member.rb15
-rw-r--r--app/models/merge_request.rb101
-rw-r--r--app/models/merge_request_diff.rb14
-rw-r--r--app/models/namespace.rb2
-rw-r--r--app/models/note.rb58
-rw-r--r--app/models/project.rb41
-rw-r--r--app/models/project_group_link.rb4
-rw-r--r--app/models/project_services/builds_email_service.rb3
-rw-r--r--app/models/project_services/campfire_service.rb51
-rw-r--r--app/models/project_services/pivotaltracker_service.rb31
-rw-r--r--app/models/project_team.rb24
-rw-r--r--app/models/project_wiki.rb4
-rw-r--r--app/models/protected_branch.rb11
-rw-r--r--app/models/protected_branch/merge_access_level.rb6
-rw-r--r--app/models/protected_branch/push_access_level.rb6
-rw-r--r--app/models/repository.rb46
-rw-r--r--app/models/service.rb2
-rw-r--r--app/models/spam_log.rb4
-rw-r--r--app/models/spam_report.rb5
-rw-r--r--app/models/todo.rb19
-rw-r--r--app/models/u2f_registration.rb7
-rw-r--r--app/models/user.rb15
-rw-r--r--app/models/user_agent_detail.rb9
-rw-r--r--app/services/akismet_service.rb79
-rw-r--r--app/services/boards/base_service.rb5
-rw-r--r--app/services/boards/create_service.rb16
-rw-r--r--app/services/boards/issues/list_service.rb63
-rw-r--r--app/services/boards/issues/move_service.rb59
-rw-r--r--app/services/boards/lists/create_service.rb22
-rw-r--r--app/services/boards/lists/destroy_service.rb25
-rw-r--r--app/services/boards/lists/generate_service.rb36
-rw-r--r--app/services/boards/lists/move_service.rb51
-rw-r--r--app/services/ci/create_builds_service.rb62
-rw-r--r--app/services/ci/create_pipeline_builds_service.rb42
-rw-r--r--app/services/ci/create_pipeline_service.rb97
-rw-r--r--app/services/ci/create_trigger_request_service.rb17
-rw-r--r--app/services/ci/process_pipeline_service.rb77
-rw-r--r--app/services/compare_service.rb6
-rw-r--r--app/services/create_commit_builds_service.rb69
-rw-r--r--app/services/create_spam_log_service.rb13
-rw-r--r--app/services/delete_branch_service.rb9
-rw-r--r--app/services/delete_tag_service.rb9
-rw-r--r--app/services/delete_user_service.rb9
-rw-r--r--app/services/destroy_group_service.rb16
-rw-r--r--app/services/files/base_service.rb1
-rw-r--r--app/services/files/update_service.rb23
-rw-r--r--app/services/git_push_service.rb28
-rw-r--r--app/services/git_tag_push_service.rb22
-rw-r--r--app/services/ham_service.rb26
-rw-r--r--app/services/import_export_clean_up_service.rb24
-rw-r--r--app/services/issuable_base_service.rb97
-rw-r--r--app/services/issues/close_service.rb2
-rw-r--r--app/services/issues/create_service.rb38
-rw-r--r--app/services/issues/reopen_service.rb2
-rw-r--r--app/services/issues/update_service.rb7
-rw-r--r--app/services/members/authorized_destroy_service.rb19
-rw-r--r--app/services/members/destroy_service.rb12
-rw-r--r--app/services/merge_requests/build_service.rb2
-rw-r--r--app/services/merge_requests/close_service.rb2
-rw-r--r--app/services/merge_requests/create_service.rb25
-rw-r--r--app/services/merge_requests/get_urls_service.rb63
-rw-r--r--app/services/merge_requests/merge_request_diff_cache_service.rb8
-rw-r--r--app/services/merge_requests/reopen_service.rb2
-rw-r--r--app/services/merge_requests/resolve_service.rb31
-rw-r--r--app/services/merge_requests/resolved_discussion_notification_service.rb10
-rw-r--r--app/services/merge_requests/update_service.rb11
-rw-r--r--app/services/notes/create_service.rb27
-rw-r--r--app/services/notes/post_process_service.rb2
-rw-r--r--app/services/notes/slash_commands_service.rb33
-rw-r--r--app/services/notification_service.rb49
-rw-r--r--app/services/projects/autocomplete_service.rb27
-rw-r--r--app/services/projects/destroy_service.rb8
-rw-r--r--app/services/projects/enable_deploy_key_service.rb17
-rw-r--r--app/services/projects/participants_service.rb38
-rw-r--r--app/services/protected_branches/create_service.rb18
-rw-r--r--app/services/repository_archive_clean_up_service.rb4
-rw-r--r--app/services/slash_commands/interpret_service.rb236
-rw-r--r--app/services/spam_check_service.rb38
-rw-r--r--app/services/spam_service.rb78
-rw-r--r--app/services/system_note_service.rb6
-rw-r--r--app/services/test_hook_service.rb2
-rw-r--r--app/services/todo_service.rb13
-rw-r--r--app/services/user_agent_detail_service.rb13
-rw-r--r--app/views/admin/abuse_reports/_abuse_report.html.haml17
-rw-r--r--app/views/admin/abuse_reports/index.html.haml33
-rw-r--r--app/views/admin/application_settings/_form.html.haml28
-rw-r--r--app/views/admin/builds/_build.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/labels/_form.html.haml3
-rw-r--r--app/views/admin/spam_logs/_spam_log.html.haml5
-rw-r--r--app/views/admin/users/show.html.haml8
-rw-r--r--app/views/dashboard/todos/_todo.html.haml15
-rw-r--r--app/views/dashboard/todos/index.html.haml19
-rw-r--r--app/views/devise/sessions/_new_crowd.html.haml2
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml2
-rw-r--r--app/views/discussions/_diff_discussion.html.haml8
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml13
-rw-r--r--app/views/discussions/_discussion.html.haml23
-rw-r--r--app/views/discussions/_headline.html.haml14
-rw-r--r--app/views/discussions/_jump_to_next.html.haml9
-rw-r--r--app/views/discussions/_notes.html.haml20
-rw-r--r--app/views/discussions/_parallel_diff_discussion.html.haml21
-rw-r--r--app/views/discussions/_resolve_all.html.haml11
-rw-r--r--app/views/groups/group_members/_new_group_member.html.haml9
-rw-r--r--app/views/groups/group_members/update.js.haml1
-rw-r--r--app/views/import/github/status.html.haml4
-rw-r--r--app/views/koding/index.html.haml6
-rw-r--r--app/views/layouts/_init_auto_complete.html.haml4
-rw-r--r--app/views/layouts/_page.html.haml5
-rw-r--r--app/views/layouts/application.html.haml2
-rw-r--r--app/views/layouts/koding.html.haml5
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml5
-rw-r--r--app/views/layouts/nav/_project.html.haml4
-rw-r--r--app/views/layouts/project.html.haml6
-rw-r--r--app/views/notify/new_issue_email.text.erb2
-rw-r--r--app/views/notify/new_mention_in_issue_email.html.haml12
-rw-r--r--app/views/notify/new_mention_in_issue_email.text.erb7
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.html.haml15
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.text.erb9
-rw-r--r--app/views/notify/new_merge_request_email.text.erb2
-rw-r--r--app/views/notify/repository_push_email.html.haml3
-rw-r--r--app/views/notify/resolved_all_discussions_email.html.haml2
-rw-r--r--app/views/notify/resolved_all_discussions_email.text.erb3
-rw-r--r--app/views/profiles/accounts/show.html.haml2
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml4
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml31
-rw-r--r--app/views/projects/_home_panel.html.haml3
-rw-r--r--app/views/projects/_zen.html.haml3
-rw-r--r--app/views/projects/badges/badge.svg.erb36
-rw-r--r--app/views/projects/blob/_editor.html.haml2
-rw-r--r--app/views/projects/blob/_image.html.haml16
-rw-r--r--app/views/projects/blob/diff.html.haml34
-rw-r--r--app/views/projects/blob/edit.html.haml18
-rw-r--r--app/views/projects/blob/new.html.haml9
-rw-r--r--app/views/projects/boards/components/_blank_state.html.haml15
-rw-r--r--app/views/projects/boards/components/_board.html.haml43
-rw-r--r--app/views/projects/boards/components/_card.html.haml33
-rw-r--r--app/views/projects/boards/show.html.haml19
-rw-r--r--app/views/projects/branches/_branch.html.haml4
-rw-r--r--app/views/projects/builds/_sidebar.html.haml197
-rw-r--r--app/views/projects/builds/show.html.haml28
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml2
-rw-r--r--app/views/projects/buttons/_koding.html.haml7
-rw-r--r--app/views/projects/buttons/_star.html.haml6
-rw-r--r--app/views/projects/ci/builds/_build.html.haml2
-rw-r--r--app/views/projects/ci/builds/_build_pipeline.html.haml14
-rw-r--r--app/views/projects/ci/pipelines/_pipeline.html.haml46
-rw-r--r--app/views/projects/commit/_change.html.haml4
-rw-r--r--app/views/projects/commit/_ci_menu.html.haml2
-rw-r--r--app/views/projects/commit/_commit_box.html.haml8
-rw-r--r--app/views/projects/commit/_pipeline.html.haml26
-rw-r--r--app/views/projects/commit/_pipelines_list.haml17
-rw-r--r--app/views/projects/commit/show.html.haml2
-rw-r--r--app/views/projects/compare/_form.html.haml2
-rw-r--r--app/views/projects/compare/show.html.haml2
-rw-r--r--app/views/projects/deployments/_actions.haml6
-rw-r--r--app/views/projects/deployments/_commit.html.haml8
-rw-r--r--app/views/projects/deployments/_deployment.html.haml1
-rw-r--r--app/views/projects/diffs/_content.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml19
-rw-r--r--app/views/projects/diffs/_file.html.haml15
-rw-r--r--app/views/projects/diffs/_line.html.haml19
-rw-r--r--app/views/projects/diffs/_match_line.html.haml7
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml10
-rw-r--r--app/views/projects/diffs/_text_file.html.haml20
-rw-r--r--app/views/projects/environments/_environment.html.haml8
-rw-r--r--app/views/projects/environments/index.html.haml7
-rw-r--r--app/views/projects/environments/show.html.haml4
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml9
-rw-r--r--app/views/projects/graphs/show.html.haml2
-rw-r--r--app/views/projects/group_links/index.html.haml11
-rw-r--r--app/views/projects/issues/_head.html.haml7
-rw-r--r--app/views/projects/issues/show.html.haml11
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml3
-rw-r--r--app/views/projects/merge_requests/_new_submit.html.haml13
-rw-r--r--app/views/projects/merge_requests/_show.html.haml33
-rw-r--r--app/views/projects/merge_requests/conflicts.html.haml29
-rw-r--r--app/views/projects/merge_requests/conflicts/_commit_stats.html.haml20
-rw-r--r--app/views/projects/merge_requests/conflicts/_inline_view.html.haml28
-rw-r--r--app/views/projects/merge_requests/conflicts/_parallel_view.html.haml27
-rw-r--r--app/views/projects/merge_requests/conflicts/_submit_form.html.haml15
-rw-r--r--app/views/projects/merge_requests/show/_builds.html.haml1
-rw-r--r--app/views/projects/merge_requests/show/_diffs.html.haml3
-rw-r--r--app/views/projects/merge_requests/show/_mr_title.html.haml2
-rw-r--r--app/views/projects/merge_requests/show/_pipelines.html.haml1
-rw-r--r--app/views/projects/merge_requests/widget/_heading.html.haml13
-rw-r--r--app/views/projects/merge_requests/widget/_merged.html.haml2
-rw-r--r--app/views/projects/merge_requests/widget/_open.html.haml10
-rw-r--r--app/views/projects/merge_requests/widget/_show.html.haml3
-rw-r--r--app/views/projects/merge_requests/widget/open/_conflicts.html.haml13
-rw-r--r--app/views/projects/new.html.haml40
-rw-r--r--app/views/projects/notes/_form.html.haml10
-rw-r--r--app/views/projects/notes/_hints.html.haml13
-rw-r--r--app/views/projects/notes/_note.html.haml56
-rw-r--r--app/views/projects/pipelines/_info.html.haml2
-rw-r--r--app/views/projects/pipelines/new.html.haml2
-rw-r--r--app/views/projects/pipelines_settings/_badge.html.haml27
-rw-r--r--app/views/projects/pipelines_settings/show.html.haml25
-rw-r--r--app/views/projects/project_members/_new_project_member.html.haml9
-rw-r--r--app/views/projects/project_members/index.html.haml2
-rw-r--r--app/views/projects/project_members/update.js.haml1
-rw-r--r--app/views/projects/protected_branches/_branches_list.html.haml50
-rw-r--r--app/views/projects/protected_branches/_create_protected_branch.html.haml41
-rw-r--r--app/views/projects/protected_branches/_dropdown.html.haml12
-rw-r--r--app/views/projects/protected_branches/_protected_branch.html.haml18
-rw-r--r--app/views/projects/protected_branches/_update_protected_branch.html.haml10
-rw-r--r--app/views/projects/protected_branches/index.html.haml36
-rw-r--r--app/views/projects/releases/edit.html.haml16
-rw-r--r--app/views/projects/show.html.haml8
-rw-r--r--app/views/projects/tree/_tree_commit_column.html.haml2
-rw-r--r--app/views/projects/tree/_tree_row.html.haml6
-rw-r--r--app/views/projects/wikis/_form.html.haml11
-rw-r--r--app/views/shared/_labels_row.html.haml6
-rw-r--r--app/views/shared/_ref_switcher.html.haml2
-rw-r--r--app/views/shared/icons/_icon_fork.svg2
-rw-r--r--app/views/shared/icons/_icon_play.svg1
-rw-r--r--app/views/shared/icons/_next_discussion.svg1
-rw-r--r--app/views/shared/issuable/_filter.html.haml14
-rw-r--r--app/views/shared/issuable/_form.html.haml40
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml10
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
-rw-r--r--app/views/shared/members/_member.html.haml22
-rw-r--r--app/views/shared/projects/_project.html.haml8
-rw-r--r--app/views/shared/snippets/_form.html.haml9
-rw-r--r--app/views/shared/web_hooks/_form.html.haml23
-rw-r--r--app/views/u2f/_register.html.haml13
-rw-r--r--app/workers/emails_on_push_worker.rb19
-rw-r--r--app/workers/group_destroy_worker.rb17
-rw-r--r--app/workers/import_export_project_cleanup_worker.rb (renamed from app/workers/gitlab_remove_project_export_worker.rb)4
-rw-r--r--app/workers/irker_worker.rb6
-rw-r--r--app/workers/post_receive.rb4
-rw-r--r--app/workers/project_destroy_worker.rb2
-rw-r--r--app/workers/remove_expired_group_links_worker.rb7
-rw-r--r--app/workers/remove_expired_members_worker.rb13
-rw-r--r--app/workers/repository_fork_worker.rb4
-rw-r--r--app/workers/repository_import_worker.rb4
471 files changed, 9946 insertions, 2473 deletions
diff --git a/app/assets/images/bg-header.png b/app/assets/images/bg-header.png
deleted file mode 100644
index 639271c6faf..00000000000
--- a/app/assets/images/bg-header.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/bg_fallback.png b/app/assets/images/bg_fallback.png
deleted file mode 100644
index 5c55bc79dec..00000000000
--- a/app/assets/images/bg_fallback.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/chosen-sprite.png b/app/assets/images/chosen-sprite.png
deleted file mode 100644
index 3d936b07d44..00000000000
--- a/app/assets/images/chosen-sprite.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/diff_note_add.png b/app/assets/images/diff_note_add.png
deleted file mode 100644
index 0084422e330..00000000000
--- a/app/assets/images/diff_note_add.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/icon-search.png b/app/assets/images/icon-search.png
deleted file mode 100644
index 3c1c146541d..00000000000
--- a/app/assets/images/icon-search.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/icon_sprite.png b/app/assets/images/icon_sprite.png
deleted file mode 100644
index 2e7a5023398..00000000000
--- a/app/assets/images/icon_sprite.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/images.png b/app/assets/images/images.png
deleted file mode 100644
index bd60de994c4..00000000000
--- a/app/assets/images/images.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/koding-logo.svg b/app/assets/images/koding-logo.svg
new file mode 100644
index 00000000000..ad89d684d94
--- /dev/null
+++ b/app/assets/images/koding-logo.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14">
+ <g fill="#d6d7d9">
+ <path d="M8.7 0L5.3.3l3.2 6.8-3.2 6.6 3.5.3L12 6.9z"/>
+ <ellipse cx="1.7" cy="11.1" rx="1.7" ry="1.7"/>
+ <ellipse cx="1.7" cy="5.6" rx="1.7" ry="1.7"/>
+ </g>
+</svg> \ No newline at end of file
diff --git a/app/assets/images/move.png b/app/assets/images/move.png
deleted file mode 100644
index 6a0567f8f25..00000000000
--- a/app/assets/images/move.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/progress_bar.gif b/app/assets/images/progress_bar.gif
deleted file mode 100644
index c3d43fa40b2..00000000000
--- a/app/assets/images/progress_bar.gif
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/slider_handles.png b/app/assets/images/slider_handles.png
deleted file mode 100644
index 52ad11ab7a1..00000000000
--- a/app/assets/images/slider_handles.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/switch_icon.png b/app/assets/images/switch_icon.png
deleted file mode 100644
index c6b6c8d9521..00000000000
--- a/app/assets/images/switch_icon.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/trans_bg.gif b/app/assets/images/trans_bg.gif
deleted file mode 100644
index 1a1c9c15ec7..00000000000
--- a/app/assets/images/trans_bg.gif
+++ /dev/null
Binary files differ
diff --git a/app/assets/javascripts/abuse_reports.js.es6 b/app/assets/javascripts/abuse_reports.js.es6
new file mode 100644
index 00000000000..748084b0307
--- /dev/null
+++ b/app/assets/javascripts/abuse_reports.js.es6
@@ -0,0 +1,39 @@
+window.gl = window.gl || {};
+((global) => {
+ const MAX_MESSAGE_LENGTH = 500;
+ const MESSAGE_CELL_SELECTOR = '.abuse-reports .message';
+
+ class AbuseReports {
+ constructor() {
+ $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage);
+ $(document)
+ .off('click', MESSAGE_CELL_SELECTOR)
+ .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation);
+ }
+
+ truncateLongMessage() {
+ const $messageCellElement = $(this);
+ const reportMessage = $messageCellElement.text();
+ if (reportMessage.length > MAX_MESSAGE_LENGTH) {
+ $messageCellElement.data('original-message', reportMessage);
+ $messageCellElement.data('message-truncated', 'true');
+ $messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH));
+ }
+ }
+
+ toggleMessageTruncation() {
+ const $messageCellElement = $(this);
+ const originalMessage = $messageCellElement.data('original-message');
+ if (!originalMessage) return;
+ if ($messageCellElement.data('message-truncated') === 'true') {
+ $messageCellElement.data('message-truncated', 'false');
+ $messageCellElement.text(originalMessage);
+ } else {
+ $messageCellElement.data('message-truncated', 'true');
+ $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`);
+ }
+ }
+ }
+
+ global.AbuseReports = AbuseReports;
+})(window.gl);
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 49c2ac0dac3..84b292e59c6 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -9,10 +9,11 @@
licensePath: "/api/:version/licenses/:key",
gitignorePath: "/api/:version/gitignores/:key",
gitlabCiYmlPath: "/api/:version/gitlab_ci_ymls/:key",
+ issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key",
+
group: function(group_id, callback) {
- var url;
- url = Api.buildUrl(Api.groupPath);
- url = url.replace(':id', group_id);
+ var url = Api.buildUrl(Api.groupPath)
+ .replace(':id', group_id);
return $.ajax({
url: url,
data: {
@@ -24,8 +25,7 @@
});
},
groups: function(query, skip_ldap, callback) {
- var url;
- url = Api.buildUrl(Api.groupsPath);
+ var url = Api.buildUrl(Api.groupsPath);
return $.ajax({
url: url,
data: {
@@ -39,8 +39,7 @@
});
},
namespaces: function(query, callback) {
- var url;
- url = Api.buildUrl(Api.namespacesPath);
+ var url = Api.buildUrl(Api.namespacesPath);
return $.ajax({
url: url,
data: {
@@ -54,8 +53,7 @@
});
},
projects: function(query, order, callback) {
- var url;
- url = Api.buildUrl(Api.projectsPath);
+ var url = Api.buildUrl(Api.projectsPath);
return $.ajax({
url: url,
data: {
@@ -70,9 +68,8 @@
});
},
newLabel: function(project_id, data, callback) {
- var url;
- url = Api.buildUrl(Api.labelsPath);
- url = url.replace(':id', project_id);
+ var url = Api.buildUrl(Api.labelsPath)
+ .replace(':id', project_id);
data.private_token = gon.api_token;
return $.ajax({
url: url,
@@ -86,9 +83,8 @@
});
},
groupProjects: function(group_id, query, callback) {
- var url;
- url = Api.buildUrl(Api.groupProjectsPath);
- url = url.replace(':id', group_id);
+ var url = Api.buildUrl(Api.groupProjectsPath)
+ .replace(':id', group_id);
return $.ajax({
url: url,
data: {
@@ -102,8 +98,8 @@
});
},
licenseText: function(key, data, callback) {
- var url;
- url = Api.buildUrl(Api.licensePath).replace(':key', key);
+ var url = Api.buildUrl(Api.licensePath)
+ .replace(':key', key);
return $.ajax({
url: url,
data: data
@@ -112,19 +108,32 @@
});
},
gitignoreText: function(key, callback) {
- var url;
- url = Api.buildUrl(Api.gitignorePath).replace(':key', key);
+ var url = Api.buildUrl(Api.gitignorePath)
+ .replace(':key', key);
return $.get(url, function(gitignore) {
return callback(gitignore);
});
},
gitlabCiYml: function(key, callback) {
- var url;
- url = Api.buildUrl(Api.gitlabCiYmlPath).replace(':key', key);
+ var url = Api.buildUrl(Api.gitlabCiYmlPath)
+ .replace(':key', key);
return $.get(url, function(file) {
return callback(file);
});
},
+ issueTemplate: function(namespacePath, projectPath, key, type, callback) {
+ var url = Api.buildUrl(Api.issuableTemplatePath)
+ .replace(':key', key)
+ .replace(':type', type)
+ .replace(':project_path', projectPath)
+ .replace(':namespace_path', namespacePath);
+ $.ajax({
+ url: url,
+ dataType: 'json'
+ }).done(function(file) {
+ callback(null, file);
+ }).error(callback);
+ },
buildUrl: function(url) {
if (gon.relative_url_root != null) {
url = gon.relative_url_root + url;
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 127e568adc9..fc354dfd677 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -26,8 +26,6 @@
/*= require bootstrap/tooltip */
/*= require bootstrap/popover */
/*= require select2 */
-/*= require ace/ace */
-/*= require ace/ext-searchbox */
/*= require underscore */
/*= require dropzone */
/*= require mousetrap */
@@ -41,6 +39,7 @@
/*= require date.format */
/*= require_directory ./behaviors */
/*= require_directory ./blob */
+/*= require_directory ./templates */
/*= require_directory ./commit */
/*= require_directory ./extensions */
/*= require_directory ./lib/utils */
@@ -152,7 +151,9 @@
});
});
$('.remove-row').bind('ajax:success', function() {
- return $(this).closest('li').fadeOut();
+ $(this).tooltip('destroy')
+ .closest('li')
+ .fadeOut();
});
$('.js-remove-tr').bind('ajax:before', function() {
return $(this).hide();
@@ -223,8 +224,14 @@
return $('.navbar-toggle').toggleClass('active');
});
$body.on("click", ".js-toggle-diff-comments", function(e) {
- $(this).toggleClass('active');
- $(this).closest(".diff-file").find(".notes_holder").toggle();
+ var $this = $(this);
+ $this.toggleClass('active');
+ var notesHolders = $this.closest('.diff-file').find('.notes_holder');
+ if ($this.hasClass('active')) {
+ notesHolders.show();
+ } else {
+ notesHolders.hide();
+ }
return e.preventDefault();
});
$document.off("click", '.js-confirm-danger');
@@ -287,7 +294,7 @@
$('.page-with-sidebar').toggleClass('page-sidebar-collapsed page-sidebar-expanded').removeClass('page-sidebar-pinned');
$('.navbar-fixed-top').removeClass('header-pinned-nav');
}
- return $document.off('click', '.js-nav-pin').on('click', '.js-nav-pin', function(e) {
+ $document.off('click', '.js-nav-pin').on('click', '.js-nav-pin', function(e) {
var $page, $pinBtn, $tooltip, $topNav, doPinNav, tooltipText;
e.preventDefault();
$pinBtn = $(e.currentTarget);
@@ -315,6 +322,8 @@
$tooltip.find('.tooltip-inner').text(tooltipText);
return $pinBtn.attr('title', tooltipText).tooltip('fixTitle');
});
- });
+ // Custom time ago
+ gl.utils.shortTimeAgo($('.js-short-timeago'));
+ });
}).call(this);
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index ea683b31f75..aee1c29eee3 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,5 +1,6 @@
(function() {
this.AwardsHandler = (function() {
+ const FROM_SENTENCE_REGEX = /(?:, and | and |, )/; //For separating lists produced by ruby's Array#toSentence
function AwardsHandler() {
this.aliases = gl.emojiAliases();
$(document).off('click', '.js-add-award').on('click', '.js-add-award', (function(_this) {
@@ -130,7 +131,7 @@
counter = $emojiButton.find('.js-counter');
counter.text(parseInt(counter.text()) + 1);
$emojiButton.addClass('active');
- this.addMeToUserList(votesBlock, emoji);
+ this.addYouToUserList(votesBlock, emoji);
return this.animateEmoji($emojiButton);
}
} else {
@@ -161,23 +162,11 @@
$emojiButton = votesBlock.find("[data-emoji=" + mutualVote + "]").parent();
isAlreadyVoted = $emojiButton.hasClass('active');
if (isAlreadyVoted) {
- this.showEmojiLoader($emojiButton);
- return this.addAward(votesBlock, awardUrl, mutualVote, false, function() {
- return $emojiButton.removeClass('is-loading');
- });
+ this.addAward(votesBlock, awardUrl, mutualVote, false);
}
}
};
- AwardsHandler.prototype.showEmojiLoader = function($emojiButton) {
- var $loader;
- $loader = $emojiButton.find('.fa-spinner');
- if (!$loader.length) {
- $emojiButton.append('<i class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>');
- }
- return $emojiButton.addClass('is-loading');
- };
-
AwardsHandler.prototype.isActive = function($emojiButton) {
return $emojiButton.hasClass('active');
};
@@ -188,11 +177,11 @@
counterNumber = parseInt(counter.text(), 10);
if (counterNumber > 1) {
counter.text(counterNumber - 1);
- this.removeMeFromUserList($emojiButton, emoji);
+ this.removeYouFromUserList($emojiButton, emoji);
} else if (emoji === 'thumbsup' || emoji === 'thumbsdown') {
$emojiButton.tooltip('destroy');
counter.text('0');
- this.removeMeFromUserList($emojiButton, emoji);
+ this.removeYouFromUserList($emojiButton, emoji);
if ($emojiButton.parents('.note').length) {
this.removeEmoji($emojiButton);
}
@@ -216,43 +205,48 @@
return $awardBlock.attr('data-original-title') || $awardBlock.attr('data-title') || '';
};
- AwardsHandler.prototype.removeMeFromUserList = function($emojiButton, emoji) {
+ AwardsHandler.prototype.toSentence = function(list) {
+ if(list.length <= 2){
+ return list.join(' and ');
+ }
+ else{
+ return list.slice(0, -1).join(', ') + ', and ' + list[list.length - 1];
+ }
+ };
+
+ AwardsHandler.prototype.removeYouFromUserList = function($emojiButton, emoji) {
var authors, awardBlock, newAuthors, originalTitle;
awardBlock = $emojiButton;
originalTitle = this.getAwardTooltip(awardBlock);
- authors = originalTitle.split(', ');
- authors.splice(authors.indexOf('me'), 1);
- newAuthors = authors.join(', ');
- awardBlock.closest('.js-emoji-btn').removeData('original-title').attr('data-original-title', newAuthors);
- return this.resetTooltip(awardBlock);
+ authors = originalTitle.split(FROM_SENTENCE_REGEX);
+ authors.splice(authors.indexOf('You'), 1);
+ return awardBlock
+ .closest('.js-emoji-btn')
+ .removeData('title')
+ .removeAttr('data-title')
+ .removeAttr('data-original-title')
+ .attr('title', this.toSentence(authors))
+ .tooltip('fixTitle');
};
- AwardsHandler.prototype.addMeToUserList = function(votesBlock, emoji) {
+ AwardsHandler.prototype.addYouToUserList = function(votesBlock, emoji) {
var awardBlock, origTitle, users;
awardBlock = this.findEmojiIcon(votesBlock, emoji).parent();
origTitle = this.getAwardTooltip(awardBlock);
users = [];
if (origTitle) {
- users = origTitle.trim().split(', ');
+ users = origTitle.trim().split(FROM_SENTENCE_REGEX);
}
- users.push('me');
- awardBlock.attr('title', users.join(', '));
- return this.resetTooltip(awardBlock);
- };
-
- AwardsHandler.prototype.resetTooltip = function(award) {
- var cb;
- award.tooltip('destroy');
- cb = function() {
- return award.tooltip();
- };
- return setTimeout(cb, 200);
+ users.unshift('You');
+ return awardBlock
+ .attr('title', this.toSentence(users))
+ .tooltip('fixTitle');
};
AwardsHandler.prototype.createEmoji_ = function(votesBlock, emoji) {
var $emojiButton, buttonHtml, emojiCssClass;
emojiCssClass = this.resolveNameToCssClass(emoji);
- buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='me' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>";
+ buttonHtml = "<button class='btn award-control js-emoji-btn has-tooltip active' title='You' data-placement='bottom'> <div class='icon emoji-icon " + emojiCssClass + "' data-emoji='" + emoji + "'></div> <span class='award-control-text js-counter'>1</span> </button>";
$emojiButton = $(buttonHtml);
$emojiButton.insertBefore(votesBlock.find('.js-award-holder')).find('.emoji-icon').data('emoji', emoji);
this.animateEmoji($emojiButton);
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 2cf0a6631b8..b0a37ef0e0a 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -9,6 +9,7 @@
}
this.onClick = bind(this.onClick, this);
this.dropdown = opts.dropdown, this.data = opts.data, this.pattern = opts.pattern, this.wrapper = opts.wrapper, this.editor = opts.editor, this.fileEndpoint = opts.fileEndpoint, this.$input = (ref = opts.$input) != null ? ref : $('#file_name');
+ this.dropdownIcon = $('.fa-chevron-down', this.dropdown);
this.buildDropdown();
this.bindEvents();
this.onFilenameUpdate();
@@ -60,11 +61,26 @@
return this.requestFile(item);
};
- TemplateSelector.prototype.requestFile = function(item) {};
+ TemplateSelector.prototype.requestFile = function(item) {
+ // This `requestFile` method is an abstract method that should
+ // be added by all subclasses.
+ };
- TemplateSelector.prototype.requestFileSuccess = function(file) {
+ TemplateSelector.prototype.requestFileSuccess = function(file, skipFocus) {
this.editor.setValue(file.content, 1);
- return this.editor.focus();
+ if (!skipFocus) this.editor.focus();
+ };
+
+ TemplateSelector.prototype.startLoadingSpinner = function() {
+ this.dropdownIcon
+ .addClass('fa-spinner fa-spin')
+ .removeClass('fa-chevron-down');
+ };
+
+ TemplateSelector.prototype.stopLoadingSpinner = function() {
+ this.dropdownIcon
+ .addClass('fa-chevron-down')
+ .removeClass('fa-spinner fa-spin');
};
return TemplateSelector;
diff --git a/app/assets/javascripts/blob_edit/blob_edit_bundle.js b/app/assets/javascripts/blob_edit/blob_edit_bundle.js
new file mode 100644
index 00000000000..2afef43f3d6
--- /dev/null
+++ b/app/assets/javascripts/blob_edit/blob_edit_bundle.js
@@ -0,0 +1,12 @@
+/*= require_tree . */
+
+(function() {
+ $(function() {
+ var url = $(".js-edit-blob-form").data("relative-url-root");
+ url += $(".js-edit-blob-form").data("assets-prefix");
+
+ var blob = new EditBlob(url, $('.js-edit-blob-form').data('blob-language'));
+ new NewCommitForm($('.js-edit-blob-form'));
+ });
+
+}).call(this);
diff --git a/app/assets/javascripts/blob/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 649c79daee8..649c79daee8 100644
--- a/app/assets/javascripts/blob/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6
new file mode 100644
index 00000000000..a612cf0f1ae
--- /dev/null
+++ b/app/assets/javascripts/boards/boards_bundle.js.es6
@@ -0,0 +1,57 @@
+//= require vue
+//= require vue-resource
+//= require Sortable
+//= require_tree ./models
+//= require_tree ./stores
+//= require_tree ./services
+//= require_tree ./mixins
+//= require ./components/board
+//= require ./components/new_list_dropdown
+//= require ./vue_resource_interceptor
+
+$(() => {
+ const $boardApp = document.getElementById('board-app'),
+ Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+
+ if (gl.IssueBoardsApp) {
+ gl.IssueBoardsApp.$destroy(true);
+ }
+
+ gl.IssueBoardsApp = new Vue({
+ el: $boardApp,
+ components: {
+ 'board': gl.issueBoards.Board
+ },
+ data: {
+ state: Store.state,
+ loading: true,
+ endpoint: $boardApp.dataset.endpoint,
+ disabled: $boardApp.dataset.disabled === 'true',
+ issueLinkBase: $boardApp.dataset.issueLinkBase
+ },
+ init: Store.create.bind(Store),
+ created () {
+ gl.boardService = new BoardService(this.endpoint);
+ },
+ ready () {
+ Store.disabled = this.disabled;
+ gl.boardService.all()
+ .then((resp) => {
+ resp.json().forEach((board) => {
+ const list = Store.addList(board);
+
+ if (list.type === 'done') {
+ list.position = Infinity;
+ } else if (list.type === 'backlog') {
+ list.position = -1;
+ }
+ });
+
+ Store.addBlankState();
+ this.loading = false;
+ });
+ }
+ });
+});
diff --git a/app/assets/javascripts/boards/components/board.js.es6 b/app/assets/javascripts/boards/components/board.js.es6
new file mode 100644
index 00000000000..d7f4107cb02
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board.js.es6
@@ -0,0 +1,81 @@
+//= require ./board_blank_state
+//= require ./board_delete
+//= require ./board_list
+
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.Board = Vue.extend({
+ components: {
+ 'board-list': gl.issueBoards.BoardList,
+ 'board-delete': gl.issueBoards.BoardDelete,
+ 'board-blank-state': gl.issueBoards.BoardBlankState
+ },
+ props: {
+ list: Object,
+ disabled: Boolean,
+ issueLinkBase: String
+ },
+ data () {
+ return {
+ query: '',
+ filters: Store.state.filters
+ };
+ },
+ watch: {
+ query () {
+ this.list.filters = this.getFilterData();
+ this.list.getIssues(true);
+ },
+ filters: {
+ handler () {
+ this.list.page = 1;
+ this.list.getIssues(true);
+ },
+ deep: true
+ }
+ },
+ methods: {
+ getFilterData () {
+ const filters = this.filters;
+ let queryData = { search: this.query };
+
+ Object.keys(filters).forEach((key) => { queryData[key] = filters[key]; });
+
+ return queryData;
+ }
+ },
+ ready () {
+ const options = gl.issueBoards.getBoardSortableDefaultOptions({
+ disabled: this.disabled,
+ group: 'boards',
+ draggable: '.is-draggable',
+ handle: '.js-board-handle',
+ onEnd: (e) => {
+ gl.issueBoards.onEnd();
+
+ if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
+ const order = this.sortable.toArray(),
+ $board = this.$parent.$refs.board[e.oldIndex + 1],
+ list = $board.list;
+
+ $board.$destroy(true);
+
+ this.$nextTick(() => {
+ Store.state.lists.splice(e.newIndex, 0, list);
+ Store.moveList(list, order);
+ });
+ }
+ }
+ });
+
+ this.sortable = Sortable.create(this.$el.parentNode, options);
+ },
+ beforeDestroy () {
+ Store.state.lists.$remove(this.list);
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_blank_state.js.es6 b/app/assets/javascripts/boards/components/board_blank_state.js.es6
new file mode 100644
index 00000000000..63d72d857d9
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_blank_state.js.es6
@@ -0,0 +1,49 @@
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardBlankState = Vue.extend({
+ data () {
+ return {
+ predefinedLabels: [
+ new ListLabel({ title: 'Development', color: '#5CB85C' }),
+ new ListLabel({ title: 'Testing', color: '#F0AD4E' }),
+ new ListLabel({ title: 'Production', color: '#FF5F00' }),
+ new ListLabel({ title: 'Ready', color: '#FF0000' })
+ ]
+ }
+ },
+ methods: {
+ addDefaultLists () {
+ this.clearBlankState();
+
+ this.predefinedLabels.forEach((label, i) => {
+ Store.addList({
+ title: label.title,
+ position: i,
+ list_type: 'label',
+ label: {
+ title: label.title,
+ color: label.color
+ }
+ });
+ });
+
+ // Save the labels
+ gl.boardService.generateDefaultLists()
+ .then((resp) => {
+ resp.json().forEach((listObj) => {
+ const list = Store.findList('title', listObj.title);
+
+ list.id = listObj.id;
+ list.label.id = listObj.label.id;
+ list.getIssues();
+ });
+ });
+ },
+ clearBlankState: Store.removeBlankState.bind(Store)
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_card.js.es6 b/app/assets/javascripts/boards/components/board_card.js.es6
new file mode 100644
index 00000000000..4a7cfeaeab2
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_card.js.es6
@@ -0,0 +1,43 @@
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardCard = Vue.extend({
+ props: {
+ list: Object,
+ issue: Object,
+ issueLinkBase: String,
+ disabled: Boolean,
+ index: Number
+ },
+ methods: {
+ filterByLabel (label, e) {
+ let labelToggleText = label.title;
+ const labelIndex = Store.state.filters['label_name'].indexOf(label.title);
+ $(e.target).tooltip('hide');
+
+ if (labelIndex === -1) {
+ Store.state.filters['label_name'].push(label.title);
+ $('.labels-filter').prepend(`<input type="hidden" name="label_name[]" value="${label.title}" />`);
+ } else {
+ Store.state.filters['label_name'].splice(labelIndex, 1);
+ labelToggleText = Store.state.filters['label_name'][0];
+ $(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove();
+ }
+
+ const selectedLabels = Store.state.filters['label_name'];
+ if (selectedLabels.length === 0) {
+ labelToggleText = 'Label';
+ } else if (selectedLabels.length > 1) {
+ labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`;
+ }
+
+ $('.labels-filter .dropdown-toggle-text').text(labelToggleText);
+
+ Store.updateFiltersUrl();
+ }
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_delete.js.es6 b/app/assets/javascripts/boards/components/board_delete.js.es6
new file mode 100644
index 00000000000..34653cd48ef
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_delete.js.es6
@@ -0,0 +1,19 @@
+(() => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardDelete = Vue.extend({
+ props: {
+ list: Object
+ },
+ methods: {
+ deleteBoard () {
+ $(this.$el).tooltip('hide');
+
+ if (confirm('Are you sure you want to delete this list?')) {
+ this.list.destroy();
+ }
+ }
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/board_list.js.es6 b/app/assets/javascripts/boards/components/board_list.js.es6
new file mode 100644
index 00000000000..a6644e9eb8c
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_list.js.es6
@@ -0,0 +1,87 @@
+//= require ./board_card
+
+(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardList = Vue.extend({
+ components: {
+ 'board-card': gl.issueBoards.BoardCard
+ },
+ props: {
+ disabled: Boolean,
+ list: Object,
+ issues: Array,
+ loading: Boolean,
+ issueLinkBase: String
+ },
+ data () {
+ return {
+ scrollOffset: 250,
+ filters: Store.state.filters
+ };
+ },
+ watch: {
+ filters: {
+ handler () {
+ this.list.loadingMore = false;
+ this.$els.list.scrollTop = 0;
+ },
+ deep: true
+ }
+ },
+ methods: {
+ listHeight () {
+ return this.$els.list.getBoundingClientRect().height;
+ },
+ scrollHeight () {
+ return this.$els.list.scrollHeight;
+ },
+ scrollTop () {
+ return this.$els.list.scrollTop + this.listHeight();
+ },
+ loadNextPage () {
+ const getIssues = this.list.nextPage();
+
+ if (getIssues) {
+ this.list.loadingMore = true;
+ getIssues.then(() => {
+ this.list.loadingMore = false;
+ });
+ }
+ },
+ },
+ ready () {
+ const options = gl.issueBoards.getBoardSortableDefaultOptions({
+ group: 'issues',
+ sort: false,
+ disabled: this.disabled,
+ onStart: (e) => {
+ const card = this.$refs.issue[e.oldIndex];
+
+ Store.moving.issue = card.issue;
+ Store.moving.list = card.list;
+
+ gl.issueBoards.onStart();
+ },
+ onAdd: (e) => {
+ gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue);
+ },
+ onRemove: (e) => {
+ this.$refs.issue[e.oldIndex].$destroy(true);
+ }
+ });
+
+ this.sortable = Sortable.create(this.$els.list, options);
+
+ // Scroll event on list to load more
+ this.$els.list.onscroll = () => {
+ if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) {
+ this.loadNextPage();
+ }
+ };
+ }
+ });
+})();
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6
new file mode 100644
index 00000000000..1a4d8157970
--- /dev/null
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6
@@ -0,0 +1,54 @@
+$(() => {
+ const Store = gl.issueBoards.BoardsStore;
+
+ $('.js-new-board-list').each(function () {
+ const $this = $(this);
+
+ new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('project-id'));
+
+ $this.glDropdown({
+ data(term, callback) {
+ $.get($this.attr('data-labels'))
+ .then((resp) => {
+ callback(resp);
+ });
+ },
+ renderRow (label) {
+ const active = Store.findList('title', label.title),
+ $li = $('<li />'),
+ $a = $('<a />', {
+ class: (active ? `is-active js-board-list-${active.id}` : ''),
+ text: label.title,
+ href: '#'
+ }),
+ $labelColor = $('<span />', {
+ class: 'dropdown-label-box',
+ style: `background-color: ${label.color}`
+ });
+
+ return $li.append($a.prepend($labelColor));
+ },
+ search: {
+ fields: ['title']
+ },
+ filterable: true,
+ selectable: true,
+ clicked (label, $el, e) {
+ e.preventDefault();
+
+ if (!Store.findList('title', label.title)) {
+ Store.new({
+ title: label.title,
+ position: Store.state.lists.length - 2,
+ list_type: 'label',
+ label: {
+ id: label.id,
+ title: label.title,
+ color: label.color
+ }
+ });
+ }
+ }
+ });
+ });
+});
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
new file mode 100644
index 00000000000..44addb3ea98
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6
@@ -0,0 +1,35 @@
+((w) => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.onStart = () => {
+ $('.has-tooltip').tooltip('hide')
+ .tooltip('disable');
+ document.body.classList.add('is-dragging');
+ };
+
+ gl.issueBoards.onEnd = () => {
+ $('.has-tooltip').tooltip('enable');
+ document.body.classList.remove('is-dragging');
+ };
+
+ gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch;
+
+ gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
+ let defaultSortOptions = {
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ fallbackOnBody: true,
+ ghostClass: 'is-ghost',
+ filter: '.has-tooltip',
+ delay: gl.issueBoards.touchEnabled ? 100 : 0,
+ scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100,
+ scrollSpeed: 20,
+ onStart: gl.issueBoards.onStart,
+ onEnd: gl.issueBoards.onEnd
+ }
+
+ Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; });
+ return defaultSortOptions;
+ };
+})(window);
diff --git a/app/assets/javascripts/boards/models/issue.js.es6 b/app/assets/javascripts/boards/models/issue.js.es6
new file mode 100644
index 00000000000..eb082103de9
--- /dev/null
+++ b/app/assets/javascripts/boards/models/issue.js.es6
@@ -0,0 +1,44 @@
+class ListIssue {
+ constructor (obj) {
+ this.id = obj.iid;
+ this.title = obj.title;
+ this.confidential = obj.confidential;
+ this.labels = [];
+
+ if (obj.assignee) {
+ this.assignee = new ListUser(obj.assignee);
+ }
+
+ obj.labels.forEach((label) => {
+ this.labels.push(new ListLabel(label));
+ });
+
+ this.priority = this.labels.reduce((max, label) => {
+ return (label.priority < max) ? label.priority : max;
+ }, Infinity);
+ }
+
+ addLabel (label) {
+ if (!this.findLabel(label)) {
+ this.labels.push(new ListLabel(label));
+ }
+ }
+
+ findLabel (findLabel) {
+ return this.labels.filter( label => label.title === findLabel.title )[0];
+ }
+
+ removeLabel (removeLabel) {
+ if (removeLabel) {
+ this.labels = this.labels.filter( label => removeLabel.title !== label.title );
+ }
+ }
+
+ removeLabels (labels) {
+ labels.forEach(this.removeLabel.bind(this));
+ }
+
+ getLists () {
+ return gl.issueBoards.BoardsStore.state.lists.filter( list => list.findIssue(this.id) );
+ }
+}
diff --git a/app/assets/javascripts/boards/models/label.js.es6 b/app/assets/javascripts/boards/models/label.js.es6
new file mode 100644
index 00000000000..583829552cd
--- /dev/null
+++ b/app/assets/javascripts/boards/models/label.js.es6
@@ -0,0 +1,10 @@
+class ListLabel {
+ constructor (obj) {
+ this.id = obj.id;
+ this.title = obj.title;
+ this.color = obj.color;
+ this.textColor = obj.text_color;
+ this.description = obj.description;
+ this.priority = (obj.priority !== null) ? obj.priority : Infinity;
+ }
+}
diff --git a/app/assets/javascripts/boards/models/list.js.es6 b/app/assets/javascripts/boards/models/list.js.es6
new file mode 100644
index 00000000000..be2b8c568a8
--- /dev/null
+++ b/app/assets/javascripts/boards/models/list.js.es6
@@ -0,0 +1,125 @@
+class List {
+ constructor (obj) {
+ this.id = obj.id;
+ this._uid = this.guid();
+ this.position = obj.position;
+ this.title = obj.title;
+ this.type = obj.list_type;
+ this.preset = ['backlog', 'done', 'blank'].indexOf(this.type) > -1;
+ this.filters = gl.issueBoards.BoardsStore.state.filters;
+ this.page = 1;
+ this.loading = true;
+ this.loadingMore = false;
+ this.issues = [];
+
+ if (obj.label) {
+ this.label = new ListLabel(obj.label);
+ }
+
+ if (this.type !== 'blank' && this.id) {
+ this.getIssues();
+ }
+ }
+
+ guid() {
+ const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1);
+ return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
+ }
+
+ save () {
+ return gl.boardService.createList(this.label.id)
+ .then((resp) => {
+ const data = resp.json();
+
+ this.id = data.id;
+ this.type = data.list_type;
+ this.position = data.position;
+
+ return this.getIssues();
+ });
+ }
+
+ destroy () {
+ gl.issueBoards.BoardsStore.state.lists.$remove(this);
+ gl.issueBoards.BoardsStore.updateNewListDropdown(this.id);
+
+ gl.boardService.destroyList(this.id);
+ }
+
+ update () {
+ gl.boardService.updateList(this.id, this.position);
+ }
+
+ nextPage () {
+ if (Math.floor(this.issues.length / 20) === this.page) {
+ this.page++;
+
+ return this.getIssues(false);
+ }
+ }
+
+ canSearch () {
+ return this.type === 'backlog';
+ }
+
+ getIssues (emptyIssues = true) {
+ const filters = this.filters;
+ let data = { page: this.page };
+
+ Object.keys(filters).forEach((key) => { data[key] = filters[key]; });
+
+ if (this.label) {
+ data.label_name = data.label_name.filter( label => label !== this.label.title );
+ }
+
+ if (emptyIssues) {
+ this.loading = true;
+ }
+
+ return gl.boardService.getIssuesForList(this.id, data)
+ .then((resp) => {
+ const data = resp.json();
+ this.loading = false;
+
+ if (emptyIssues) {
+ this.issues = [];
+ }
+
+ this.createIssues(data);
+ });
+ }
+
+ createIssues (data) {
+ data.forEach((issueObj) => {
+ this.addIssue(new ListIssue(issueObj));
+ });
+ }
+
+ addIssue (issue, listFrom) {
+ this.issues.push(issue);
+
+ if (this.label) {
+ issue.addLabel(this.label);
+ }
+
+ if (listFrom) {
+ gl.boardService.moveIssue(issue.id, listFrom.id, this.id);
+ }
+ }
+
+ findIssue (id) {
+ return this.issues.filter( issue => issue.id === id )[0];
+ }
+
+ removeIssue (removeIssue) {
+ this.issues = this.issues.filter((issue) => {
+ const matchesRemove = removeIssue.id === issue.id;
+
+ if (matchesRemove) {
+ issue.removeLabel(this.label);
+ }
+
+ return !matchesRemove;
+ });
+ }
+}
diff --git a/app/assets/javascripts/boards/models/user.js.es6 b/app/assets/javascripts/boards/models/user.js.es6
new file mode 100644
index 00000000000..904b3a68507
--- /dev/null
+++ b/app/assets/javascripts/boards/models/user.js.es6
@@ -0,0 +1,8 @@
+class ListUser {
+ constructor (user) {
+ this.id = user.id;
+ this.name = user.name;
+ this.username = user.username;
+ this.avatar = user.avatar_url;
+ }
+}
diff --git a/app/assets/javascripts/boards/services/board_service.js.es6 b/app/assets/javascripts/boards/services/board_service.js.es6
new file mode 100644
index 00000000000..9b80fb2e99f
--- /dev/null
+++ b/app/assets/javascripts/boards/services/board_service.js.es6
@@ -0,0 +1,61 @@
+class BoardService {
+ constructor (root) {
+ Vue.http.options.root = root;
+
+ this.lists = Vue.resource(`${root}/lists{/id}`, {}, {
+ generate: {
+ method: 'POST',
+ url: `${root}/lists/generate.json`
+ }
+ });
+ this.issue = Vue.resource(`${root}/issues{/id}`, {});
+ this.issues = Vue.resource(`${root}/lists{/id}/issues`, {});
+
+ Vue.http.interceptors.push((request, next) => {
+ request.headers['X-CSRF-Token'] = $.rails.csrfToken();
+ next();
+ });
+ }
+
+ all () {
+ return this.lists.get();
+ }
+
+ generateDefaultLists () {
+ return this.lists.generate({});
+ }
+
+ createList (label_id) {
+ return this.lists.save({}, {
+ list: {
+ label_id
+ }
+ });
+ }
+
+ updateList (id, position) {
+ return this.lists.update({ id }, {
+ list: {
+ position
+ }
+ });
+ }
+
+ destroyList (id) {
+ return this.lists.delete({ id });
+ }
+
+ getIssuesForList (id, filter = {}) {
+ let data = { id };
+ Object.keys(filter).forEach((key) => { data[key] = filter[key]; });
+
+ return this.issues.get(data);
+ }
+
+ moveIssue (id, from_list_id, to_list_id) {
+ return this.issue.update({ id }, {
+ from_list_id,
+ to_list_id
+ });
+ }
+};
diff --git a/app/assets/javascripts/boards/stores/boards_store.js.es6 b/app/assets/javascripts/boards/stores/boards_store.js.es6
new file mode 100644
index 00000000000..18f26a1f911
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/boards_store.js.es6
@@ -0,0 +1,112 @@
+(() => {
+ window.gl = window.gl || {};
+ window.gl.issueBoards = window.gl.issueBoards || {};
+
+ gl.issueBoards.BoardsStore = {
+ disabled: false,
+ state: {},
+ moving: {
+ issue: {},
+ list: {}
+ },
+ create () {
+ this.state.lists = [];
+ this.state.filters = {
+ author_id: gl.utils.getParameterValues('author_id')[0],
+ assignee_id: gl.utils.getParameterValues('assignee_id')[0],
+ milestone_title: gl.utils.getParameterValues('milestone_title')[0],
+ label_name: gl.utils.getParameterValues('label_name[]')
+ };
+ },
+ addList (listObj) {
+ const list = new List(listObj);
+ this.state.lists.push(list);
+
+ return list;
+ },
+ new (listObj) {
+ const list = this.addList(listObj),
+ backlogList = this.findList('type', 'backlog', 'backlog');
+
+ list
+ .save()
+ .then(() => {
+ // Remove any new issues from the backlog
+ // as they will be visible in the new list
+ list.issues.forEach(backlogList.removeIssue.bind(backlogList));
+ });
+ this.removeBlankState();
+ },
+ updateNewListDropdown (listId) {
+ $(`.js-board-list-${listId}`).removeClass('is-active');
+ },
+ shouldAddBlankState () {
+ // Decide whether to add the blank state
+ return !(this.state.lists.filter( list => list.type !== 'backlog' && list.type !== 'done' )[0]);
+ },
+ addBlankState () {
+ if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
+
+ this.addList({
+ id: 'blank',
+ list_type: 'blank',
+ title: 'Welcome to your Issue Board!',
+ position: 0
+ });
+ },
+ removeBlankState () {
+ this.removeList('blank');
+
+ $.cookie('issue_board_welcome_hidden', 'true', {
+ expires: 365 * 10
+ });
+ },
+ welcomeIsHidden () {
+ return $.cookie('issue_board_welcome_hidden') === 'true';
+ },
+ removeList (id, type = 'blank') {
+ const list = this.findList('id', id, type);
+
+ if (!list) return;
+
+ this.state.lists = this.state.lists.filter( list => list.id !== id );
+ },
+ moveList (listFrom, orderLists) {
+ orderLists.forEach((id, i) => {
+ const list = this.findList('id', parseInt(id));
+
+ list.position = i;
+ });
+ listFrom.update();
+ },
+ moveIssueToList (listFrom, listTo, issue) {
+ const issueTo = listTo.findIssue(issue.id),
+ issueLists = issue.getLists(),
+ listLabels = issueLists.map( listIssue => listIssue.label );
+
+ // Add to new lists issues if it doesn't already exist
+ if (!issueTo) {
+ listTo.addIssue(issue, listFrom);
+ }
+
+ if (listTo.type === 'done' && listFrom.type !== 'backlog') {
+ issueLists.forEach((list) => {
+ list.removeIssue(issue);
+ })
+ issue.removeLabels(listLabels);
+ } else {
+ listFrom.removeIssue(issue);
+ }
+ },
+ findList (key, val, type = 'label') {
+ return this.state.lists.filter((list) => {
+ const byType = type ? list['type'] === type : true;
+
+ return list[key] === val && byType;
+ })[0];
+ },
+ updateFiltersUrl () {
+ history.pushState(null, null, `?${$.param(this.state.filters)}`);
+ }
+ };
+})();
diff --git a/app/assets/javascripts/boards/test_utils/simulate_drag.js b/app/assets/javascripts/boards/test_utils/simulate_drag.js
new file mode 100644
index 00000000000..75f8b730195
--- /dev/null
+++ b/app/assets/javascripts/boards/test_utils/simulate_drag.js
@@ -0,0 +1,119 @@
+(function () {
+ 'use strict';
+
+ function simulateEvent(el, type, options) {
+ var event;
+ if (!el) return;
+ var ownerDocument = el.ownerDocument;
+
+ options = options || {};
+
+ if (/^mouse/.test(type)) {
+ event = ownerDocument.createEvent('MouseEvents');
+ event.initMouseEvent(type, true, true, ownerDocument.defaultView,
+ options.button, options.screenX, options.screenY, options.clientX, options.clientY,
+ options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
+ } else {
+ event = ownerDocument.createEvent('CustomEvent');
+
+ event.initCustomEvent(type, true, true, ownerDocument.defaultView,
+ options.button, options.screenX, options.screenY, options.clientX, options.clientY,
+ options.ctrlKey, options.altKey, options.shiftKey, options.metaKey, options.button, el);
+
+ event.dataTransfer = {
+ data: {},
+
+ setData: function (type, val) {
+ this.data[type] = val;
+ },
+
+ getData: function (type) {
+ return this.data[type];
+ }
+ };
+ }
+
+ if (el.dispatchEvent) {
+ el.dispatchEvent(event);
+ } else if (el.fireEvent) {
+ el.fireEvent('on' + type, event);
+ }
+
+ return event;
+ }
+
+ function getTraget(target) {
+ var el = typeof target.el === 'string' ? document.getElementById(target.el.substr(1)) : target.el;
+ var children = el.children;
+
+ return (
+ children[target.index] ||
+ children[target.index === 'first' ? 0 : -1] ||
+ children[target.index === 'last' ? children.length - 1 : -1]
+ );
+ }
+
+ function getRect(el) {
+ var rect = el.getBoundingClientRect();
+ var width = rect.right - rect.left;
+ var height = rect.bottom - rect.top;
+
+ return {
+ x: rect.left,
+ y: rect.top,
+ cx: rect.left + width / 2,
+ cy: rect.top + height / 2,
+ w: width,
+ h: height,
+ hw: width / 2,
+ wh: height / 2
+ };
+ }
+
+ function simulateDrag(options, callback) {
+ options.to.el = options.to.el || options.from.el;
+
+ var fromEl = getTraget(options.from);
+ var toEl = getTraget(options.to);
+ var scrollable = options.scrollable;
+
+ var fromRect = getRect(fromEl);
+ var toRect = getRect(toEl);
+
+ var startTime = new Date().getTime();
+ var duration = options.duration || 1000;
+ simulateEvent(fromEl, 'mousedown', {button: 0});
+ options.ontap && options.ontap();
+ window.SIMULATE_DRAG_ACTIVE = 1;
+
+ var dragInterval = setInterval(function loop() {
+ var progress = (new Date().getTime() - startTime) / duration;
+ var x = (fromRect.cx + (toRect.cx - fromRect.cx) * progress) - scrollable.scrollLeft;
+ var y = (fromRect.cy + (toRect.cy - fromRect.cy) * progress) - scrollable.scrollTop;
+ var overEl = fromEl.ownerDocument.elementFromPoint(x, y);
+
+ simulateEvent(overEl, 'mousemove', {
+ clientX: x,
+ clientY: y
+ });
+
+ if (progress >= 1) {
+ options.ondragend && options.ondragend();
+ simulateEvent(toEl, 'mouseup');
+ clearInterval(dragInterval);
+ window.SIMULATE_DRAG_ACTIVE = 0;
+ }
+ }, 100);
+
+ return {
+ target: fromEl,
+ fromList: fromEl.parentNode,
+ toList: toEl.parentNode
+ };
+ }
+
+
+ // Export
+ window.simulateEvent = simulateEvent;
+ window.simulateDrag = simulateDrag;
+})();
diff --git a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6
new file mode 100644
index 00000000000..f9f9f7999d4
--- /dev/null
+++ b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6
@@ -0,0 +1,10 @@
+Vue.http.interceptors.push((request, next) => {
+ Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1;
+
+ Vue.nextTick(() => {
+ setTimeout(() => {
+ Vue.activeResources--;
+ }, 500);
+ });
+ next();
+});
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index e135cb92a30..0d7d29bb0d0 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -6,19 +6,26 @@
Build.state = null;
- function Build(page_url, build_url, build_status, state1) {
- this.page_url = page_url;
- this.build_url = build_url;
- this.build_status = build_status;
- this.state = state1;
+ function Build(options) {
+ this.page_url = options.page_url;
+ this.build_url = options.build_url;
+ this.build_status = options.build_status;
+ this.state = options.state1;
+ this.build_stage = options.build_stage;
this.hideSidebar = bind(this.hideSidebar, this);
this.toggleSidebar = bind(this.toggleSidebar, this);
+ this.updateDropdown = bind(this.updateDropdown, this);
clearInterval(Build.interval);
this.bp = Breakpoints.get();
- this.hideSidebar();
$('.js-build-sidebar').niceScroll();
+
+ this.populateJobs(this.build_stage);
+ this.updateStageDropdownText(this.build_stage);
+ this.hideSidebar();
+
$(document).off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
$(window).off('resize.build').on('resize.build', this.hideSidebar);
+ $(document).off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown);
this.updateArtifactRemoveDate();
if ($('#build-trace').length) {
this.getInitialBuildTrace();
@@ -128,10 +135,26 @@
$date = $('.js-artifacts-remove');
if ($date.length) {
date = $date.text();
- return $date.text($.timefor(new Date(date), ' '));
+ return $date.text($.timefor(new Date(date.replace(/-/g, '/')), ' '));
}
};
+ Build.prototype.populateJobs = function(stage) {
+ $('.build-job').hide();
+ $('.build-job[data-stage="' + stage + '"]').show();
+ };
+
+ Build.prototype.updateStageDropdownText = function(stage) {
+ $('.stage-selection').text(stage);
+ };
+
+ Build.prototype.updateDropdown = function(e) {
+ e.preventDefault();
+ var stage = e.currentTarget.text;
+ this.updateStageDropdownText(stage);
+ this.populateJobs(stage);
+ };
+
return Build;
})();
diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js
index c82798cc6a5..c43af17442b 100644
--- a/app/assets/javascripts/copy_to_clipboard.js
+++ b/app/assets/javascripts/copy_to_clipboard.js
@@ -34,6 +34,7 @@
$(function() {
var clipboard;
+
clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess);
return clipboard.on('error', genericError);
diff --git a/app/assets/javascripts/create_label.js.es6 b/app/assets/javascripts/create_label.js.es6
new file mode 100644
index 00000000000..46d1c3f00c1
--- /dev/null
+++ b/app/assets/javascripts/create_label.js.es6
@@ -0,0 +1,126 @@
+(function (w) {
+ class CreateLabelDropdown {
+ constructor ($el, projectId) {
+ this.$el = $el;
+ this.projectId = projectId;
+ this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown'));
+ this.$cancelButton = $('.js-cancel-label-btn', this.$el);
+ this.$newLabelField = $('#new_label_name', this.$el);
+ this.$newColorField = $('#new_label_color', this.$el);
+ this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el);
+ this.$newLabelError = $('.js-label-error', this.$el);
+ this.$newLabelCreateButton = $('.js-new-label-btn', this.$el);
+ this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el);
+
+ this.$newLabelError.hide();
+ this.$newLabelCreateButton.disable();
+
+ this.cleanBinding();
+ this.addBinding();
+ }
+
+ cleanBinding () {
+ this.$colorSuggestions.off('click');
+ this.$newLabelField.off('keyup change');
+ this.$newColorField.off('keyup change');
+ this.$dropdownBack.off('click');
+ this.$cancelButton.off('click');
+ this.$newLabelCreateButton.off('click');
+ }
+
+ addBinding () {
+ const self = this;
+
+ this.$colorSuggestions.on('click', function (e) {
+ const $this = $(this);
+ self.addColorValue(e, $this);
+ });
+
+ this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this));
+ this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this));
+
+ this.$dropdownBack.on('click', this.resetForm.bind(this));
+
+ this.$cancelButton.on('click', function(e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ self.resetForm();
+ self.$dropdownBack.trigger('click');
+ });
+
+ this.$newLabelCreateButton.on('click', this.saveLabel.bind(this));
+ }
+
+ addColorValue (e, $this) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.$newColorField.val($this.data('color')).trigger('change');
+ this.$colorPreview
+ .css('background-color', $this.data('color'))
+ .parent()
+ .addClass('is-active');
+ }
+
+ enableLabelCreateButton () {
+ if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') {
+ this.$newLabelError.hide();
+ this.$newLabelCreateButton.enable();
+ } else {
+ this.$newLabelCreateButton.disable();
+ }
+ }
+
+ resetForm () {
+ this.$newLabelField
+ .val('')
+ .trigger('change');
+
+ this.$newColorField
+ .val('')
+ .trigger('change');
+
+ this.$colorPreview
+ .css('background-color', '')
+ .parent()
+ .removeClass('is-active');
+ }
+
+ saveLabel (e) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ Api.newLabel(this.projectId, {
+ name: this.$newLabelField.val(),
+ color: this.$newColorField.val()
+ }, (label) => {
+ this.$newLabelCreateButton.enable();
+
+ if (label.message) {
+ let errors;
+
+ if (typeof label.message === 'string') {
+ errors = label.message;
+ } else {
+ errors = label.message.map(function (value, key) {
+ return key + " " + value[0];
+ }).join("<br/>");
+ }
+
+ this.$newLabelError
+ .html(errors)
+ .show();
+ } else {
+ this.$dropdownBack.trigger('click');
+ }
+ });
+ }
+ }
+
+ if (!w.gl) {
+ w.gl = {};
+ }
+
+ gl.CreateLabelDropdown = CreateLabelDropdown;
+})(window);
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 298f3852085..3dd7ceba92f 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -10,7 +10,7 @@
$(document).off('click', '.js-unfold');
$(document).on('click', '.js-unfold', (function(_this) {
return function(event) {
- var line_number, link, offset, old_line, params, prev_new_line, prev_old_line, ref, ref1, since, target, to, unfold, unfoldBottom;
+ var line_number, link, file, offset, old_line, params, prev_new_line, prev_old_line, ref, ref1, since, target, to, unfold, unfoldBottom;
target = $(event.target);
unfoldBottom = target.hasClass('js-unfold-bottom');
unfold = true;
@@ -31,14 +31,16 @@
unfold = false;
}
}
- link = target.parents('.diff-file').attr('data-blob-diff-path');
+ file = target.parents('.diff-file');
+ link = file.data('blob-diff-path');
params = {
since: since,
to: to,
bottom: unfoldBottom,
offset: offset,
unfold: unfold,
- indent: 1
+ indent: 1,
+ view: file.data('view')
};
return $.get(link, params, function(response) {
return target.parent().replaceWith(response);
@@ -48,26 +50,13 @@
}
Diff.prototype.lineNumbers = function(line) {
- var i, l, len, line_number, line_numbers, lines, results;
if (!line.children().length) {
return [0, 0];
}
- lines = line.children().slice(0, 2);
- line_numbers = (function() {
- var i, len, results;
- results = [];
- for (i = 0, len = lines.length; i < len; i++) {
- l = lines[i];
- results.push($(l).attr('data-linenumber'));
- }
- return results;
- })();
- results = [];
- for (i = 0, len = line_numbers.length; i < len; i++) {
- line_number = line_numbers[i];
- results.push(parseInt(line_number));
- }
- return results;
+
+ return line.find('.diff-line-num').map(function() {
+ return parseInt($(this).data('linenumber'));
+ });
};
return Diff;
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6
new file mode 100644
index 00000000000..48bc7d77805
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6
@@ -0,0 +1,49 @@
+((w) => {
+ w.CommentAndResolveBtn = Vue.extend({
+ props: {
+ discussionId: String,
+ textareaIsEmpty: Boolean
+ },
+ computed: {
+ discussion: function () {
+ return CommentsStore.state[this.discussionId];
+ },
+ showButton: function () {
+ if (this.discussion) {
+ return this.discussion.isResolvable();
+ } else {
+ return false;
+ }
+ },
+ isDiscussionResolved: function () {
+ return this.discussion.isResolved();
+ },
+ buttonText: function () {
+ if (this.isDiscussionResolved) {
+ if (this.textareaIsEmpty) {
+ return "Unresolve discussion";
+ } else {
+ return "Comment & unresolve discussion";
+ }
+ } else {
+ if (this.textareaIsEmpty) {
+ return "Resolve discussion";
+ } else {
+ return "Comment & resolve discussion";
+ }
+ }
+ }
+ },
+ ready: function () {
+ const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`);
+ this.textareaIsEmpty = $textarea.val() === '';
+
+ $textarea.on('input.comment-and-resolve-btn', () => {
+ this.textareaIsEmpty = $textarea.val() === '';
+ });
+ },
+ destroyed: function () {
+ $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn');
+ }
+ });
+})(window);
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6
new file mode 100644
index 00000000000..ad80d1118df
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6
@@ -0,0 +1,188 @@
+(() => {
+ JumpToDiscussion = Vue.extend({
+ mixins: [DiscussionMixins],
+ props: {
+ discussionId: String
+ },
+ data: function () {
+ return {
+ discussions: CommentsStore.state,
+ };
+ },
+ computed: {
+ discussion: function () {
+ return this.discussions[this.discussionId];
+ },
+ allResolved: function () {
+ return this.unresolvedDiscussionCount === 0;
+ },
+ showButton: function () {
+ if (this.discussionId) {
+ if (this.unresolvedDiscussionCount > 1) {
+ return true;
+ } else {
+ return this.discussionId !== this.lastResolvedId;
+ }
+ } else {
+ return this.unresolvedDiscussionCount >= 1;
+ }
+ },
+ lastResolvedId: function () {
+ let lastId;
+ for (const discussionId in this.discussions) {
+ const discussion = this.discussions[discussionId];
+
+ if (!discussion.isResolved()) {
+ lastId = discussion.id;
+ }
+ }
+ return lastId;
+ }
+ },
+ methods: {
+ jumpToNextUnresolvedDiscussion: function () {
+ let discussionsSelector,
+ discussionIdsInScope,
+ firstUnresolvedDiscussionId,
+ nextUnresolvedDiscussionId,
+ activeTab = window.mrTabs.currentAction,
+ hasDiscussionsToJumpTo = true,
+ jumpToFirstDiscussion = !this.discussionId;
+
+ const discussionIdsForElements = function(elements) {
+ return elements.map(function() {
+ return $(this).attr('data-discussion-id');
+ }).toArray();
+ };
+
+ const discussions = this.discussions;
+
+ if (activeTab === 'diffs') {
+ discussionsSelector = '.diffs .notes[data-discussion-id]';
+ discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
+
+ let unresolvedDiscussionCount = 0;
+
+ for (let i = 0; i < discussionIdsInScope.length; i++) {
+ const discussionId = discussionIdsInScope[i];
+ const discussion = discussions[discussionId];
+ if (discussion && !discussion.isResolved()) {
+ unresolvedDiscussionCount++;
+ }
+ }
+
+ if (this.discussionId && !this.discussion.isResolved()) {
+ // If this is the last unresolved discussion on the diffs tab,
+ // there are no discussions to jump to.
+ if (unresolvedDiscussionCount === 1) {
+ hasDiscussionsToJumpTo = false;
+ }
+ } else {
+ // If there are no unresolved discussions on the diffs tab at all,
+ // there are no discussions to jump to.
+ if (unresolvedDiscussionCount === 0) {
+ hasDiscussionsToJumpTo = false;
+ }
+ }
+ } else if (activeTab !== 'notes') {
+ // If we are on the commits or builds tabs,
+ // there are no discussions to jump to.
+ hasDiscussionsToJumpTo = false;
+ }
+
+ if (!hasDiscussionsToJumpTo) {
+ // If there are no discussions to jump to on the current page,
+ // switch to the notes tab and jump to the first disucssion there.
+ window.mrTabs.activateTab('notes');
+ activeTab = 'notes';
+ jumpToFirstDiscussion = true;
+ }
+
+ if (activeTab === 'notes') {
+ discussionsSelector = '.discussion[data-discussion-id]';
+ discussionIdsInScope = discussionIdsForElements($(discussionsSelector));
+ }
+
+ let currentDiscussionFound = false;
+ for (let i = 0; i < discussionIdsInScope.length; i++) {
+ const discussionId = discussionIdsInScope[i];
+ const discussion = discussions[discussionId];
+
+ if (!discussion) {
+ // Discussions for comments on commits in this MR don't have a resolved status.
+ continue;
+ }
+
+ if (!firstUnresolvedDiscussionId && !discussion.isResolved()) {
+ firstUnresolvedDiscussionId = discussionId;
+
+ if (jumpToFirstDiscussion) {
+ break;
+ }
+ }
+
+ if (!jumpToFirstDiscussion) {
+ if (currentDiscussionFound) {
+ if (!discussion.isResolved()) {
+ nextUnresolvedDiscussionId = discussionId;
+ break;
+ }
+ else {
+ continue;
+ }
+ }
+
+ if (discussionId === this.discussionId) {
+ currentDiscussionFound = true;
+ }
+ }
+ }
+
+ nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId;
+
+ if (!nextUnresolvedDiscussionId) {
+ return;
+ }
+
+ let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`);
+
+ if (activeTab === 'notes') {
+ $target = $target.closest('.note-discussion');
+
+ // If the next discussion is closed, toggle it open.
+ if ($target.find('.js-toggle-content').is(':hidden')) {
+ $target.find('.js-toggle-button i').trigger('click')
+ }
+ } else if (activeTab === 'diffs') {
+ // Resolved discussions are hidden in the diffs tab by default.
+ // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab.
+ // When jumping between unresolved discussions on the diffs tab, we show them.
+ $target.closest(".content").show();
+
+ $target = $target.closest("tr.notes_holder");
+ $target.show();
+
+ // If we are on the diffs tab, we don't scroll to the discussion itself, but to
+ // 4 diff lines above it: the line the discussion was in response to + 3 context
+ let prevEl;
+ for (let i = 0; i < 4; i++) {
+ prevEl = $target.prev();
+
+ // If the discussion doesn't have 4 lines above it, we'll have to do with fewer.
+ if (!prevEl.hasClass("line_holder")) {
+ break;
+ }
+
+ $target = prevEl;
+ }
+ }
+
+ $.scrollTo($target, {
+ offset: -($('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight())
+ });
+ }
+ }
+ });
+
+ Vue.component('jump-to-discussion', JumpToDiscussion);
+})();
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6
new file mode 100644
index 00000000000..be6ebc77947
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6
@@ -0,0 +1,107 @@
+((w) => {
+ w.ResolveBtn = Vue.extend({
+ mixins: [
+ ButtonMixins
+ ],
+ props: {
+ noteId: Number,
+ discussionId: String,
+ resolved: Boolean,
+ namespacePath: String,
+ projectPath: String,
+ canResolve: Boolean,
+ resolvedBy: String
+ },
+ data: function () {
+ return {
+ discussions: CommentsStore.state,
+ loading: false
+ };
+ },
+ watch: {
+ 'discussions': {
+ handler: 'updateTooltip',
+ deep: true
+ }
+ },
+ computed: {
+ discussion: function () {
+ return this.discussions[this.discussionId];
+ },
+ note: function () {
+ if (this.discussion) {
+ return this.discussion.getNote(this.noteId);
+ } else {
+ return undefined;
+ }
+ },
+ buttonText: function () {
+ if (this.isResolved) {
+ return `Resolved by ${this.resolvedByName}`;
+ } else if (this.canResolve) {
+ return 'Mark as resolved';
+ } else {
+ return 'Unable to resolve';
+ }
+ },
+ isResolved: function () {
+ if (this.note) {
+ return this.note.resolved;
+ } else {
+ return false;
+ }
+ },
+ resolvedByName: function () {
+ return this.note.resolved_by;
+ },
+ },
+ methods: {
+ updateTooltip: function () {
+ $(this.$els.button)
+ .tooltip('hide')
+ .tooltip('fixTitle');
+ },
+ resolve: function () {
+ if (!this.canResolve) return;
+
+ let promise;
+ this.loading = true;
+
+ if (this.isResolved) {
+ promise = ResolveService
+ .unresolve(this.namespace, this.noteId);
+ } else {
+ promise = ResolveService
+ .resolve(this.namespace, this.noteId);
+ }
+
+ promise.then((response) => {
+ this.loading = false;
+
+ if (response.status === 200) {
+ const data = response.json();
+ const resolved_by = data ? data.resolved_by : null;
+
+ CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by);
+ this.discussion.updateHeadline(data);
+ } else {
+ new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert');
+ }
+
+ this.$nextTick(this.updateTooltip);
+ });
+ }
+ },
+ compiled: function () {
+ $(this.$els.button).tooltip({
+ container: 'body'
+ });
+ },
+ beforeDestroy: function () {
+ CommentsStore.delete(this.discussionId, this.noteId);
+ },
+ created: function () {
+ CommentsStore.create(this.discussionId, this.noteId, this.canResolve, this.resolved, this.resolvedBy);
+ }
+ });
+})(window);
diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_count.js.es6
new file mode 100644
index 00000000000..9e383b14a3e
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/components/resolve_count.js.es6
@@ -0,0 +1,18 @@
+((w) => {
+ w.ResolveCount = Vue.extend({
+ mixins: [DiscussionMixins],
+ props: {
+ loggedOut: Boolean
+ },
+ data: function () {
+ return {
+ discussions: CommentsStore.state
+ };
+ },
+ computed: {
+ allResolved: function () {
+ return this.resolvedDiscussionCount === this.discussionCount;
+ }
+ }
+ });
+})(window);
diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6
new file mode 100644
index 00000000000..e373b06b1eb
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6
@@ -0,0 +1,60 @@
+((w) => {
+ w.ResolveDiscussionBtn = Vue.extend({
+ mixins: [
+ ButtonMixins
+ ],
+ props: {
+ discussionId: String,
+ mergeRequestId: Number,
+ namespacePath: String,
+ projectPath: String,
+ canResolve: Boolean,
+ },
+ data: function() {
+ return {
+ discussions: CommentsStore.state
+ };
+ },
+ computed: {
+ discussion: function () {
+ return this.discussions[this.discussionId];
+ },
+ showButton: function () {
+ if (this.discussion) {
+ return this.discussion.isResolvable();
+ } else {
+ return false;
+ }
+ },
+ isDiscussionResolved: function () {
+ if (this.discussion) {
+ return this.discussion.isResolved();
+ } else {
+ return false;
+ }
+ },
+ buttonText: function () {
+ if (this.isDiscussionResolved) {
+ return "Unresolve discussion";
+ } else {
+ return "Resolve discussion";
+ }
+ },
+ loading: function () {
+ if (this.discussion) {
+ return this.discussion.loading;
+ } else {
+ return false;
+ }
+ }
+ },
+ methods: {
+ resolve: function () {
+ ResolveService.toggleResolveForDiscussion(this.namespace, this.mergeRequestId, this.discussionId);
+ }
+ },
+ created: function () {
+ CommentsStore.createDiscussion(this.discussionId, this.canResolve);
+ }
+ });
+})(window);
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
new file mode 100644
index 00000000000..22d9cf6c857
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
@@ -0,0 +1,35 @@
+//= require vue
+//= require vue-resource
+//= require_directory ./models
+//= require_directory ./stores
+//= require_directory ./services
+//= require_directory ./mixins
+//= require_directory ./components
+
+$(() => {
+ window.DiffNotesApp = new Vue({
+ el: '#diff-notes-app',
+ components: {
+ 'resolve-btn': ResolveBtn,
+ 'resolve-discussion-btn': ResolveDiscussionBtn,
+ 'comment-and-resolve-btn': CommentAndResolveBtn
+ },
+ methods: {
+ compileComponents: function () {
+ const $components = $('resolve-btn, resolve-discussion-btn, jump-to-discussion');
+ if ($components.length) {
+ $components.each(function () {
+ DiffNotesApp.$compile($(this).get(0));
+ });
+ }
+ }
+ }
+ });
+
+ new Vue({
+ el: '#resolve-count-app',
+ components: {
+ 'resolve-count': ResolveCount
+ }
+ });
+});
diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 b/app/assets/javascripts/diff_notes/mixins/discussion.js.es6
new file mode 100644
index 00000000000..a05f885201d
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/mixins/discussion.js.es6
@@ -0,0 +1,35 @@
+((w) => {
+ w.DiscussionMixins = {
+ computed: {
+ discussionCount: function () {
+ return Object.keys(this.discussions).length;
+ },
+ resolvedDiscussionCount: function () {
+ let resolvedCount = 0;
+
+ for (const discussionId in this.discussions) {
+ const discussion = this.discussions[discussionId];
+
+ if (discussion.isResolved()) {
+ resolvedCount++;
+ }
+ }
+
+ return resolvedCount;
+ },
+ unresolvedDiscussionCount: function () {
+ let unresolvedCount = 0;
+
+ for (const discussionId in this.discussions) {
+ const discussion = this.discussions[discussionId];
+
+ if (!discussion.isResolved()) {
+ unresolvedCount++;
+ }
+ }
+
+ return unresolvedCount;
+ }
+ }
+ };
+})(window);
diff --git a/app/assets/javascripts/diff_notes/mixins/namespace.js.es6 b/app/assets/javascripts/diff_notes/mixins/namespace.js.es6
new file mode 100644
index 00000000000..d278678085b
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/mixins/namespace.js.es6
@@ -0,0 +1,9 @@
+((w) => {
+ w.ButtonMixins = {
+ computed: {
+ namespace: function () {
+ return `${this.namespacePath}/${this.projectPath}`;
+ }
+ }
+ };
+})(window);
diff --git a/app/assets/javascripts/diff_notes/models/discussion.js.es6 b/app/assets/javascripts/diff_notes/models/discussion.js.es6
new file mode 100644
index 00000000000..488714e4870
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/models/discussion.js.es6
@@ -0,0 +1,87 @@
+class DiscussionModel {
+ constructor (discussionId) {
+ this.id = discussionId;
+ this.notes = {};
+ this.loading = false;
+ this.canResolve = false;
+ }
+
+ createNote (noteId, canResolve, resolved, resolved_by) {
+ Vue.set(this.notes, noteId, new NoteModel(this.id, noteId, canResolve, resolved, resolved_by));
+ }
+
+ deleteNote (noteId) {
+ Vue.delete(this.notes, noteId);
+ }
+
+ getNote (noteId) {
+ return this.notes[noteId];
+ }
+
+ notesCount() {
+ return Object.keys(this.notes).length;
+ }
+
+ isResolved () {
+ for (const noteId in this.notes) {
+ const note = this.notes[noteId];
+
+ if (!note.resolved) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ resolveAllNotes (resolved_by) {
+ for (const noteId in this.notes) {
+ const note = this.notes[noteId];
+
+ if (!note.resolved) {
+ note.resolved = true;
+ note.resolved_by = resolved_by;
+ }
+ }
+ }
+
+ unResolveAllNotes () {
+ for (const noteId in this.notes) {
+ const note = this.notes[noteId];
+
+ if (note.resolved) {
+ note.resolved = false;
+ note.resolved_by = null;
+ }
+ }
+ }
+
+ updateHeadline (data) {
+ const $discussionHeadline = $(`.discussion[data-discussion-id="${this.id}"] .js-discussion-headline`);
+
+ if (data.discussion_headline_html) {
+ if ($discussionHeadline.length) {
+ $discussionHeadline.replaceWith(data.discussion_headline_html);
+ } else {
+ $(`.discussion[data-discussion-id="${this.id}"] .discussion-header`).append(data.discussion_headline_html);
+ }
+ } else {
+ $discussionHeadline.remove();
+ }
+ }
+
+ isResolvable () {
+ if (!this.canResolve) {
+ return false;
+ }
+
+ for (const noteId in this.notes) {
+ const note = this.notes[noteId];
+
+ if (note.canResolve) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/assets/javascripts/diff_notes/models/note.js.es6 b/app/assets/javascripts/diff_notes/models/note.js.es6
new file mode 100644
index 00000000000..f2d2d389c38
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/models/note.js.es6
@@ -0,0 +1,9 @@
+class NoteModel {
+ constructor (discussionId, noteId, canResolve, resolved, resolved_by) {
+ this.discussionId = discussionId;
+ this.id = noteId;
+ this.canResolve = canResolve;
+ this.resolved = resolved;
+ this.resolved_by = resolved_by;
+ }
+}
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js.es6 b/app/assets/javascripts/diff_notes/services/resolve.js.es6
new file mode 100644
index 00000000000..de771ff814b
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/services/resolve.js.es6
@@ -0,0 +1,88 @@
+((w) => {
+ class ResolveServiceClass {
+ constructor() {
+ this.noteResource = Vue.resource('notes{/noteId}/resolve');
+ this.discussionResource = Vue.resource('merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve');
+ }
+
+ setCSRF() {
+ Vue.http.headers.common['X-CSRF-Token'] = $.rails.csrfToken();
+ }
+
+ prepareRequest(namespace) {
+ this.setCSRF();
+ Vue.http.options.root = `/${namespace}`;
+ }
+
+ resolve(namespace, noteId) {
+ this.prepareRequest(namespace);
+
+ return this.noteResource.save({ noteId }, {});
+ }
+
+ unresolve(namespace, noteId) {
+ this.prepareRequest(namespace);
+
+ return this.noteResource.delete({ noteId }, {});
+ }
+
+ toggleResolveForDiscussion(namespace, mergeRequestId, discussionId) {
+ const discussion = CommentsStore.state[discussionId],
+ isResolved = discussion.isResolved();
+ let promise;
+
+ if (isResolved) {
+ promise = this.unResolveAll(namespace, mergeRequestId, discussionId);
+ } else {
+ promise = this.resolveAll(namespace, mergeRequestId, discussionId);
+ }
+
+ promise.then((response) => {
+ discussion.loading = false;
+
+ if (response.status === 200) {
+ const data = response.json();
+ const resolved_by = data ? data.resolved_by : null;
+
+ if (isResolved) {
+ discussion.unResolveAllNotes();
+ } else {
+ discussion.resolveAllNotes(resolved_by);
+ }
+
+ discussion.updateHeadline(data);
+ } else {
+ new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert');
+ }
+ })
+ }
+
+ resolveAll(namespace, mergeRequestId, discussionId) {
+ const discussion = CommentsStore.state[discussionId];
+
+ this.prepareRequest(namespace);
+
+ discussion.loading = true;
+
+ return this.discussionResource.save({
+ mergeRequestId,
+ discussionId
+ }, {});
+ }
+
+ unResolveAll(namespace, mergeRequestId, discussionId) {
+ const discussion = CommentsStore.state[discussionId];
+
+ this.prepareRequest(namespace);
+
+ discussion.loading = true;
+
+ return this.discussionResource.delete({
+ mergeRequestId,
+ discussionId
+ }, {});
+ }
+ }
+
+ w.ResolveService = new ResolveServiceClass();
+})(window);
diff --git a/app/assets/javascripts/diff_notes/stores/comments.js.es6 b/app/assets/javascripts/diff_notes/stores/comments.js.es6
new file mode 100644
index 00000000000..69522e1dac5
--- /dev/null
+++ b/app/assets/javascripts/diff_notes/stores/comments.js.es6
@@ -0,0 +1,53 @@
+((w) => {
+ w.CommentsStore = {
+ state: {},
+ get: function (discussionId, noteId) {
+ return this.state[discussionId].getNote(noteId);
+ },
+ createDiscussion: function (discussionId, canResolve) {
+ let discussion = this.state[discussionId];
+ if (!this.state[discussionId]) {
+ discussion = new DiscussionModel(discussionId);
+ Vue.set(this.state, discussionId, discussion);
+ }
+
+ if (canResolve !== undefined) {
+ discussion.canResolve = canResolve;
+ }
+
+ return discussion;
+ },
+ create: function (discussionId, noteId, canResolve, resolved, resolved_by) {
+ const discussion = this.createDiscussion(discussionId);
+
+ discussion.createNote(noteId, canResolve, resolved, resolved_by);
+ },
+ update: function (discussionId, noteId, resolved, resolved_by) {
+ const discussion = this.state[discussionId];
+ const note = discussion.getNote(noteId);
+ note.resolved = resolved;
+ note.resolved_by = resolved_by;
+ },
+ delete: function (discussionId, noteId) {
+ const discussion = this.state[discussionId];
+ discussion.deleteNote(noteId);
+
+ if (discussion.notesCount() === 0) {
+ Vue.delete(this.state, discussionId);
+ }
+ },
+ unresolvedDiscussionIds: function () {
+ let ids = [];
+
+ for (const discussionId in this.state) {
+ const discussion = this.state[discussionId];
+
+ if (!discussion.isResolved()) {
+ ids.push(discussion.id);
+ }
+ }
+
+ return ids;
+ }
+ };
+})(window);
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 9e6901962c6..ba64d2bcf0b 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -20,6 +20,9 @@
path = page.split(':');
shortcut_handler = null;
switch (page) {
+ case 'projects:boards:show':
+ shortcut_handler = new ShortcutsNavigation();
+ break;
case 'projects:issues:index':
Issuable.init();
new IssuableBulkActions();
@@ -55,6 +58,7 @@
shortcut_handler = new ShortcutsNavigation();
new GLForm($('.issue-form'));
new IssuableForm($('.issue-form'));
+ new IssuableTemplateSelectors();
break;
case 'projects:merge_requests:new':
case 'projects:merge_requests:edit':
@@ -62,6 +66,7 @@
shortcut_handler = new ShortcutsNavigation();
new GLForm($('.merge-request-form'));
new IssuableForm($('.merge-request-form'));
+ new IssuableTemplateSelectors();
break;
case 'projects:tags:new':
new ZenMode();
@@ -86,6 +91,8 @@
new ZenMode();
new MergedButtons();
break;
+ case "projects:merge_requests:conflicts":
+ window.mcui = new MergeConflictResolver()
case 'projects:merge_requests:index':
shortcut_handler = new ShortcutsNavigation();
Issuable.init();
@@ -122,10 +129,12 @@
new NotificationsDropdown();
break;
case 'groups:group_members:index':
+ new gl.MemberExpirationDate();
new GroupMembers();
new UsersSelect();
break;
case 'projects:project_members:index':
+ new gl.MemberExpirationDate();
new ProjectMembers();
new UsersSelect();
break;
@@ -167,14 +176,15 @@
new BuildArtifacts();
break;
case 'projects:group_links:index':
+ new gl.MemberExpirationDate();
new GroupsSelect();
break;
case 'search:show':
new Search();
break;
case 'projects:protected_branches:index':
- new ProtectedBranchesAccessSelect($(".new_protected_branch"), false, true);
- new ProtectedBranchesAccessSelect($(".protected-branches-list"), true, false);
+ new gl.ProtectedBranchCreate();
+ new gl.ProtectedBranchEditList();
break;
}
switch (path.first()) {
@@ -186,6 +196,15 @@
break;
case 'projects':
new NamespaceSelects();
+ break;
+ case 'labels':
+ switch (path[2]) {
+ case 'edit':
+ new Labels();
+ }
+ case 'abuse_reports':
+ new gl.AbuseReports();
+ break;
}
break;
case 'dashboard':
@@ -211,6 +230,7 @@
new ProjectNew();
break;
case 'show':
+ new Star();
new ProjectNew();
new ProjectShow();
new NotificationsDropdown();
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 288cce04f87..4a6fea929c7 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,5 +1,5 @@
-/*= require markdown_preview */
+/*= require preview_markdown */
(function() {
this.DropzoneInput = (function() {
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 09b5eb398d4..3fb3b1a8b51 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -33,18 +33,19 @@
this.render = bind(this.render, this);
this.VIEW_TYPE = $('input#view[type=hidden]').val();
debounce = _.debounce(this.render, DEBOUNCE_TIMEOUT_DURATION);
- $(document).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy);
+ $(this.filesContainerElement).off('mouseover', LINE_COLUMN_CLASSES).off('mouseleave', LINE_COLUMN_CLASSES).on('mouseover', LINE_COLUMN_CLASSES, debounce).on('mouseleave', LINE_COLUMN_CLASSES, this.destroy);
}
FilesCommentButton.prototype.render = function(e) {
var $currentTarget, buttonParentElement, lineContentElement, textFileElement;
$currentTarget = $(e.currentTarget);
+
buttonParentElement = this.getButtonParent($currentTarget);
- if (!this.shouldRender(e, buttonParentElement)) {
- return;
- }
- textFileElement = this.getTextFileElement($currentTarget);
+ if (!this.validateButtonParent(buttonParentElement)) return;
lineContentElement = this.getLineContent($currentTarget);
+ if (!this.validateLineContent(lineContentElement)) return;
+
+ textFileElement = this.getTextFileElement($currentTarget);
buttonParentElement.append(this.buildButton({
noteableType: textFileElement.attr('data-noteable-type'),
noteableID: textFileElement.attr('data-noteable-id'),
@@ -119,10 +120,14 @@
return newButtonParent.is(this.getButtonParent($(e.currentTarget)));
};
- FilesCommentButton.prototype.shouldRender = function(e, buttonParentElement) {
+ FilesCommentButton.prototype.validateButtonParent = function(buttonParentElement) {
return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS) && $(COMMENT_BUTTON_CLASS, buttonParentElement).length === 0;
};
+ FilesCommentButton.prototype.validateLineContent = function(lineContentElement) {
+ return lineContentElement.attr('data-discussion-id') && lineContentElement.attr('data-discussion-id') !== '';
+ };
+
return FilesCommentButton;
})();
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js.es6
index 41f4c1914f2..3dca06d36b1 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js.es6
@@ -47,8 +47,8 @@
}
}
},
- setup: function(wrap) {
- this.input = $('.js-gfm-input');
+ setup: function(input) {
+ this.input = input || $('.js-gfm-input');
this.destroyAtWho();
this.setupAtWho();
if (this.dataSource) {
@@ -223,7 +223,7 @@
}
}
});
- return this.input.atwho({
+ this.input.atwho({
at: '~',
alias: 'labels',
searchKey: 'search',
@@ -249,6 +249,68 @@
}
}
});
+ // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
+ this.input.filter('[data-supports-slash-commands="true"]').atwho({
+ at: '/',
+ alias: 'commands',
+ searchKey: 'search',
+ displayTpl: function(value) {
+ var tpl = '<li>/${name}';
+ if (value.aliases.length > 0) {
+ tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
+ }
+ if (value.params.length > 0) {
+ tpl += ' <small><%- params.join(" ") %></small>';
+ }
+ if (value.description !== '') {
+ tpl += '<small class="description"><i><%- description %></i></small>';
+ }
+ tpl += '</li>';
+ return _.template(tpl)(value);
+ },
+ insertTpl: function(value) {
+ var tpl = "/${name} ";
+ var reference_prefix = null;
+ if (value.params.length > 0) {
+ reference_prefix = value.params[0][0];
+ if (/^[@%~]/.test(reference_prefix)) {
+ tpl += '<%- reference_prefix %>';
+ }
+ }
+ return _.template(tpl)({ reference_prefix: reference_prefix });
+ },
+ suffix: '',
+ callbacks: {
+ sorter: this.DefaultOptions.sorter,
+ filter: this.DefaultOptions.filter,
+ beforeInsert: this.DefaultOptions.beforeInsert,
+ beforeSave: function(commands) {
+ return $.map(commands, function(c) {
+ var search = c.name;
+ if (c.aliases.length > 0) {
+ search = search + " " + c.aliases.join(" ");
+ }
+ return {
+ name: c.name,
+ aliases: c.aliases,
+ params: c.params,
+ description: c.description,
+ search: search
+ };
+ });
+ },
+ matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
+ var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi
+ var match = regexp.exec(subtext);
+ if (match) {
+ return match[1];
+ } else {
+ return null;
+ }
+ }
+ }
+ });
+ return;
},
destroyAtWho: function() {
return this.input.atwho('destroy');
@@ -265,6 +327,7 @@
this.input.atwho('load', 'mergerequests', data.mergerequests);
this.input.atwho('load', ':', data.emojis);
this.input.atwho('load', '~', data.labels);
+ this.input.atwho('load', '/', data.commands);
return $(':focus').trigger('keyup');
}
};
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index c5d92831fbe..0179b320a3b 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -28,38 +28,42 @@
};
})(this));
timeout = "";
- this.input.on("keyup", (function(_this) {
- return function(e) {
+ this.input
+ .on('keydown', function (e) {
+ var keyCode = e.which;
+ if (keyCode === 13 && !options.elIsInput) {
+ e.preventDefault()
+ }
+ })
+ .on('keyup', function(e) {
var keyCode;
keyCode = e.which;
if (ARROW_KEY_CODES.indexOf(keyCode) >= 0) {
return;
}
- if (_this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ if (this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) {
$inputContainer.addClass(HAS_VALUE_CLASS);
- } else if (_this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
+ } else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) {
$inputContainer.removeClass(HAS_VALUE_CLASS);
}
- if (keyCode === 13) {
+ if (keyCode === 13 && !options.elIsInput) {
return false;
}
- if (_this.options.remote) {
+ if (this.options.remote) {
clearTimeout(timeout);
return timeout = setTimeout(function() {
- var blur_field;
- blur_field = _this.shouldBlur(keyCode);
- if (blur_field && _this.filterInputBlur) {
- _this.input.blur();
+ var blurField = this.shouldBlur(keyCode);
+ if (blurField && this.filterInputBlur) {
+ this.input.blur();
}
- return _this.options.query(_this.input.val(), function(data) {
- return _this.options.callback(data);
- });
- }, 250);
+ return this.options.query(this.input.val(), function(data) {
+ return this.options.callback(data);
+ }.bind(this));
+ }.bind(this), 250);
} else {
- return _this.filter(_this.input.val());
+ return this.filter(this.input.val());
}
- };
- })(this));
+ }.bind(this));
}
GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) {
@@ -106,9 +110,9 @@
matches = fuzzaldrinPlus.match($el.text().trim(), search_text);
if (!$el.is('.dropdown-header')) {
if (matches.length) {
- return $el.show();
+ return $el.show().removeClass('option-hidden');
} else {
- return $el.hide();
+ return $el.hide().addClass('option-hidden');
}
}
});
@@ -174,7 +178,7 @@
})();
GitLabDropdown = (function() {
- var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, currentIndex;
+ var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, currentIndex;
LOADING_CLASS = "is-loading";
@@ -186,6 +190,12 @@
currentIndex = -1;
+ NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link, .option-hidden';
+
+ SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ")";
+
+ CURSOR_SELECT_SCROLL_PADDING = 5
+
FILTER_INPUT = '.dropdown-input .dropdown-input-field';
function GitLabDropdown(el1, options) {
@@ -208,6 +218,7 @@
if (this.options.data) {
if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) {
this.fullData = this.options.data;
+ currentIndex = -1;
this.parseData(this.options.data);
} else {
this.remote = new GitLabDropdownRemote(this.options.data, {
@@ -227,6 +238,7 @@
}
if (this.options.filterable) {
this.filter = new GitLabDropdownFilter(this.filterInput, {
+ elIsInput: $(this.el).is('input'),
filterInputBlur: this.filterInputBlur,
filterByText: this.options.filterByText,
onFilter: this.options.onFilter,
@@ -235,7 +247,7 @@
keys: searchFields,
elements: (function(_this) {
return function() {
- selector = '.dropdown-content li:not(.divider)';
+ selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
if (_this.dropdown.find('.dropdown-toggle-page').length) {
selector = ".dropdown-page-one " + selector;
}
@@ -251,12 +263,16 @@
return function(data) {
_this.parseData(data);
if (_this.filterInput.val() !== '') {
- selector = '.dropdown-content li:not(.divider):visible';
+ selector = SELECTABLE_CLASSES;
if (_this.dropdown.find('.dropdown-toggle-page').length) {
selector = ".dropdown-page-one " + selector;
}
- $(selector, _this.dropdown).first().find('a').addClass('is-focused');
- return currentIndex = 0;
+ if ($(_this.el).is('input')) {
+ currentIndex = -1;
+ } else {
+ $(selector, _this.dropdown).first().find('a').addClass('is-focused');
+ currentIndex = 0;
+ }
}
};
})(this)
@@ -371,7 +387,7 @@
var $target;
if (this.options.multiSelect) {
$target = $(e.target);
- if (!$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) {
+ if ($target && !$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && !$target.data('is-link')) {
e.stopPropagation();
return false;
} else {
@@ -382,6 +398,7 @@
GitLabDropdown.prototype.opened = function() {
var contentHtml;
+ this.resetRows();
this.addArrowKeyEvent();
if (this.options.setIndeterminateIds) {
this.options.setIndeterminateIds.call(this);
@@ -404,6 +421,7 @@
GitLabDropdown.prototype.hidden = function(e) {
var $input;
+ this.resetRows();
this.removeArrayKeyEvent();
$input = this.dropdown.find(".dropdown-input-field");
if (this.options.filterable) {
@@ -457,14 +475,15 @@
return "<li class='separator'></li>";
}
if (data.header != null) {
- return "<li class='dropdown-header'>" + data.header + "</li>";
+ return _.template('<li class="dropdown-header"><%- header %></li>')({ header: data.header });
}
if (this.options.renderRow) {
html = this.options.renderRow.call(this.options, data, this);
} else {
if (!selected) {
value = this.options.id ? this.options.id(data) : data.id;
- fieldName = this.options.fieldName;
+ fieldName = typeof this.options.fieldName === 'function' ? this.options.fieldName() : this.options.fieldName;
+
field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']");
if (field.length) {
selected = true;
@@ -488,11 +507,16 @@
text = this.highlightTextMatches(text, this.filterInput.val());
}
if (group) {
- groupAttrs = "data-group='" + group + "' data-index='" + index + "'";
+ groupAttrs = 'data-group=' + group + ' data-index=' + index;
} else {
groupAttrs = '';
}
- html = "<li> <a href='" + url + "' " + groupAttrs + " class='" + cssClass + "'> " + text + " </a> </li>";
+ html = _.template('<li><a href="<%- url %>" <%- groupAttrs %> class="<%- cssClass %>"><%= text %></a></li>')({
+ url: url,
+ groupAttrs: groupAttrs,
+ cssClass: cssClass,
+ text: text
+ });
}
return html;
};
@@ -514,20 +538,8 @@
return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>";
};
- GitLabDropdown.prototype.highlightRow = function(index) {
- var selector;
- if (this.filterInput.val() !== "") {
- selector = '.dropdown-content li:first-child a';
- if (this.dropdown.find(".dropdown-toggle-page").length) {
- selector = ".dropdown-page-one .dropdown-content li:first-child a";
- }
- return this.getElement(selector).addClass('is-focused');
- }
- };
-
GitLabDropdown.prototype.rowClicked = function(el) {
var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value;
- fieldName = this.options.fieldName;
isInput = $(this.el).is('input');
if (this.renderedData) {
groupName = el.data('group');
@@ -539,6 +551,7 @@
selectedObject = this.renderedData[selectedIndex];
}
}
+ fieldName = typeof this.options.fieldName === 'function' ? this.options.fieldName(selectedObject) : this.options.fieldName;
value = this.options.id ? this.options.id(selectedObject, el) : selectedObject.id;
if (isInput) {
field = $(this.el);
@@ -553,10 +566,9 @@
field.remove();
}
if (this.options.toggleLabel) {
- return this.updateLabel(selectedObject, el, this);
- } else {
- return selectedObject;
+ this.updateLabel(selectedObject, el, this);
}
+ return selectedObject;
} else if (el.hasClass(INDETERMINATE_CLASS)) {
el.addClass(ACTIVE_CLASS);
el.removeClass(INDETERMINATE_CLASS);
@@ -564,7 +576,7 @@
field.remove();
}
if (!field.length && fieldName) {
- this.addInput(fieldName, value);
+ this.addInput(fieldName, value, selectedObject);
}
return selectedObject;
} else {
@@ -583,7 +595,7 @@
}
if (value != null) {
if (!field.length && fieldName) {
- this.addInput(fieldName, value);
+ this.addInput(fieldName, value, selectedObject);
} else {
field.val(value).trigger('change');
}
@@ -592,26 +604,37 @@
}
};
- GitLabDropdown.prototype.addInput = function(fieldName, value) {
+ GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) {
var $input;
$input = $('<input>').attr('type', 'hidden').attr('name', fieldName).val(value);
if (this.options.inputId != null) {
$input.attr('id', this.options.inputId);
}
+ if (selectedObject && selectedObject.type) {
+ $input.attr('data-type', selectedObject.type);
+ }
return this.dropdown.before($input);
};
- GitLabDropdown.prototype.selectRowAtIndex = function(e, index) {
+ GitLabDropdown.prototype.selectRowAtIndex = function(index) {
var $el, selector;
- selector = ".dropdown-content li:not(.divider,.dropdown-header,.separator):eq(" + index + ") a";
+ // If we pass an option index
+ if (typeof index !== "undefined") {
+ selector = SELECTABLE_CLASSES + ":eq(" + index + ") a";
+ } else {
+ selector = ".dropdown-content .is-focused";
+ }
if (this.dropdown.find(".dropdown-toggle-page").length) {
selector = ".dropdown-page-one " + selector;
}
$el = $(selector, this.dropdown);
if ($el.length) {
- e.preventDefault();
- e.stopImmediatePropagation();
- return $el.first().trigger('click');
+ var href = $el.attr('href');
+ if (href && href !== '#') {
+ Turbolinks.visit(href);
+ } else {
+ $el.first().trigger('click');
+ }
}
};
@@ -619,7 +642,7 @@
var $input, ARROW_KEY_CODES, selector;
ARROW_KEY_CODES = [38, 40];
$input = this.dropdown.find(".dropdown-input-field");
- selector = '.dropdown-content li:not(.divider,.dropdown-header,.separator)';
+ selector = SELECTABLE_CLASSES;
if (this.dropdown.find(".dropdown-toggle-page").length) {
selector = ".dropdown-page-one " + selector;
}
@@ -647,7 +670,7 @@
return false;
}
if (currentKeyCode === 13 && currentIndex !== -1) {
- return _this.selectRowAtIndex(e, currentIndex);
+ _this.selectRowAtIndex();
}
};
})(this));
@@ -657,6 +680,11 @@
return $('body').off('keydown');
};
+ GitLabDropdown.prototype.resetRows = function resetRows() {
+ currentIndex = -1;
+ $('.is-focused', this.dropdown).removeClass('is-focused');
+ };
+
GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) {
var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop;
$('.is-focused', this.dropdown).removeClass('is-focused');
@@ -670,10 +698,14 @@
listItemHeight = $listItem.outerHeight();
listItemTop = $listItem.prop('offsetTop');
listItemBottom = listItemTop + listItemHeight;
- if (listItemBottom > dropdownContentBottom + dropdownScrollTop) {
- return $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom);
- } else if (listItemTop < dropdownContentTop + dropdownScrollTop) {
- return $dropdownContent.scrollTop(listItemTop - dropdownContentTop);
+ if (!index) {
+ $dropdownContent.scrollTop(0)
+ } else if (index === ($listItems.length - 1)) {
+ $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight'));
+ } else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) {
+ $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING);
+ } else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) {
+ return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING);
}
};
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 6ac7564a848..528a673eb15 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -21,7 +21,7 @@
this.form.find('.div-dropzone').remove();
this.form.addClass('gfm-form');
disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button'));
- GitLab.GfmAutoComplete.setup();
+ GitLab.GfmAutoComplete.setup(this.form.find('.js-gfm-input'));
new DropzoneInput(this.form);
autosize(this.textarea);
this.addEventListeners();
diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable.js
index f27f1bad1f7..d0305c6c6a1 100644
--- a/app/assets/javascripts/issuable.js
+++ b/app/assets/javascripts/issuable.js
@@ -5,13 +5,10 @@
this.Issuable = {
init: function() {
- if (!issuable_created) {
- issuable_created = true;
- Issuable.initTemplates();
- Issuable.initSearch();
- Issuable.initChecks();
- return Issuable.initLabelFilterRemove();
- }
+ Issuable.initTemplates();
+ Issuable.initSearch();
+ Issuable.initChecks();
+ return Issuable.initLabelFilterRemove();
},
initTemplates: function() {
return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <span class="label-row btn-group" role="group" aria-label="<%- label.title %>" style="color: <%- label.text_color %>;"> <a href="#" class="btn btn-transparent has-tooltip" style="background-color: <%- label.color %>;" title="<%- label.description %>" data-container="body"> <%- label.title %> </a> <button type="button" class="btn btn-transparent label-remove js-label-filter-remove" style="background-color: <%- label.color %>;" data-label="<%- label.title %>"> <i class="fa fa-times"></i> </button> </span> <% }); %>');
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 297d4f029f0..b7f92ae9883 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -102,20 +102,34 @@
};
IssuableForm.prototype.initMoveDropdown = function() {
- var $moveDropdown;
+ var $moveDropdown, pageSize;
$moveDropdown = $('.js-move-dropdown');
if ($moveDropdown.length) {
+ pageSize = $moveDropdown.data('page-size');
return $('.js-move-dropdown').select2({
ajax: {
url: $moveDropdown.data('projects-url'),
- results: function(data) {
+ quietMillis: 125,
+ data: function(term, page, context) {
return {
- results: data
+ search: term,
+ offset_id: context
};
},
- data: function(query) {
+ results: function(data) {
+ var context,
+ more;
+
+ if (data.length >= pageSize)
+ more = true;
+
+ if (data[data.length - 1])
+ context = data[data.length - 1].id;
+
return {
- search: query
+ results: data,
+ more: more,
+ context: context
};
}
},
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 675dd5b7cea..565dbeacdb3 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -4,7 +4,7 @@
var _this;
_this = this;
$('.js-label-select').each(function(i, dropdown) {
- var $block, $colorPreview, $dropdown, $form, $loading, $newLabelCreateButton, $newLabelError, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, newColorField, newLabelField, projectId, resetForm, saveLabel, saveLabelData, selectedLabel, showAny, showNo;
+ var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, projectId, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip;
$dropdown = $(dropdown);
projectId = $dropdown.data('project-id');
labelUrl = $dropdown.data('labels');
@@ -13,8 +13,6 @@
if ((selectedLabel != null) && !$dropdown.hasClass('js-multiselect')) {
selectedLabel = selectedLabel.split(',');
}
- newLabelField = $('#new_label_name');
- newColorField = $('#new_label_color');
showNo = $dropdown.data('show-no');
showAny = $dropdown.data('show-any');
defaultLabel = $dropdown.data('default-label');
@@ -23,11 +21,8 @@
$block = $selectbox.closest('.block');
$form = $dropdown.closest('form');
$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
+ $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
$value = $block.find('.value');
- $newLabelError = $('.js-label-error');
- $colorPreview = $('.js-dropdown-label-color-preview');
- $newLabelCreateButton = $('.js-new-label-btn');
- $newLabelError.hide();
$loading = $block.find('.block-loading').fadeOut();
if (issueUpdateURL != null) {
issueURLSplit = issueUpdateURL.split('/');
@@ -36,60 +31,13 @@
labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> <a href="<%- ["",issueURLSplit[1], issueURLSplit[2],""].join("/") %>issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <span class="label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;"> <%- label.title %> </span> </a> <% }); %>');
labelNoneHTMLTemplate = '<span class="no-value">None</span>';
}
- if (newLabelField.length) {
- $('.suggest-colors-dropdown a').on("click", function(e) {
- e.preventDefault();
- e.stopPropagation();
- newColorField.val($(this).data('color')).trigger('change');
- return $colorPreview.css('background-color', $(this).data('color')).parent().addClass('is-active');
- });
- resetForm = function() {
- newLabelField.val('').trigger('change');
- newColorField.val('').trigger('change');
- return $colorPreview.css('background-color', '').parent().removeClass('is-active');
- };
- $('.dropdown-menu-back').on('click', function() {
- return resetForm();
- });
- $('.js-cancel-label-btn').on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- resetForm();
- return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
- });
- enableLabelCreateButton = function() {
- if (newLabelField.val() !== '' && newColorField.val() !== '') {
- $newLabelError.hide();
- return $newLabelCreateButton.enable();
- } else {
- return $newLabelCreateButton.disable();
- }
- };
- saveLabel = function() {
- return Api.newLabel(projectId, {
- name: newLabelField.val(),
- color: newColorField.val()
- }, function(label) {
- var errors;
- $newLabelCreateButton.enable();
- if (label.message != null) {
- errors = _.map(label.message, function(value, key) {
- return key + " " + value[0];
- });
- return $newLabelError.html(errors.join("<br/>")).show();
- } else {
- return $('.dropdown-menu-back', $dropdown.parent()).trigger('click');
- }
- });
- };
- newLabelField.on('keyup change', enableLabelCreateButton);
- newColorField.on('keyup change', enableLabelCreateButton);
- $newLabelCreateButton.disable().on('click', function(e) {
- e.preventDefault();
- e.stopPropagation();
- return saveLabel();
- });
+
+ $sidebarLabelTooltip.tooltip();
+
+ if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
+ new gl.CreateLabelDropdown($dropdown.closest('.dropdown').find('.dropdown-new-label'), projectId);
}
+
saveLabelData = function() {
var data, selected;
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").map(function() {
@@ -109,7 +57,7 @@
dataType: 'JSON',
data: data
}).done(function(data) {
- var labelCount, template;
+ var labelCount, template, labelTooltipTitle, labelTitles;
$loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide();
@@ -123,6 +71,27 @@
}
$value.removeAttr('style').html(template);
$sidebarCollapsedValue.text(labelCount);
+
+ if (data.labels.length) {
+ labelTitles = data.labels.map(function(label) {
+ return label.title;
+ });
+
+ if (labelTitles.length > 5) {
+ labelTitles = labelTitles.slice(0, 5);
+ labelTitles.push('and ' + (data.labels.length - 5) + ' more');
+ }
+
+ labelTooltipTitle = labelTitles.join(', ');
+ } else {
+ labelTooltipTitle = '';
+ $sidebarLabelTooltip.tooltip('destroy');
+ }
+
+ $sidebarLabelTooltip
+ .attr('title', labelTooltipTitle)
+ .tooltip('fixTitle');
+
$('.has-tooltip', $value).tooltip({
container: 'body'
});
@@ -270,6 +239,9 @@
isMRIndex = page === 'projects:merge_requests:index';
$selectbox.hide();
$value.removeAttr('style');
+ if (page === 'projects:boards:show') {
+ return;
+ }
if ($dropdown.hasClass('js-multiselect')) {
if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
selectedLabels = $dropdown.closest('form').find("input:hidden[name='" + ($dropdown.data('fieldName')) + "']");
@@ -289,7 +261,7 @@
}
},
multiSelect: $dropdown.hasClass('js-multiselect'),
- clicked: function(label) {
+ clicked: function(label, $el, e) {
var isIssueIndex, isMRIndex, page;
_this.enableBulkLabelDropdown();
if ($dropdown.hasClass('js-filter-bulk-update')) {
@@ -298,7 +270,23 @@
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index';
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (page === 'projects:boards:show') {
+ if (label.isAny) {
+ gl.issueBoards.BoardsStore.state.filters['label_name'] = [];
+ } else if (label.title) {
+ gl.issueBoards.BoardsStore.state.filters['label_name'].push(label.title);
+ } else {
+ var filters = gl.issueBoards.BoardsStore.state.filters['label_name'];
+ filters = filters.filter(function (label) {
+ return label !== $el.text().trim();
+ });
+ gl.issueBoards.BoardsStore.state.filters['label_name'] = filters;
+ }
+
+ gl.issueBoards.BoardsStore.updateFiltersUrl();
+ e.preventDefault();
+ return;
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (!$dropdown.hasClass('js-multiselect')) {
selectedLabel = label.title;
return Issuable.filterResults($dropdown.closest('form'));
diff --git a/app/assets/javascripts/lib/ace.js b/app/assets/javascripts/lib/ace.js
new file mode 100644
index 00000000000..4cdf99cae72
--- /dev/null
+++ b/app/assets/javascripts/lib/ace.js
@@ -0,0 +1,2 @@
+/*= require ace-rails-ap */
+/*= require ace/ext-searchbox */
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index e817261f210..10afa7e4329 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -8,13 +8,16 @@
base.utils = {};
}
w.gl.utils.days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
+
w.gl.utils.formatDate = function(datetime) {
return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
};
+
w.gl.utils.getDayName = function(date) {
return this.days[date.getDay()];
};
- return w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago) {
+
+ w.gl.utils.localTimeAgo = function($timeagoEls, setTimeago) {
if (setTimeago == null) {
setTimeago = true;
}
@@ -31,6 +34,39 @@
});
}
};
+
+ w.gl.utils.shortTimeAgo = function($el) {
+ var shortLocale, tmpLocale;
+ shortLocale = {
+ prefixAgo: null,
+ prefixFromNow: null,
+ suffixAgo: 'ago',
+ suffixFromNow: 'from now',
+ seconds: '1 min',
+ minute: '1 min',
+ minutes: '%d mins',
+ hour: '1 hr',
+ hours: '%d hrs',
+ day: '1 day',
+ days: '%d days',
+ month: '1 month',
+ months: '%d months',
+ year: '1 year',
+ years: '%d years',
+ wordSeparator: ' ',
+ numbers: []
+ };
+ tmpLocale = $.timeago.settings.strings;
+ $el.each(function(el) {
+ var $el1;
+ $el1 = $(this);
+ return $el1.attr('title', gl.utils.formatDate($el.attr('datetime')));
+ });
+ $.timeago.settings.strings = shortLocale;
+ $el.timeago();
+ $.timeago.settings.strings = tmpLocale;
+ };
+
})(window);
}).call(this);
diff --git a/app/assets/javascripts/lib/utils/md5.js b/app/assets/javascripts/lib/utils/md5.js
deleted file mode 100644
index b63716eaad2..00000000000
--- a/app/assets/javascripts/lib/utils/md5.js
+++ /dev/null
@@ -1,211 +0,0 @@
-function md5 (str) {
- // http://kevin.vanzonneveld.net
- // + original by: Webtoolkit.info (http://www.webtoolkit.info/)
- // + namespaced by: Michael White (http://getsprink.com)
- // + tweaked by: Jack
- // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
- // + input by: Brett Zamir (http://brett-zamir.me)
- // + bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
- // - depends on: utf8_encode
- // * example 1: md5('Kevin van Zonneveld');
- // * returns 1: '6e658d4bfcb59cc13f96c14450ac40b9'
- var xl;
-
- var rotateLeft = function (lValue, iShiftBits) {
- return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
- };
-
- var addUnsigned = function (lX, lY) {
- var lX4, lY4, lX8, lY8, lResult;
- lX8 = (lX & 0x80000000);
- lY8 = (lY & 0x80000000);
- lX4 = (lX & 0x40000000);
- lY4 = (lY & 0x40000000);
- lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF);
- if (lX4 & lY4) {
- return (lResult ^ 0x80000000 ^ lX8 ^ lY8);
- }
- if (lX4 | lY4) {
- if (lResult & 0x40000000) {
- return (lResult ^ 0xC0000000 ^ lX8 ^ lY8);
- } else {
- return (lResult ^ 0x40000000 ^ lX8 ^ lY8);
- }
- } else {
- return (lResult ^ lX8 ^ lY8);
- }
- };
-
- var _F = function (x, y, z) {
- return (x & y) | ((~x) & z);
- };
- var _G = function (x, y, z) {
- return (x & z) | (y & (~z));
- };
- var _H = function (x, y, z) {
- return (x ^ y ^ z);
- };
- var _I = function (x, y, z) {
- return (y ^ (x | (~z)));
- };
-
- var _FF = function (a, b, c, d, x, s, ac) {
- a = addUnsigned(a, addUnsigned(addUnsigned(_F(b, c, d), x), ac));
- return addUnsigned(rotateLeft(a, s), b);
- };
-
- var _GG = function (a, b, c, d, x, s, ac) {
- a = addUnsigned(a, addUnsigned(addUnsigned(_G(b, c, d), x), ac));
- return addUnsigned(rotateLeft(a, s), b);
- };
-
- var _HH = function (a, b, c, d, x, s, ac) {
- a = addUnsigned(a, addUnsigned(addUnsigned(_H(b, c, d), x), ac));
- return addUnsigned(rotateLeft(a, s), b);
- };
-
- var _II = function (a, b, c, d, x, s, ac) {
- a = addUnsigned(a, addUnsigned(addUnsigned(_I(b, c, d), x), ac));
- return addUnsigned(rotateLeft(a, s), b);
- };
-
- var convertToWordArray = function (str) {
- var lWordCount;
- var lMessageLength = str.length;
- var lNumberOfWords_temp1 = lMessageLength + 8;
- var lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64;
- var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16;
- var lWordArray = new Array(lNumberOfWords - 1);
- var lBytePosition = 0;
- var lByteCount = 0;
- while (lByteCount < lMessageLength) {
- lWordCount = (lByteCount - (lByteCount % 4)) / 4;
- lBytePosition = (lByteCount % 4) * 8;
- lWordArray[lWordCount] = (lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition));
- lByteCount++;
- }
- lWordCount = (lByteCount - (lByteCount % 4)) / 4;
- lBytePosition = (lByteCount % 4) * 8;
- lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition);
- lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
- lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
- return lWordArray;
- };
-
- var wordToHex = function (lValue) {
- var wordToHexValue = "",
- wordToHexValue_temp = "",
- lByte, lCount;
- for (lCount = 0; lCount <= 3; lCount++) {
- lByte = (lValue >>> (lCount * 8)) & 255;
- wordToHexValue_temp = "0" + lByte.toString(16);
- wordToHexValue = wordToHexValue + wordToHexValue_temp.substr(wordToHexValue_temp.length - 2, 2);
- }
- return wordToHexValue;
- };
-
- var x = [],
- k, AA, BB, CC, DD, a, b, c, d, S11 = 7,
- S12 = 12,
- S13 = 17,
- S14 = 22,
- S21 = 5,
- S22 = 9,
- S23 = 14,
- S24 = 20,
- S31 = 4,
- S32 = 11,
- S33 = 16,
- S34 = 23,
- S41 = 6,
- S42 = 10,
- S43 = 15,
- S44 = 21;
-
- str = this.utf8_encode(str);
- x = convertToWordArray(str);
- a = 0x67452301;
- b = 0xEFCDAB89;
- c = 0x98BADCFE;
- d = 0x10325476;
-
- xl = x.length;
- for (k = 0; k < xl; k += 16) {
- AA = a;
- BB = b;
- CC = c;
- DD = d;
- a = _FF(a, b, c, d, x[k + 0], S11, 0xD76AA478);
- d = _FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756);
- c = _FF(c, d, a, b, x[k + 2], S13, 0x242070DB);
- b = _FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE);
- a = _FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF);
- d = _FF(d, a, b, c, x[k + 5], S12, 0x4787C62A);
- c = _FF(c, d, a, b, x[k + 6], S13, 0xA8304613);
- b = _FF(b, c, d, a, x[k + 7], S14, 0xFD469501);
- a = _FF(a, b, c, d, x[k + 8], S11, 0x698098D8);
- d = _FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF);
- c = _FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1);
- b = _FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE);
- a = _FF(a, b, c, d, x[k + 12], S11, 0x6B901122);
- d = _FF(d, a, b, c, x[k + 13], S12, 0xFD987193);
- c = _FF(c, d, a, b, x[k + 14], S13, 0xA679438E);
- b = _FF(b, c, d, a, x[k + 15], S14, 0x49B40821);
- a = _GG(a, b, c, d, x[k + 1], S21, 0xF61E2562);
- d = _GG(d, a, b, c, x[k + 6], S22, 0xC040B340);
- c = _GG(c, d, a, b, x[k + 11], S23, 0x265E5A51);
- b = _GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA);
- a = _GG(a, b, c, d, x[k + 5], S21, 0xD62F105D);
- d = _GG(d, a, b, c, x[k + 10], S22, 0x2441453);
- c = _GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681);
- b = _GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8);
- a = _GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6);
- d = _GG(d, a, b, c, x[k + 14], S22, 0xC33707D6);
- c = _GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87);
- b = _GG(b, c, d, a, x[k + 8], S24, 0x455A14ED);
- a = _GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905);
- d = _GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8);
- c = _GG(c, d, a, b, x[k + 7], S23, 0x676F02D9);
- b = _GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A);
- a = _HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942);
- d = _HH(d, a, b, c, x[k + 8], S32, 0x8771F681);
- c = _HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122);
- b = _HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C);
- a = _HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44);
- d = _HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9);
- c = _HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60);
- b = _HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70);
- a = _HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6);
- d = _HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA);
- c = _HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085);
- b = _HH(b, c, d, a, x[k + 6], S34, 0x4881D05);
- a = _HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039);
- d = _HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5);
- c = _HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8);
- b = _HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665);
- a = _II(a, b, c, d, x[k + 0], S41, 0xF4292244);
- d = _II(d, a, b, c, x[k + 7], S42, 0x432AFF97);
- c = _II(c, d, a, b, x[k + 14], S43, 0xAB9423A7);
- b = _II(b, c, d, a, x[k + 5], S44, 0xFC93A039);
- a = _II(a, b, c, d, x[k + 12], S41, 0x655B59C3);
- d = _II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92);
- c = _II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D);
- b = _II(b, c, d, a, x[k + 1], S44, 0x85845DD1);
- a = _II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F);
- d = _II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0);
- c = _II(c, d, a, b, x[k + 6], S43, 0xA3014314);
- b = _II(b, c, d, a, x[k + 13], S44, 0x4E0811A1);
- a = _II(a, b, c, d, x[k + 4], S41, 0xF7537E82);
- d = _II(d, a, b, c, x[k + 11], S42, 0xBD3AF235);
- c = _II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB);
- b = _II(b, c, d, a, x[k + 9], S44, 0xEB86D391);
- a = addUnsigned(a, AA);
- b = addUnsigned(b, BB);
- c = addUnsigned(c, CC);
- d = addUnsigned(d, DD);
- }
-
- var temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d);
-
- return temp.toLowerCase();
-}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 130479642f3..b6636de5767 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -104,9 +104,12 @@
return self.updateText($this.closest('.md-area').find('textarea'), $this.data('md-tag'), $this.data('md-block'), !$this.data('md-prepend'));
});
};
- return gl.text.removeListeners = function(form) {
+ gl.text.removeListeners = function(form) {
return $('.js-md', form).off();
};
+ return gl.text.truncate = function(string, maxLength) {
+ return string.substr(0, (maxLength - 3)) + '...';
+ };
})(window);
}).call(this);
diff --git a/app/assets/javascripts/lib/utils/utf8_encode.js b/app/assets/javascripts/lib/utils/utf8_encode.js
deleted file mode 100644
index 39ffe44dae0..00000000000
--- a/app/assets/javascripts/lib/utils/utf8_encode.js
+++ /dev/null
@@ -1,70 +0,0 @@
-function utf8_encode (argString) {
- // http://kevin.vanzonneveld.net
- // + original by: Webtoolkit.info (http://www.webtoolkit.info/)
- // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net)
- // + improved by: sowberry
- // + tweaked by: Jack
- // + bugfixed by: Onno Marsman
- // + improved by: Yves Sucaet
- // + bugfixed by: Onno Marsman
- // + bugfixed by: Ulrich
- // + bugfixed by: Rafal Kukawski
- // + improved by: kirilloid
- // + bugfixed by: kirilloid
- // * example 1: utf8_encode('Kevin van Zonneveld');
- // * returns 1: 'Kevin van Zonneveld'
-
- if (argString === null || typeof argString === "undefined") {
- return "";
- }
-
- var string = (argString + ''); // .replace(/\r\n/g, "\n").replace(/\r/g, "\n");
- var utftext = '',
- start, end, stringl = 0;
-
- start = end = 0;
- stringl = string.length;
- for (var n = 0; n < stringl; n++) {
- var c1 = string.charCodeAt(n);
- var enc = null;
-
- if (c1 < 128) {
- end++;
- } else if (c1 > 127 && c1 < 2048) {
- enc = String.fromCharCode(
- (c1 >> 6) | 192,
- ( c1 & 63) | 128
- );
- } else if (c1 & 0xF800 != 0xD800) {
- enc = String.fromCharCode(
- (c1 >> 12) | 224,
- ((c1 >> 6) & 63) | 128,
- ( c1 & 63) | 128
- );
- } else { // surrogate pairs
- if (c1 & 0xFC00 != 0xD800) { throw new RangeError("Unmatched trail surrogate at " + n); }
- var c2 = string.charCodeAt(++n);
- if (c2 & 0xFC00 != 0xDC00) { throw new RangeError("Unmatched lead surrogate at " + (n-1)); }
- c1 = ((c1 & 0x3FF) << 10) + (c2 & 0x3FF) + 0x10000;
- enc = String.fromCharCode(
- (c1 >> 18) | 240,
- ((c1 >> 12) & 63) | 128,
- ((c1 >> 6) & 63) | 128,
- ( c1 & 63) | 128
- );
- }
- if (enc !== null) {
- if (end > start) {
- utftext += string.slice(start, end);
- }
- utftext += enc;
- start = end = n + 1;
- }
- }
-
- if (end > start) {
- utftext += string.slice(start, stringl);
- }
-
- return utftext;
-}
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
new file mode 100644
index 00000000000..1935af491f7
--- /dev/null
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -0,0 +1,32 @@
+(function() {
+ // Add datepickers to all `js-access-expiration-date` elements. If those elements are
+ // children of an element with the `clearable-input` class, and have a sibling
+ // `js-clear-input` element, then show that element when there is a value in the
+ // datepicker, and make clicking on that element clear the field.
+ //
+ gl.MemberExpirationDate = function() {
+ function toggleClearInput() {
+ $(this).closest('.clearable-input').toggleClass('has-value', $(this).val() !== '');
+ }
+
+ var inputs = $('.js-access-expiration-date');
+
+ inputs.datepicker({
+ dateFormat: 'yy-mm-dd',
+ minDate: 1,
+ onSelect: toggleClearInput
+ });
+
+ inputs.next('.js-clear-input').on('click', function(event) {
+ event.preventDefault();
+
+ var input = $(this).closest('.clearable-input').find('.js-access-expiration-date');
+ input.datepicker('setDate', null);
+ toggleClearInput.call(input);
+ });
+
+ inputs.on('blur', toggleClearInput);
+
+ inputs.each(toggleClearInput);
+ };
+}).call(this);
diff --git a/app/assets/javascripts/merge_conflict_data_provider.js.es6 b/app/assets/javascripts/merge_conflict_data_provider.js.es6
new file mode 100644
index 00000000000..cd92df8ddc5
--- /dev/null
+++ b/app/assets/javascripts/merge_conflict_data_provider.js.es6
@@ -0,0 +1,341 @@
+const HEAD_HEADER_TEXT = 'HEAD//our changes';
+const ORIGIN_HEADER_TEXT = 'origin//their changes';
+const HEAD_BUTTON_TITLE = 'Use ours';
+const ORIGIN_BUTTON_TITLE = 'Use theirs';
+
+
+class MergeConflictDataProvider {
+
+ getInitialData() {
+ const diffViewType = $.cookie('diff_view');
+
+ return {
+ isLoading : true,
+ hasError : false,
+ isParallel : diffViewType === 'parallel',
+ diffViewType : diffViewType,
+ isSubmitting : false,
+ conflictsData : {},
+ resolutionData : {}
+ }
+ }
+
+
+ decorateData(vueInstance, data) {
+ this.vueInstance = vueInstance;
+
+ if (data.type === 'error') {
+ vueInstance.hasError = true;
+ data.errorMessage = data.message;
+ }
+ else {
+ data.shortCommitSha = data.commit_sha.slice(0, 7);
+ data.commitMessage = data.commit_message;
+
+ this.setParallelLines(data);
+ this.setInlineLines(data);
+ this.updateResolutionsData(data);
+ }
+
+ vueInstance.conflictsData = data;
+ vueInstance.isSubmitting = false;
+
+ const conflictsText = this.getConflictsCount() > 1 ? 'conflicts' : 'conflict';
+ vueInstance.conflictsData.conflictsText = conflictsText;
+ }
+
+
+ updateResolutionsData(data) {
+ const vi = this.vueInstance;
+
+ data.files.forEach( (file) => {
+ file.sections.forEach( (section) => {
+ if (section.conflict) {
+ vi.$set(`resolutionData['${section.id}']`, false);
+ }
+ });
+ });
+ }
+
+
+ setParallelLines(data) {
+ data.files.forEach( (file) => {
+ file.filePath = this.getFilePath(file);
+ file.iconClass = `fa-${file.blob_icon}`;
+ file.blobPath = file.blob_path;
+ file.parallelLines = [];
+ const linesObj = { left: [], right: [] };
+
+ file.sections.forEach( (section) => {
+ const { conflict, lines, id } = section;
+
+ if (conflict) {
+ linesObj.left.push(this.getOriginHeaderLine(id));
+ linesObj.right.push(this.getHeadHeaderLine(id));
+ }
+
+ lines.forEach( (line) => {
+ const { type } = line;
+
+ if (conflict) {
+ if (type === 'old') {
+ linesObj.left.push(this.getLineForParallelView(line, id, 'conflict'));
+ }
+ else if (type === 'new') {
+ linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true));
+ }
+ }
+ else {
+ const lineType = type || 'context';
+
+ linesObj.left.push (this.getLineForParallelView(line, id, lineType));
+ linesObj.right.push(this.getLineForParallelView(line, id, lineType, true));
+ }
+ });
+
+ this.checkLineLengths(linesObj);
+ });
+
+ for (let i = 0, len = linesObj.left.length; i < len; i++) {
+ file.parallelLines.push([
+ linesObj.right[i],
+ linesObj.left[i]
+ ]);
+ }
+
+ });
+ }
+
+
+ checkLineLengths(linesObj) {
+ let { left, right } = linesObj;
+
+ if (left.length !== right.length) {
+ if (left.length > right.length) {
+ const diff = left.length - right.length;
+ for (let i = 0; i < diff; i++) {
+ right.push({ lineType: 'emptyLine', richText: '' });
+ }
+ }
+ else {
+ const diff = right.length - left.length;
+ for (let i = 0; i < diff; i++) {
+ left.push({ lineType: 'emptyLine', richText: '' });
+ }
+ }
+ }
+ }
+
+
+ setInlineLines(data) {
+ data.files.forEach( (file) => {
+ file.iconClass = `fa-${file.blob_icon}`;
+ file.blobPath = file.blob_path;
+ file.filePath = this.getFilePath(file);
+ file.inlineLines = []
+
+ file.sections.forEach( (section) => {
+ let currentLineType = 'new';
+ const { conflict, lines, id } = section;
+
+ if (conflict) {
+ file.inlineLines.push(this.getHeadHeaderLine(id));
+ }
+
+ lines.forEach( (line) => {
+ const { type } = line;
+
+ if ((type === 'new' || type === 'old') && currentLineType !== type) {
+ currentLineType = type;
+ file.inlineLines.push({ lineType: 'emptyLine', richText: '' });
+ }
+
+ this.decorateLineForInlineView(line, id, conflict);
+ file.inlineLines.push(line);
+ })
+
+ if (conflict) {
+ file.inlineLines.push(this.getOriginHeaderLine(id));
+ }
+ });
+ });
+ }
+
+
+ handleSelected(sectionId, selection) {
+ const vi = this.vueInstance;
+
+ vi.resolutionData[sectionId] = selection;
+ vi.conflictsData.files.forEach( (file) => {
+ file.inlineLines.forEach( (line) => {
+ if (line.id === sectionId && (line.hasConflict || line.isHeader)) {
+ this.markLine(line, selection);
+ }
+ });
+
+ file.parallelLines.forEach( (lines) => {
+ const left = lines[0];
+ const right = lines[1];
+ const hasSameId = right.id === sectionId || left.id === sectionId;
+ const isLeftMatch = left.hasConflict || left.isHeader;
+ const isRightMatch = right.hasConflict || right.isHeader;
+
+ if (hasSameId && (isLeftMatch || isRightMatch)) {
+ this.markLine(left, selection);
+ this.markLine(right, selection);
+ }
+ })
+ });
+ }
+
+
+ updateViewType(newType) {
+ const vi = this.vueInstance;
+
+ if (newType === vi.diffView || !(newType === 'parallel' || newType === 'inline')) {
+ return;
+ }
+
+ vi.diffView = newType;
+ vi.isParallel = newType === 'parallel';
+ $.cookie('diff_view', newType); // TODO: Make sure that cookie path added.
+ $('.content-wrapper .container-fluid').toggleClass('container-limited');
+ }
+
+
+ markLine(line, selection) {
+ if (selection === 'head' && line.isHead) {
+ line.isSelected = true;
+ line.isUnselected = false;
+ }
+ else if (selection === 'origin' && line.isOrigin) {
+ line.isSelected = true;
+ line.isUnselected = false;
+ }
+ else {
+ line.isSelected = false;
+ line.isUnselected = true;
+ }
+ }
+
+
+ getConflictsCount() {
+ return Object.keys(this.vueInstance.resolutionData).length;
+ }
+
+
+ getResolvedCount() {
+ let count = 0;
+ const data = this.vueInstance.resolutionData;
+
+ for (const id in data) {
+ const resolution = data[id];
+ if (resolution) {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+
+ isReadyToCommit() {
+ const { conflictsData, isSubmitting } = this.vueInstance
+ const allResolved = this.getConflictsCount() === this.getResolvedCount();
+ const hasCommitMessage = $.trim(conflictsData.commitMessage).length;
+
+ return !isSubmitting && hasCommitMessage && allResolved;
+ }
+
+
+ getCommitButtonText() {
+ const initial = 'Commit conflict resolution';
+ const inProgress = 'Committing...';
+ const vue = this.vueInstance;
+
+ return vue ? vue.isSubmitting ? inProgress : initial : initial;
+ }
+
+
+ decorateLineForInlineView(line, id, conflict) {
+ const { type } = line;
+ line.id = id;
+ line.hasConflict = conflict;
+ line.isHead = type === 'new';
+ line.isOrigin = type === 'old';
+ line.hasMatch = type === 'match';
+ line.richText = line.rich_text;
+ line.isSelected = false;
+ line.isUnselected = false;
+ }
+
+ getLineForParallelView(line, id, lineType, isHead) {
+ const { old_line, new_line, rich_text } = line;
+ const hasConflict = lineType === 'conflict';
+
+ return {
+ id,
+ lineType,
+ hasConflict,
+ isHead : hasConflict && isHead,
+ isOrigin : hasConflict && !isHead,
+ hasMatch : lineType === 'match',
+ lineNumber : isHead ? new_line : old_line,
+ section : isHead ? 'head' : 'origin',
+ richText : rich_text,
+ isSelected : false,
+ isUnselected : false
+ }
+ }
+
+
+ getHeadHeaderLine(id) {
+ return {
+ id : id,
+ richText : HEAD_HEADER_TEXT,
+ buttonTitle : HEAD_BUTTON_TITLE,
+ type : 'new',
+ section : 'head',
+ isHeader : true,
+ isHead : true,
+ isSelected : false,
+ isUnselected: false
+ }
+ }
+
+
+ getOriginHeaderLine(id) {
+ return {
+ id : id,
+ richText : ORIGIN_HEADER_TEXT,
+ buttonTitle : ORIGIN_BUTTON_TITLE,
+ type : 'old',
+ section : 'origin',
+ isHeader : true,
+ isOrigin : true,
+ isSelected : false,
+ isUnselected: false
+ }
+ }
+
+
+ handleFailedRequest(vueInstance, data) {
+ vueInstance.hasError = true;
+ vueInstance.conflictsData.errorMessage = 'Something went wrong!';
+ }
+
+
+ getCommitData() {
+ return {
+ commit_message: this.vueInstance.conflictsData.commitMessage,
+ sections: this.vueInstance.resolutionData
+ }
+ }
+
+
+ getFilePath(file) {
+ const { old_path, new_path } = file;
+ return old_path === new_path ? new_path : `${old_path} โ†’ ${new_path}`;
+ }
+
+}
diff --git a/app/assets/javascripts/merge_conflict_resolver.js.es6 b/app/assets/javascripts/merge_conflict_resolver.js.es6
new file mode 100644
index 00000000000..77bffbcb403
--- /dev/null
+++ b/app/assets/javascripts/merge_conflict_resolver.js.es6
@@ -0,0 +1,85 @@
+//= require vue
+
+class MergeConflictResolver {
+
+ constructor() {
+ this.dataProvider = new MergeConflictDataProvider()
+ this.initVue()
+ }
+
+
+ initVue() {
+ const that = this;
+ this.vue = new Vue({
+ el : '#conflicts',
+ name : 'MergeConflictResolver',
+ data : this.dataProvider.getInitialData(),
+ created : this.fetchData(),
+ computed : this.setComputedProperties(),
+ methods : {
+ handleSelected(sectionId, selection) {
+ that.dataProvider.handleSelected(sectionId, selection);
+ },
+ handleViewTypeChange(newType) {
+ that.dataProvider.updateViewType(newType);
+ },
+ commit() {
+ that.commit();
+ }
+ }
+ })
+ }
+
+
+ setComputedProperties() {
+ const dp = this.dataProvider;
+
+ return {
+ conflictsCount() { return dp.getConflictsCount() },
+ resolvedCount() { return dp.getResolvedCount() },
+ readyToCommit() { return dp.isReadyToCommit() },
+ commitButtonText() { return dp.getCommitButtonText() }
+ }
+ }
+
+
+ fetchData() {
+ const dp = this.dataProvider;
+
+ $.get($('#conflicts').data('conflictsPath'))
+ .done((data) => {
+ dp.decorateData(this.vue, data);
+ })
+ .error((data) => {
+ dp.handleFailedRequest(this.vue, data);
+ })
+ .always(() => {
+ this.vue.isLoading = false;
+
+ this.vue.$nextTick(() => {
+ $('#conflicts .js-syntax-highlight').syntaxHighlight();
+ });
+
+ if (this.vue.diffViewType === 'parallel') {
+ $('.content-wrapper .container-fluid').removeClass('container-limited');
+ }
+ })
+ }
+
+
+ commit() {
+ this.vue.isSubmitting = true;
+
+ $.post($('#conflicts').data('resolveConflictsPath'), this.dataProvider.getCommitData())
+ .done((data) => {
+ window.location.href = data.redirect_to;
+ })
+ .error(() => {
+ new Flash('Something went wrong!');
+ })
+ .always(() => {
+ this.vue.isSubmitting = false;
+ });
+ }
+
+}
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 47e6dd1084d..56ebf84c4f6 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -34,7 +34,7 @@
MergeRequest.prototype.initTabs = function() {
if (this.opts.action !== 'new') {
- return new MergeRequestTabs(this.opts);
+ window.mrTabs = new MergeRequestTabs(this.opts);
} else {
return $('.merge-request-tabs a[data-toggle="tab"]:first').tab('show');
}
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 52c2ed61012..ad08209d61e 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -9,10 +9,13 @@
MergeRequestTabs.prototype.buildsLoaded = false;
+ MergeRequestTabs.prototype.pipelinesLoaded = false;
+
MergeRequestTabs.prototype.commitsLoaded = false;
function MergeRequestTabs(opts) {
this.opts = opts != null ? opts : {};
+ this.opts.setUrl = this.opts.setUrl !== undefined ? this.opts.setUrl : true;
this.setCurrentAction = bind(this.setCurrentAction, this);
this.tabShown = bind(this.tabShown, this);
this.showTab = bind(this.showTab, this);
@@ -50,10 +53,15 @@
} else if (action === 'builds') {
this.loadBuilds($target.attr('href'));
this.expandView();
+ } else if (action === 'pipelines') {
+ this.loadPipelines($target.attr('href'));
+ this.expandView();
} else {
this.expandView();
}
- return this.setCurrentAction(action);
+ if (this.opts.setUrl) {
+ this.setCurrentAction(action);
+ }
};
MergeRequestTabs.prototype.scrollToElement = function(container) {
@@ -81,7 +89,8 @@
if (action === 'show') {
action = 'notes';
}
- new_state = this._location.pathname.replace(/\/(commits|diffs|builds)(\.html)?\/?$/, '');
+ this.currentAction = action;
+ new_state = this._location.pathname.replace(/\/(commits|diffs|builds|pipelines)(\.html)?\/?$/, '');
if (action !== 'notes') {
new_state += "/" + action;
}
@@ -119,6 +128,11 @@
success: (function(_this) {
return function(data) {
$('#diffs').html(data.html);
+
+ if (typeof DiffNotesApp !== 'undefined') {
+ DiffNotesApp.compileComponents();
+ }
+
gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'));
$('#diffs .js-syntax-highlight').syntaxHighlight();
$('#diffs .diff-file').singleFileDiff();
@@ -177,6 +191,21 @@
});
};
+ MergeRequestTabs.prototype.loadPipelines = function(source) {
+ if (this.pipelinesLoaded) {
+ return;
+ }
+ return this._get({
+ url: source + ".json",
+ success: function(data) {
+ $('#pipelines').html(data.html);
+ gl.utils.localTimeAgo($('.js-timeago', '#pipelines'));
+ this.pipelinesLoaded = true;
+ return this.scrollToElement("#pipelines");
+ }.bind(this)
+ });
+ };
+
MergeRequestTabs.prototype.toggleLoading = function(status) {
return $('.mr-loading-status .loading').toggle(status);
};
diff --git a/app/assets/javascripts/merge_request_widget.js b/app/assets/javascripts/merge_request_widget.js
index 362aaa906d0..bd35b6f679d 100644
--- a/app/assets/javascripts/merge_request_widget.js
+++ b/app/assets/javascripts/merge_request_widget.js
@@ -28,7 +28,7 @@
MergeRequestWidget.prototype.addEventListeners = function() {
var allowedPages;
- allowedPages = ['show', 'commits', 'builds', 'changes'];
+ allowedPages = ['show', 'commits', 'builds', 'pipelines', 'changes'];
return $(document).on('page:change.merge_request', (function(_this) {
return function() {
var page;
@@ -53,7 +53,7 @@
return function(data) {
var callback, urlSuffix;
if (data.state === "merged") {
- urlSuffix = deleteSourceBranch ? '?delete_source=true' : '';
+ urlSuffix = deleteSourceBranch ? '?deleted_source_branch=true' : '';
return window.location.href = window.location.pathname + urlSuffix;
} else if (data.merge_error) {
return $('.mr-widget-body').html("<h4>" + data.merge_error + "</h4>");
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index a0b65d20c03..e897ebdf630 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -94,7 +94,7 @@
$selectbox.hide();
return $value.css('display', '');
},
- clicked: function(selected) {
+ clicked: function(selected, $el, e) {
var data, isIssueIndex, isMRIndex, page;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
@@ -102,7 +102,11 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
return;
}
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (page === 'projects:boards:show') {
+ gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name;
+ gl.issueBoards.BoardsStore.updateFiltersUrl();
+ e.preventDefault();
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
if (selected.name != null) {
selectedMilestone = selected.name;
} else {
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 9ece474d994..d0d5cad813a 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -68,6 +68,7 @@
$(document).on("click", ".note-edit-cancel", this.cancelEdit);
$(document).on("click", ".js-comment-button", this.updateCloseButton);
$(document).on("keyup input", ".js-note-text", this.updateTargetButtons);
+ $(document).on('click', '.js-comment-resolve-button', this.resolveDiscussion);
$(document).on("click", ".js-note-delete", this.removeNote);
$(document).on("click", ".js-note-attachment-delete", this.removeAttachment);
$(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton);
@@ -100,6 +101,7 @@
$(document).off("click", ".js-note-target-close");
$(document).off("click", ".js-note-discard");
$(document).off("keydown", ".js-note-text");
+ $(document).off('click', '.js-comment-resolve-button');
$('.note .js-task-list-container').taskList('disable');
return $(document).off('tasklist:changed', '.note .js-task-list-container');
};
@@ -201,7 +203,7 @@
Increase @pollingInterval up to 120 seconds on every function call,
if `shouldReset` has a truthy value, 'null' or 'undefined' the variable
will reset to @basePollingInterval.
-
+
Note: this function is used to gradually increase the polling interval
if there aren't new notes coming from the server
*/
@@ -223,7 +225,7 @@
/*
Render note in main comments area.
-
+
Note: for rendering inline notes use renderDiscussionNote
*/
@@ -231,7 +233,13 @@
var $notesList, votesBlock;
if (!note.valid) {
if (note.award) {
- new Flash('You have already awarded this emoji!', 'alert');
+ new Flash('You have already awarded this emoji!', 'alert', this.parentTimeline);
+ }
+ else {
+ if (note.errors.commands_only) {
+ new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
+ this.refresh();
+ }
}
return;
}
@@ -245,6 +253,7 @@
$notesList.append(note.html).syntaxHighlight();
gl.utils.localTimeAgo($notesList.find("#note_" + note.id + " .js-timeago"), false);
this.initTaskList();
+ this.refresh();
return this.updateNotesCount(1);
}
};
@@ -265,7 +274,7 @@
/*
Render note in discussion area.
-
+
Note: for rendering inline notes use renderDiscussionNote
*/
@@ -297,6 +306,11 @@
} else {
discussionContainer.append(note_html);
}
+
+ if (typeof DiffNotesApp !== 'undefined') {
+ DiffNotesApp.compileComponents();
+ }
+
gl.utils.localTimeAgo($('.js-timeago', note_html), false);
return this.updateNotesCount(1);
};
@@ -304,7 +318,7 @@
/*
Called in response the main target form has been successfully submitted.
-
+
Removes any errors.
Resets text and preview.
Resets buttons.
@@ -329,7 +343,7 @@
/*
Shows the main form and does some setup on it.
-
+
Sets some hidden fields in the form.
*/
@@ -343,13 +357,14 @@
form.find("#note_line_code").remove();
form.find("#note_position").remove();
form.find("#note_type").remove();
+ form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
return this.parentTimeline = form.parents('.timeline');
};
/*
General note form setup.
-
+
deactivates the submit button when text is empty
hides the preview button when text is empty
setup GFM auto complete
@@ -366,7 +381,7 @@
/*
Called in response to the new note form being submitted
-
+
Adds new note to list.
*/
@@ -381,19 +396,33 @@
/*
Called in response to the new note form being submitted
-
+
Adds new note to list.
*/
Notes.prototype.addDiscussionNote = function(xhr, note, status) {
+ var $form = $(xhr.target);
+
+ if ($form.attr('data-resolve-all') != null) {
+ var namespacePath = $form.attr('data-namespace-path'),
+ projectPath = $form.attr('data-project-path')
+ discussionId = $form.attr('data-discussion-id'),
+ mergeRequestId = $form.attr('data-noteable-iid'),
+ namespace = namespacePath + '/' + projectPath;
+
+ if (ResolveService != null) {
+ ResolveService.toggleResolveForDiscussion(namespace, mergeRequestId, discussionId);
+ }
+ }
+
this.renderDiscussionNote(note);
- return this.removeDiscussionNoteForm($(xhr.target));
+ this.removeDiscussionNoteForm($form);
};
/*
Called in response to the edit note form being submitted
-
+
Updates the current note field.
*/
@@ -404,13 +433,18 @@
$html.syntaxHighlight();
$html.find('.js-task-list-container').taskList('enable');
$note_li = $('.note-row-' + note.id);
- return $note_li.replaceWith($html);
+
+ $note_li.replaceWith($html);
+
+ if (typeof DiffNotesApp !== 'undefined') {
+ DiffNotesApp.compileComponents();
+ }
};
/*
Called in response to clicking the edit note link
-
+
Replaces the note text with the note edit form
Adds a data attribute to the form with the original content of the note for cancellations
*/
@@ -450,7 +484,7 @@
/*
Called in response to clicking the edit note link
-
+
Hides edit form and restores the original note text to the editor textarea.
*/
@@ -472,7 +506,7 @@
/*
Called in response to deleting a note of any kind.
-
+
Removes the actual note from view.
Removes the whole discussion if the last note is being removed.
*/
@@ -485,6 +519,15 @@
var note, notes;
note = $(el);
notes = note.closest(".notes");
+
+ if (typeof DiffNotesApp !== "undefined" && DiffNotesApp !== null) {
+ ref = DiffNotesApp.$refs[noteId];
+
+ if (ref) {
+ ref.$destroy(true);
+ }
+ }
+
if (notes.find(".note").length === 1) {
notes.closest(".timeline-entry").remove();
notes.closest("tr").remove();
@@ -498,7 +541,7 @@
/*
Called in response to clicking the delete attachment link
-
+
Removes the attachment wrapper view, including image tag if it exists
Resets the note editing form
*/
@@ -515,7 +558,7 @@
/*
Called when clicking on the "reply" button for a diff line.
-
+
Shows the note form below the notes.
*/
@@ -523,17 +566,19 @@
var form, replyLink;
form = this.formClone.clone();
replyLink = $(e.target).closest(".js-discussion-reply-button");
- replyLink.hide();
- replyLink.after(form);
+ replyLink
+ .closest('.discussion-reply-holder')
+ .hide()
+ .after(form);
return this.setupDiscussionNoteForm(replyLink, form);
};
/*
Shows the diff or discussion form and does some setup on it.
-
+
Sets some hidden fields in the form.
-
+
Note: dataHolder must have the "discussionId", "lineCode", "noteableType"
and "noteableId" data attributes set.
*/
@@ -549,15 +594,29 @@
form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
+ form.find('.js-note-target-close').remove();
this.setupNoteForm(form);
+
+ if (typeof DiffNotesApp !== 'undefined') {
+ var $commentBtn = form.find('comment-and-resolve-btn');
+ $commentBtn
+ .attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'");
+ DiffNotesApp.$compile($commentBtn.get(0));
+ }
+
form.find(".js-note-text").focus();
- return form.removeClass('js-main-target-form').addClass("discussion-form js-discussion-note-form");
+ form
+ .find('.js-comment-resolve-button')
+ .attr('data-discussion-id', dataHolder.data('discussionId'));
+ form
+ .removeClass('js-main-target-form')
+ .addClass("discussion-form js-discussion-note-form");
};
/*
Called when clicking on the "add a comment" button on the side of a diff line.
-
+
Inserts a temporary row for the form below the line.
Sets up the form and shows it.
*/
@@ -570,16 +629,19 @@
nextRow = row.next();
hasNotes = nextRow.is(".notes_holder");
addForm = false;
- targetContent = ".notes_content";
- rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"></td></tr>";
+ notesContentSelector = ".notes_content";
+ rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>";
if (this.isParallelView()) {
lineType = $link.data("lineType");
- targetContent += "." + lineType;
- rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\"></td><td class=\"notes_content parallel old\"></td><td class=\"notes_line\"></td><td class=\"notes_content parallel new\"></td></tr>";
+ notesContentSelector += "." + lineType;
+ rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>";
}
+ notesContentSelector += " .content";
if (hasNotes) {
- notesContent = nextRow.find(targetContent);
+ nextRow.show();
+ notesContent = nextRow.find(notesContentSelector);
if (notesContent.length) {
+ notesContent.show();
replyButton = notesContent.find(".js-discussion-reply-button:visible");
if (replyButton.length) {
e.target = replyButton[0];
@@ -593,11 +655,13 @@
}
} else {
row.after(rowCssToAdd);
+ nextRow = row.next();
+ notesContent = nextRow.find(notesContentSelector);
addForm = true;
}
if (addForm) {
newForm = this.formClone.clone();
- newForm.appendTo(row.next().find(targetContent));
+ newForm.appendTo(notesContent);
return this.setupDiscussionNoteForm($link, newForm);
}
};
@@ -605,7 +669,7 @@
/*
Called in response to "cancel" on a diff note form.
-
+
Shows the reply button again.
Removes the form and if necessary it's temporary row.
*/
@@ -616,7 +680,9 @@
glForm = form.data('gl-form');
glForm.destroy();
form.find(".js-note-text").data("autosave").reset();
- form.prev(".js-discussion-reply-button").show();
+ form
+ .prev('.discussion-reply-holder')
+ .show();
if (row.is(".js-temp-notes-holder")) {
return row.remove();
} else {
@@ -634,7 +700,7 @@
/*
Called after an attachment file has been selected.
-
+
Updates the file name for the selected attachment.
*/
@@ -725,6 +791,18 @@
return this.notesCountBadge.text(parseInt(this.notesCountBadge.text()) + updateCount);
};
+ Notes.prototype.resolveDiscussion = function () {
+ var $this = $(this),
+ discussionId = $this.attr('data-discussion-id');
+
+ $this
+ .closest('form')
+ .attr('data-discussion-id', discussionId)
+ .attr('data-resolve-all', 'true')
+ .attr('data-namespace-path', $this.attr('data-namespace-path'))
+ .attr('data-project-path', $this.attr('data-project-path'));
+ };
+
return Notes;
})();
diff --git a/app/assets/javascripts/pipeline.js.es6 b/app/assets/javascripts/pipeline.js.es6
new file mode 100644
index 00000000000..bf33eb10100
--- /dev/null
+++ b/app/assets/javascripts/pipeline.js.es6
@@ -0,0 +1,15 @@
+(function() {
+ function toggleGraph() {
+ const $pipelineBtn = $(this).closest('.toggle-pipeline-btn');
+ const $pipelineGraph = $(this).closest('.row-content-block').next('.pipeline-graph');
+ const $btnText = $(this).find('.toggle-btn-text');
+
+ $($pipelineBtn).add($pipelineGraph).toggleClass('graph-collapsed');
+
+ const graphCollapsed = $pipelineGraph.hasClass('graph-collapsed');
+
+ graphCollapsed ? $btnText.text('Expand') : $btnText.text('Hide')
+ }
+
+ $(document).on('click', '.toggle-pipeline-btn', toggleGraph);
+})();
diff --git a/app/assets/javascripts/markdown_preview.js b/app/assets/javascripts/preview_markdown.js
index 18fc7bae09a..5fd75799640 100644
--- a/app/assets/javascripts/markdown_preview.js
+++ b/app/assets/javascripts/preview_markdown.js
@@ -28,7 +28,7 @@
};
MarkdownPreview.prototype.renderMarkdown = function(text, success) {
- if (!window.markdown_preview_path) {
+ if (!window.preview_markdown_path) {
return;
}
if (text === this.ajaxCache.text) {
@@ -36,7 +36,7 @@
}
return $.ajax({
type: 'POST',
- url: window.markdown_preview_path,
+ url: window.preview_markdown_path,
data: {
text: text
},
diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js
index e6663177161..4e1de4dfb72 100644
--- a/app/assets/javascripts/project.js
+++ b/app/assets/javascripts/project.js
@@ -65,7 +65,8 @@
url: $dropdown.data('refs-url'),
data: {
ref: $dropdown.data('ref')
- }
+ },
+ dataType: "json"
}).done(function(refs) {
return callback(refs);
});
@@ -73,7 +74,7 @@
selectable: true,
filterable: true,
filterByText: true,
- fieldName: 'ref',
+ fieldName: $dropdown.data('field-name'),
renderRow: function(ref) {
var link;
if (ref.header != null) {
@@ -89,8 +90,14 @@
toggleLabel: function(obj, $el) {
return $el.text().trim();
},
- clicked: function(e) {
- return $dropdown.closest('form').submit();
+ clicked: function(selected, $el, e) {
+ e.preventDefault()
+ if ($('input[name="ref"]').length) {
+ var $form = $dropdown.closest('form'),
+ action = $form.attr('action'),
+ divider = action.indexOf('?') < 0 ? '?' : '&';
+ Turbolinks.visit(action + '' + divider + '' + $form.serialize());
+ }
}
});
});
diff --git a/app/assets/javascripts/project_members.js b/app/assets/javascripts/project_members.js
index f6a796b325a..78f7b48bc7d 100644
--- a/app/assets/javascripts/project_members.js
+++ b/app/assets/javascripts/project_members.js
@@ -5,9 +5,6 @@
return $(this).fadeOut();
});
}
-
return ProjectMembers;
-
})();
-
}).call(this);
diff --git a/app/assets/javascripts/protected_branch_access_dropdown.js.es6 b/app/assets/javascripts/protected_branch_access_dropdown.js.es6
new file mode 100644
index 00000000000..7aeb5f92514
--- /dev/null
+++ b/app/assets/javascripts/protected_branch_access_dropdown.js.es6
@@ -0,0 +1,28 @@
+(global => {
+ global.gl = global.gl ||ย {};
+
+ gl.ProtectedBranchAccessDropdown = class {
+ constructor(options) {
+ const { $dropdown, data, onSelect } = options;
+
+ $dropdown.glDropdown({
+ data: data,
+ selectable: true,
+ inputId: $dropdown.data('input-id'),
+ fieldName: $dropdown.data('field-name'),
+ toggleLabel(item, el) {
+ if (el.is('.is-active')) {
+ return item.text;
+ } else {
+ return 'Select';
+ }
+ },
+ clicked(item, $el, e) {
+ e.preventDefault();
+ onSelect();
+ }
+ });
+ }
+ }
+
+})(window);
diff --git a/app/assets/javascripts/protected_branch_create.js.es6 b/app/assets/javascripts/protected_branch_create.js.es6
new file mode 100644
index 00000000000..46beca469b9
--- /dev/null
+++ b/app/assets/javascripts/protected_branch_create.js.es6
@@ -0,0 +1,54 @@
+(global => {
+ global.gl = global.gl ||ย {};
+
+ gl.ProtectedBranchCreate = class {
+ constructor() {
+ this.$wrap = this.$form = $('#new_protected_branch');
+ this.buildDropdowns();
+ }
+
+ buildDropdowns() {
+ const $allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
+ const $allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
+
+ // Cache callback
+ this.onSelectCallback = this.onSelect.bind(this);
+
+ // Allowed to Merge dropdown
+ new gl.ProtectedBranchAccessDropdown({
+ $dropdown: $allowedToMergeDropdown,
+ data: gon.merge_access_levels,
+ onSelect: this.onSelectCallback
+ });
+
+ // Allowed to Push dropdown
+ new gl.ProtectedBranchAccessDropdown({
+ $dropdown: $allowedToPushDropdown,
+ data: gon.push_access_levels,
+ onSelect: this.onSelectCallback
+ });
+
+ // Select default
+ $allowedToPushDropdown.data('glDropdown').selectRowAtIndex(0);
+ $allowedToMergeDropdown.data('glDropdown').selectRowAtIndex(0);
+
+ // Protected branch dropdown
+ new ProtectedBranchDropdown({
+ $dropdown: this.$wrap.find('.js-protected-branch-select'),
+ onSelect: this.onSelectCallback
+ });
+ }
+
+ // This will run after clicked callback
+ onSelect() {
+
+ // Enable submit button
+ const $branchInput = this.$wrap.find('input[name="protected_branch[name]"]');
+ const $allowedToMergeInput = this.$wrap.find('input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]');
+ const $allowedToPushInput = this.$wrap.find('input[name="protected_branch[push_access_levels_attributes][0][access_level]"]');
+
+ this.$form.find('input[type="submit"]').attr('disabled', !($branchInput.val() && $allowedToMergeInput.length && $allowedToPushInput.length));
+ }
+ }
+
+})(window);
diff --git a/app/assets/javascripts/protected_branch_dropdown.js.es6 b/app/assets/javascripts/protected_branch_dropdown.js.es6
new file mode 100644
index 00000000000..6738dc8862d
--- /dev/null
+++ b/app/assets/javascripts/protected_branch_dropdown.js.es6
@@ -0,0 +1,75 @@
+class ProtectedBranchDropdown {
+ constructor(options) {
+ this.onSelect = options.onSelect;
+ this.$dropdown = options.$dropdown;
+ this.$dropdownContainer = this.$dropdown.parent();
+ this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
+ this.$protectedBranch = this.$dropdownContainer.find('.create-new-protected-branch');
+
+ this.buildDropdown();
+ this.bindEvents();
+
+ // Hide footer
+ this.$dropdownFooter.addClass('hidden');
+ }
+
+ buildDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.getProtectedBranches.bind(this),
+ filterable: true,
+ remote: false,
+ search: {
+ fields: ['title']
+ },
+ selectable: true,
+ toggleLabel(selected) {
+ return (selected && 'id' in selected) ? selected.title : 'Protected Branch';
+ },
+ fieldName: 'protected_branch[name]',
+ text(protectedBranch) {
+ return _.escape(protectedBranch.title);
+ },
+ id(protectedBranch) {
+ return _.escape(protectedBranch.id);
+ },
+ onFilter: this.toggleCreateNewButton.bind(this),
+ clicked: (item, $el, e) => {
+ e.preventDefault();
+ this.onSelect();
+ }
+ });
+ }
+
+ bindEvents() {
+ this.$protectedBranch.on('click', this.onClickCreateWildcard.bind(this));
+ }
+
+ onClickCreateWildcard() {
+ this.$dropdown.data('glDropdown').remote.execute();
+ this.$dropdown.data('glDropdown').selectRowAtIndex(0);
+ }
+
+ getProtectedBranches(term, callback) {
+ if (this.selectedBranch) {
+ callback(gon.open_branches.concat(this.selectedBranch));
+ } else {
+ callback(gon.open_branches);
+ }
+ }
+
+ toggleCreateNewButton(branchName) {
+ this.selectedBranch = {
+ title: branchName,
+ id: branchName,
+ text: branchName
+ };
+
+ if (branchName) {
+ this.$dropdownContainer
+ .find('.create-new-protected-branch code')
+ .text(branchName);
+ }
+
+ this.$dropdownFooter.toggleClass('hidden', !branchName);
+ }
+}
diff --git a/app/assets/javascripts/protected_branch_edit.js.es6 b/app/assets/javascripts/protected_branch_edit.js.es6
new file mode 100644
index 00000000000..40bc4adb71b
--- /dev/null
+++ b/app/assets/javascripts/protected_branch_edit.js.es6
@@ -0,0 +1,66 @@
+(global => {
+ global.gl = global.gl ||ย {};
+
+ gl.ProtectedBranchEdit = class {
+ constructor(options) {
+ this.$wrap = options.$wrap;
+ this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge');
+ this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push');
+
+ this.buildDropdowns();
+ }
+
+ buildDropdowns() {
+
+ // Allowed to merge dropdown
+ new gl.ProtectedBranchAccessDropdown({
+ $dropdown: this.$allowedToMergeDropdown,
+ data: gon.merge_access_levels,
+ onSelect: this.onSelect.bind(this)
+ });
+
+ // Allowed to push dropdown
+ new gl.ProtectedBranchAccessDropdown({
+ $dropdown: this.$allowedToPushDropdown,
+ data: gon.push_access_levels,
+ onSelect: this.onSelect.bind(this)
+ });
+ }
+
+ onSelect() {
+ const $allowedToMergeInput = this.$wrap.find(`input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`);
+ const $allowedToPushInput = this.$wrap.find(`input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`);
+
+ // Do not update if one dropdown has not selected any option
+ if (!($allowedToMergeInput.length && $allowedToPushInput.length)) return;
+
+ $.ajax({
+ type: 'POST',
+ url: this.$wrap.data('url'),
+ dataType: 'json',
+ data: {
+ _method: 'PATCH',
+ id: this.$wrap.data('banchId'),
+ protected_branch: {
+ merge_access_levels_attributes: [{
+ id: this.$allowedToMergeDropdown.data('access-level-id'),
+ access_level: $allowedToMergeInput.val()
+ }],
+ push_access_levels_attributes: [{
+ id: this.$allowedToPushDropdown.data('access-level-id'),
+ access_level: $allowedToPushInput.val()
+ }]
+ }
+ },
+ success: () => {
+ this.$wrap.effect('highlight');
+ },
+ error() {
+ $.scrollTo(0);
+ new Flash('Failed to update branch!');
+ }
+ });
+ }
+ }
+
+})(window);
diff --git a/app/assets/javascripts/protected_branch_edit_list.js.es6 b/app/assets/javascripts/protected_branch_edit_list.js.es6
new file mode 100644
index 00000000000..9ff0fd12c76
--- /dev/null
+++ b/app/assets/javascripts/protected_branch_edit_list.js.es6
@@ -0,0 +1,17 @@
+(global => {
+ global.gl = global.gl ||ย {};
+
+ gl.ProtectedBranchEditList = class {
+ constructor()ย {
+ this.$wrap = $('.protected-branches-list');
+
+ // Build edit forms
+ this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => {
+ new gl.ProtectedBranchEdit({
+ $wrap: $(el)
+ });
+ });
+ }
+ }
+
+})(window);
diff --git a/app/assets/javascripts/protected_branch_select.js b/app/assets/javascripts/protected_branch_select.js
deleted file mode 100644
index 3a47fc972dc..00000000000
--- a/app/assets/javascripts/protected_branch_select.js
+++ /dev/null
@@ -1,72 +0,0 @@
-(function() {
- var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
-
- this.ProtectedBranchSelect = (function() {
- function ProtectedBranchSelect(currentProject) {
- this.toggleCreateNewButton = bind(this.toggleCreateNewButton, this);
- this.getProtectedBranches = bind(this.getProtectedBranches, this);
- $('.dropdown-footer').hide();
- this.dropdown = $('.js-protected-branch-select').glDropdown({
- data: this.getProtectedBranches,
- filterable: true,
- remote: false,
- search: {
- fields: ['title']
- },
- selectable: true,
- toggleLabel: function(selected) {
- if (selected && 'id' in selected) {
- return selected.title;
- } else {
- return 'Protected Branch';
- }
- },
- fieldName: 'protected_branch[name]',
- text: function(protected_branch) {
- return _.escape(protected_branch.title);
- },
- id: function(protected_branch) {
- return _.escape(protected_branch.id);
- },
- onFilter: this.toggleCreateNewButton,
- clicked: function() {
- return $('.protect-branch-btn').attr('disabled', false);
- }
- });
- $('.create-new-protected-branch').on('click', (function(_this) {
- return function(event) {
- _this.dropdown.data('glDropdown').remote.execute();
- return _this.dropdown.data('glDropdown').selectRowAtIndex(event, 0);
- };
- })(this));
- }
-
- ProtectedBranchSelect.prototype.getProtectedBranches = function(term, callback) {
- if (this.selectedBranch) {
- return callback(gon.open_branches.concat(this.selectedBranch));
- } else {
- return callback(gon.open_branches);
- }
- };
-
- ProtectedBranchSelect.prototype.toggleCreateNewButton = function(branchName) {
- this.selectedBranch = {
- title: branchName,
- id: branchName,
- text: branchName
- };
- if (branchName === '') {
- $('.protected-branch-select-footer-list').addClass('hidden');
- return $('.dropdown-footer').hide();
- } else {
- $('.create-new-protected-branch').text("Create Protected Branch: " + branchName);
- $('.protected-branch-select-footer-list').removeClass('hidden');
- return $('.dropdown-footer').show();
- }
- };
-
- return ProtectedBranchSelect;
-
- })();
-
-}).call(this);
diff --git a/app/assets/javascripts/protected_branches_access_select.js.es6 b/app/assets/javascripts/protected_branches_access_select.js.es6
deleted file mode 100644
index e98312bbf37..00000000000
--- a/app/assets/javascripts/protected_branches_access_select.js.es6
+++ /dev/null
@@ -1,63 +0,0 @@
-class ProtectedBranchesAccessSelect {
- constructor(container, saveOnSelect, selectDefault) {
- this.container = container;
- this.saveOnSelect = saveOnSelect;
-
- this.container.find(".allowed-to-merge").each((i, element) => {
- var fieldName = $(element).data('field-name');
- var dropdown = $(element).glDropdown({
- data: gon.merge_access_levels,
- selectable: true,
- fieldName: fieldName,
- clicked: _.chain(this.onSelect).partial(element).bind(this).value()
- });
-
- if (selectDefault) {
- dropdown.data('glDropdown').selectRowAtIndex(document.createEvent("Event"), 0);
- }
- });
-
-
- this.container.find(".allowed-to-push").each((i, element) => {
- var fieldName = $(element).data('field-name');
- var dropdown = $(element).glDropdown({
- data: gon.push_access_levels,
- selectable: true,
- fieldName: fieldName,
- clicked: _.chain(this.onSelect).partial(element).bind(this).value()
- });
-
- if (selectDefault) {
- dropdown.data('glDropdown').selectRowAtIndex(document.createEvent("Event"), 0);
- }
- });
- }
-
- onSelect(dropdown, selected, element, e) {
- $(dropdown).find('.dropdown-toggle-text').text(selected.text);
- if (this.saveOnSelect) {
- return $.ajax({
- type: "POST",
- url: $(dropdown).data('url'),
- dataType: "json",
- data: {
- _method: 'PATCH',
- id: $(dropdown).data('id'),
- protected_branch: {
- ["" + ($(dropdown).data('type')) + "_attributes"]: {
- "access_level": selected.id
- }
- }
- },
- success: function() {
- var row;
- row = $(e.target);
- return row.closest('tr').effect('highlight');
- },
- error: function() {
- return new Flash("Failed to update branch!", "alert");
- }
- });
- }
- }
-}
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 990f6536eb2..227e8c696b4 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -7,7 +7,9 @@
KEYCODE = {
ESCAPE: 27,
BACKSPACE: 8,
- ENTER: 13
+ ENTER: 13,
+ UP: 38,
+ DOWN: 40
};
function SearchAutocomplete(opts) {
@@ -223,6 +225,12 @@
case KEYCODE.ESCAPE:
this.restoreOriginalState();
break;
+ case KEYCODE.ENTER:
+ this.disableAutocomplete();
+ break;
+ case KEYCODE.UP:
+ case KEYCODE.DOWN:
+ return;
default:
if (this.searchInput.val() === '') {
this.disableAutocomplete();
@@ -319,9 +327,11 @@
};
SearchAutocomplete.prototype.disableAutocomplete = function() {
- this.searchInput.addClass('disabled');
- this.dropdown.removeClass('open');
- return this.restoreMenu();
+ if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('open')) {
+ this.searchInput.addClass('disabled');
+ this.dropdown.removeClass('open').trigger('hidden.bs.dropdown');
+ this.restoreMenu();
+ }
};
SearchAutocomplete.prototype.restoreMenu = function() {
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index b9ae497b0e5..156b9b8abec 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -35,10 +35,16 @@
this.isOpen = !this.isOpen;
if (!this.isOpen && !this.hasError) {
this.content.hide();
- return this.collapsedContent.show();
+ this.collapsedContent.show();
+ if (typeof DiffNotesApp !== 'undefined') {
+ DiffNotesApp.compileComponents();
+ }
} else if (this.content) {
this.collapsedContent.hide();
- return this.content.show();
+ this.content.show();
+ if (typeof DiffNotesApp !== 'undefined') {
+ DiffNotesApp.compileComponents();
+ }
} else {
return this.getContentHTML();
}
@@ -57,7 +63,11 @@
_this.hasError = true;
_this.content = $(ERROR_HTML);
}
- return _this.collapsedContent.after(_this.content);
+ _this.collapsedContent.after(_this.content);
+
+ if (typeof DiffNotesApp !== 'undefined') {
+ DiffNotesApp.compileComponents();
+ }
};
})(this));
};
diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js
new file mode 100644
index 00000000000..855e97eb301
--- /dev/null
+++ b/app/assets/javascripts/snippet/snippet_bundle.js
@@ -0,0 +1,12 @@
+/*= require_tree . */
+
+(function() {
+ $(function() {
+ var editor = ace.edit("editor")
+
+ $(".snippet-form-holder form").on('submit', function() {
+ $(".snippet-file-content").val(editor.getValue());
+ });
+ });
+
+}).call(this);
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js.es6 b/app/assets/javascripts/templates/issuable_template_selector.js.es6
new file mode 100644
index 00000000000..c32ddf80219
--- /dev/null
+++ b/app/assets/javascripts/templates/issuable_template_selector.js.es6
@@ -0,0 +1,51 @@
+/*= require ../blob/template_selector */
+
+((global) => {
+ class IssuableTemplateSelector extends TemplateSelector {
+ constructor(...args) {
+ super(...args);
+ this.projectPath = this.dropdown.data('project-path');
+ this.namespacePath = this.dropdown.data('namespace-path');
+ this.issuableType = this.wrapper.data('issuable-type');
+ this.titleInput = $(`#${this.issuableType}_title`);
+
+ let initialQuery = {
+ name: this.dropdown.data('selected')
+ };
+
+ if (initialQuery.name) this.requestFile(initialQuery);
+
+ $('.reset-template', this.dropdown.parent()).on('click', () => {
+ if (this.currentTemplate) this.setInputValueToTemplateContent();
+ });
+ }
+
+ requestFile(query) {
+ this.startLoadingSpinner();
+ Api.issueTemplate(this.namespacePath, this.projectPath, query.name, this.issuableType, (err, currentTemplate) => {
+ this.currentTemplate = currentTemplate;
+ if (err) return; // Error handled by global AJAX error handler
+ this.stopLoadingSpinner();
+ this.setInputValueToTemplateContent();
+ });
+ return;
+ }
+
+ setInputValueToTemplateContent() {
+ // `this.requestFileSuccess` sets the value of the description input field
+ // to the content of the template selected.
+ if (this.titleInput.val() === '') {
+ // If the title has not yet been set, focus the title input and
+ // skip focusing the description input by setting `true` as the 2nd
+ // argument to `requestFileSuccess`.
+ this.requestFileSuccess(this.currentTemplate, true);
+ this.titleInput.focus();
+ } else {
+ this.requestFileSuccess(this.currentTemplate);
+ }
+ return;
+ }
+ }
+
+ global.IssuableTemplateSelector = IssuableTemplateSelector;
+})(window);
diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js.es6 b/app/assets/javascripts/templates/issuable_template_selectors.js.es6
new file mode 100644
index 00000000000..bd8cdde033e
--- /dev/null
+++ b/app/assets/javascripts/templates/issuable_template_selectors.js.es6
@@ -0,0 +1,29 @@
+((global) => {
+ class IssuableTemplateSelectors {
+ constructor(opts = {}) {
+ this.$dropdowns = opts.$dropdowns || $('.js-issuable-selector');
+ this.editor = opts.editor || this.initEditor();
+
+ this.$dropdowns.each((i, dropdown) => {
+ let $dropdown = $(dropdown);
+ new IssuableTemplateSelector({
+ pattern: /(\.md)/,
+ data: $dropdown.data('data'),
+ wrapper: $dropdown.closest('.js-issuable-selector-wrap'),
+ dropdown: $dropdown,
+ editor: this.editor
+ });
+ });
+ }
+
+ initEditor() {
+ let editor = $('.markdown-area');
+ // Proxy ace-editor's .setValue to jQuery's .val
+ editor.setValue = editor.val;
+ editor.getValue = editor.val;
+ return editor;
+ }
+ }
+
+ global.IssuableTemplateSelectors = IssuableTemplateSelectors;
+})(window);
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 4af2a214e12..bad82868ab0 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -13,14 +13,15 @@
}
$('.js-user-search').each((function(_this) {
return function(i, dropdown) {
+ var options = {};
var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser;
$dropdown = $(dropdown);
- _this.projectId = $dropdown.data('project-id');
- _this.showCurrentUser = $dropdown.data('current-user');
+ options.projectId = $dropdown.data('project-id');
+ options.showCurrentUser = $dropdown.data('current-user');
showNullUser = $dropdown.data('null-user');
showAnyUser = $dropdown.data('any-user');
firstUser = $dropdown.data('first-user');
- _this.authorId = $dropdown.data('author-id');
+ options.authorId = $dropdown.data('author-id');
selectedId = $dropdown.data('selected');
defaultLabel = $dropdown.data('default-label');
issueURL = $dropdown.data('issueUpdate');
@@ -75,7 +76,7 @@
data: function(term, callback) {
var isAuthorFilter;
isAuthorFilter = $('.js-author-search');
- return _this.users(term, function(users) {
+ return _this.users(term, options, function(users) {
var anyUser, index, j, len, name, obj, showDivider;
if (term.length === 0) {
showDivider = 0;
@@ -140,7 +141,7 @@
$selectbox.hide();
return $value.css('display', '');
},
- clicked: function(user) {
+ clicked: function(user, $el, e) {
var isIssueIndex, isMRIndex, page, selected;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
@@ -148,7 +149,12 @@
if ($dropdown.hasClass('js-filter-bulk-update')) {
return;
}
- if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ if (page === 'projects:boards:show') {
+ selectedId = user.id;
+ gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id;
+ gl.issueBoards.BoardsStore.updateFiltersUrl();
+ e.preventDefault();
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
selectedId = user.id;
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
@@ -185,11 +191,14 @@
$('.ajax-users-select').each((function(_this) {
return function(i, select) {
var firstUser, showAnyUser, showEmailUser, showNullUser;
- _this.projectId = $(select).data('project-id');
- _this.groupId = $(select).data('group-id');
- _this.showCurrentUser = $(select).data('current-user');
- _this.authorId = $(select).data('author-id');
- _this.skipUsers = $(select).data('skip-users');
+ var options = {};
+ options.skipLdap = $(select).hasClass('skip_ldap');
+ options.projectId = $(select).data('project-id');
+ options.groupId = $(select).data('group-id');
+ options.showCurrentUser = $(select).data('current-user');
+ options.pushCodeToProtectedBranches = $(select).data('push-code-to-protected-branches');
+ options.authorId = $(select).data('author-id');
+ options.skipUsers = $(select).data('skip-users');
showNullUser = $(select).data('null-user');
showAnyUser = $(select).data('any-user');
showEmailUser = $(select).data('email-user');
@@ -199,7 +208,7 @@
multiple: $(select).hasClass('multiselect'),
minimumInputLength: 0,
query: function(query) {
- return _this.users(query.term, function(users) {
+ return _this.users(query.term, options, function(users) {
var anyUser, data, emailUser, index, j, len, name, nullUser, obj, ref;
data = {
results: users
@@ -309,7 +318,7 @@
});
};
- UsersSelect.prototype.users = function(query, callback) {
+ UsersSelect.prototype.users = function(query, options, callback) {
var url;
url = this.buildUrl(this.usersPath);
return $.ajax({
@@ -318,11 +327,13 @@
search: query,
per_page: 20,
active: true,
- project_id: this.projectId,
- group_id: this.groupId,
- current_user: this.showCurrentUser,
- author_id: this.authorId,
- skip_users: this.skipUsers
+ project_id: options.projectId || null,
+ group_id: options.groupId || null,
+ skip_ldap: options.skipLdap || null,
+ current_user: options.showCurrentUser || null,
+ push_code_to_protected_branches: options.pushCodeToProtectedBranches || null,
+ author_id: options.authorId || null,
+ skip_users: options.skipUsers || null
},
dataType: "json"
}).done(function(users) {
diff --git a/app/assets/stylesheets/behaviors.scss b/app/assets/stylesheets/behaviors.scss
index 542a53f0377..897bc49e7df 100644
--- a/app/assets/stylesheets/behaviors.scss
+++ b/app/assets/stylesheets/behaviors.scss
@@ -20,3 +20,8 @@
.turn-off { display: block; }
}
}
+
+// Hide element if Vue is still working on rendering it fully.
+[v-cloak="true"] {
+ display: none !important;
+}
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 473530cf094..6c3786b49bb 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -164,6 +164,10 @@
@include btn-outline($white-light, $orange-normal, $orange-normal, $orange-light, $white-light, $orange-light);
}
+ &.btn-spam {
+ @include btn-outline($white-light, $red-normal, $red-normal, $red-light, $white-light, $red-light);
+ }
+
&.btn-danger,
&.btn-remove,
&.btn-red {
@@ -200,6 +204,10 @@
position: relative;
top: 2px;
}
+
+ svg, .fa {
+ margin-right: 3px;
+ }
}
.btn-lg {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index c54eb0d6479..7d3a063d6c2 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -56,9 +56,13 @@
position: absolute;
top: 50%;
right: 6px;
- margin-top: -4px;
+ margin-top: -6px;
color: $dropdown-toggle-icon-color;
font-size: 10px;
+ &.fa-spinner {
+ font-size: 16px;
+ margin-top: -8px;
+ }
}
&:hover, {
@@ -72,6 +76,23 @@
&.large {
width: 200px;
}
+
+ &.wide {
+ width: 100%;
+
+ + .dropdown-select {
+ width: 100%;
+ }
+ }
+
+ // Allows dynamic-width text in the dropdown toggle.
+ // Resizes to allow long text without overflowing the container.
+ &.dynamic {
+ width: auto;
+ min-width: 160px;
+ max-width: 100%;
+ padding-right: 25px;
+ }
}
.dropdown-menu,
@@ -398,6 +419,7 @@
font-size: 14px;
a {
+ cursor: pointer;
padding-left: 10px;
}
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 407f1873431..d3e3fc50736 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -63,9 +63,10 @@
&.image_file {
background: #eee;
text-align: center;
+
img {
- padding: 100px;
- max-width: 50%;
+ padding: 20px;
+ max-width: 80%;
}
}
diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss
index f4d35c4b4b1..c0de09f3968 100644
--- a/app/assets/stylesheets/framework/gfm.scss
+++ b/app/assets/stylesheets/framework/gfm.scss
@@ -2,7 +2,7 @@
* Styles that apply to all GFM related forms.
*/
-.gfm-commit, .gfm-commit_range {
+.gfm-commit_range {
font-family: $monospace_font;
font-size: 90%;
}
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 7cf4d4fba42..07c8874bf03 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -6,11 +6,11 @@
table-layout: fixed;
pre {
- padding: 10px;
+ padding: 10px 0;
border: none;
border-radius: 0;
font-family: $monospace_font;
- font-size: $code_font_size !important;
+ font-size: $code_font_size;
line-height: $code_line_height !important;
margin: 0;
overflow: auto;
@@ -20,13 +20,20 @@
border-left: 1px solid;
code {
+ display: inline-block;
+ min-width: 100%;
font-family: $monospace_font;
- white-space: pre;
+ white-space: normal;
word-wrap: normal;
padding: 0;
.line {
- display: inline-block;
+ display: block;
+ width: 100%;
+ min-height: 19px;
+ padding-left: 10px;
+ padding-right: 10px;
+ white-space: pre;
}
}
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 2c40ec430ca..965fcc06518 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -114,6 +114,12 @@ ul.content-list {
font-size: $list-font-size;
color: $list-text-color;
+ &.no-description {
+ .title {
+ line-height: $list-text-height;
+ }
+ }
+
.title {
font-weight: 600;
}
@@ -134,12 +140,11 @@ ul.content-list {
}
.controls {
- padding-top: 1px;
float: right;
> .control-text {
margin-right: $gl-padding-top;
- line-height: 40px;
+ line-height: $list-text-height;
&:last-child {
margin-right: 0;
@@ -150,7 +155,7 @@ ul.content-list {
> .btn-group {
margin-right: $gl-padding-top;
display: inline-block;
- margin-top: 4px;
+ margin-top: 3px;
margin-bottom: 4px;
&:last-child {
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 96565da1bc9..edea4ad00eb 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -147,3 +147,8 @@
color: $gl-link-color;
}
}
+
+.atwho-view small.description {
+ float: right;
+ padding: 3px 5px;
+}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 5ec5a96a597..d2d60ed7196 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -123,4 +123,9 @@
}
}
}
-} \ No newline at end of file
+}
+
+@mixin dark-diff-match-line {
+ color: rgba(255, 255, 255, 0.3);
+ background: rgba(255, 255, 255, 0.1);
+}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 26ad2870aa0..8374f30d0b2 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -1,6 +1,5 @@
.modal-body {
position: relative;
- overflow-y: auto;
padding: 15px;
.form-actions {
diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss
index 364952d3b4a..9e924f99e9c 100644
--- a/app/assets/stylesheets/framework/nav.scss
+++ b/app/assets/stylesheets/framework/nav.scss
@@ -72,6 +72,7 @@
font-weight: normal;
background-color: #eee;
color: #78a;
+ vertical-align: baseline;
}
}
@@ -182,7 +183,6 @@
> form {
display: inline-block;
- margin-top: -1px;
}
.icon-label {
@@ -193,7 +193,6 @@
height: 35px;
display: inline-block;
position: relative;
- top: 2px;
margin-right: $gl-padding-top;
/* Medium devices (desktops, 992px and up) */
diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss
index 874416e1007..c6f30e144fd 100644
--- a/app/assets/stylesheets/framework/panels.scss
+++ b/app/assets/stylesheets/framework/panels.scss
@@ -23,4 +23,9 @@
margin-top: $gl-padding;
}
}
+
+ .panel-title {
+ font-size: inherit;
+ line-height: inherit;
+ }
}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 21d87cc9d34..b2e22b60440 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -45,7 +45,8 @@
min-width: 175px;
}
-.select2-results .select2-result-label {
+.select2-results .select2-result-label,
+.select2-more-results {
padding: 10px 15px;
}
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index 3fa4a22258d..015fe3debf9 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -222,3 +222,7 @@ header.header-pinned-nav {
padding-right: $sidebar_collapsed_width;
}
}
+
+.right-sidebar {
+ border-left: 1px solid $border-color;
+}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 8659604cb8b..06874a993fa 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -14,12 +14,20 @@
margin-top: 0;
}
+ // Single code lines should wrap
code {
font-family: $monospace_font;
- white-space: pre;
+ white-space: pre-wrap;
word-wrap: normal;
}
+ // Multi-line code blocks should scroll horizontally
+ pre {
+ code {
+ white-space: pre;
+ }
+ }
+
kbd {
display: inline-block;
padding: 3px 5px;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 1882d4e888d..5da390118c6 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -43,6 +43,7 @@ $gl-header-color: $gl-title-color;
$list-font-size: $gl-font-size;
$list-title-color: $gl-title-color;
$list-text-color: $gl-text-color;
+$list-text-height: 42px;
/*
* Markdown
@@ -275,3 +276,5 @@ $personal-access-tokens-disabled-label-color: #bbb;
$ci-output-bg: #1d1f21;
$ci-text-color: #c5c8c6;
+
+$issue-boards-font-size: 15px;
diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss
index 77a73dc379b..16ffbe57a99 100644
--- a/app/assets/stylesheets/highlight/dark.scss
+++ b/app/assets/stylesheets/highlight/dark.scss
@@ -21,6 +21,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include dark-diff-match-line;
+ }
+
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #557;
@@ -36,8 +40,7 @@
}
.line_content.match {
- color: rgba(255, 255, 255, 0.3);
- background: rgba(255, 255, 255, 0.1);
+ @include dark-diff-match-line;
}
}
diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss
index 80a509a7c1a..7de920e074b 100644
--- a/app/assets/stylesheets/highlight/monokai.scss
+++ b/app/assets/stylesheets/highlight/monokai.scss
@@ -21,6 +21,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include dark-diff-match-line;
+ }
+
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #49483e;
@@ -36,8 +40,7 @@
}
.line_content.match {
- color: rgba(255, 255, 255, 0.3);
- background: rgba(255, 255, 255, 0.1);
+ @include dark-diff-match-line;
}
}
diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss
index c62bd021aef..b11499c71ee 100644
--- a/app/assets/stylesheets/highlight/solarized_dark.scss
+++ b/app/assets/stylesheets/highlight/solarized_dark.scss
@@ -21,6 +21,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include dark-diff-match-line;
+ }
+
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #174652;
@@ -36,8 +40,7 @@
}
.line_content.match {
- color: rgba(255, 255, 255, 0.3);
- background: rgba(255, 255, 255, 0.1);
+ @include dark-diff-match-line;
}
}
diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss
index 524cfaf90c3..657bb5e3cd9 100644
--- a/app/assets/stylesheets/highlight/solarized_light.scss
+++ b/app/assets/stylesheets/highlight/solarized_light.scss
@@ -1,4 +1,10 @@
/* https://gist.github.com/qguv/7936275 */
+
+@mixin matchLine {
+ color: $black-transparent;
+ background: rgba(255, 255, 255, 0.4);
+}
+
.code.solarized-light {
// Line numbers
.line-numbers, .diff-line-num {
@@ -21,6 +27,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include matchLine;
+ }
+
td.diff-line-num.hll:not(.empty-cell),
td.line_content.hll:not(.empty-cell) {
background-color: #ddd8c5;
@@ -36,8 +46,7 @@
}
.line_content.match {
- color: $black-transparent;
- background: rgba(255, 255, 255, 0.4);
+ @include matchLine;
}
}
diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss
index 31a4e3deaac..36a80a916b2 100644
--- a/app/assets/stylesheets/highlight/white.scss
+++ b/app/assets/stylesheets/highlight/white.scss
@@ -1,4 +1,10 @@
/* https://github.com/aahan/pygments-github-style */
+
+@mixin matchLine {
+ color: $black-transparent;
+ background-color: $match-line;
+}
+
.code.white {
// Line numbers
.line-numbers, .diff-line-num {
@@ -22,6 +28,10 @@
// Diff line
.line_holder {
+ &.match .line_content {
+ @include matchLine;
+ }
+
.diff-line-num {
&.old {
background-color: $line-number-old;
@@ -57,8 +67,7 @@
}
&.match {
- color: $black-transparent;
- background-color: $match-line;
+ @include matchLine;
}
&.hll:not(.empty-cell) {
diff --git a/app/assets/stylesheets/mailers/repository_push_email.scss b/app/assets/stylesheets/mailers/repository_push_email.scss
index 33aedf1f7c1..5bfe9bcb443 100644
--- a/app/assets/stylesheets/mailers/repository_push_email.scss
+++ b/app/assets/stylesheets/mailers/repository_push_email.scss
@@ -45,7 +45,6 @@
.line_content {
padding-left: 0.5em;
padding-right: 0.5em;
- white-space: pre;
&.old {
background-color: $line-removed;
@@ -71,6 +70,10 @@
}
}
+pre {
+ margin: 0;
+}
+
span.highlight_word {
background-color: #fafe3d !important;
}
diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss
index 5607239d92d..c9cdfdcd29c 100644
--- a/app/assets/stylesheets/pages/admin.scss
+++ b/app/assets/stylesheets/pages/admin.scss
@@ -72,7 +72,6 @@
margin-bottom: 20px;
}
-
// Users List
.users-list {
@@ -98,3 +97,44 @@
}
}
}
+
+.abuse-reports {
+ .table {
+ table-layout: fixed;
+ }
+ .subheading {
+ padding-bottom: $gl-padding;
+ }
+ .message {
+ word-wrap: break-word;
+ }
+ .btn {
+ white-space: normal;
+ padding: $gl-btn-padding;
+ }
+ th {
+ width: 15%;
+ &.wide {
+ width: 55%;
+ }
+ }
+ @media (max-width: $screen-sm-max) {
+ th {
+ width: 100%;
+ }
+ td {
+ width: 100%;
+ float: left;
+ }
+ }
+
+ .no-reports {
+ .emoji-icon {
+ margin-left: $btn-side-margin;
+ margin-top: 3px;
+ }
+ span {
+ font-size: 19px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
new file mode 100644
index 00000000000..9ac4d801ac4
--- /dev/null
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -0,0 +1,306 @@
+[v-cloak] {
+ display: none;
+}
+
+.user-can-drag {
+ cursor: -webkit-grab;
+ cursor: grab;
+}
+
+.is-dragging {
+ // Important because plugin sets inline CSS
+ opacity: 1!important;
+
+ * {
+ // !important to make sure no style can override this when dragging
+ cursor: -webkit-grabbing!important;
+ cursor: grabbing!important;
+ }
+}
+
+.dropdown-menu-issues-board-new {
+ width: 320px;
+
+ .dropdown-content {
+ max-height: 150px;
+ }
+}
+
+.issue-board-dropdown-content {
+ margin: 0 8px 10px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid $dropdown-divider-color;
+
+ > p {
+ margin: 0;
+ font-size: 14px;
+ color: #9c9c9c;
+ }
+}
+
+.issue-boards-page {
+ .content-wrapper {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ }
+
+ .sub-nav,
+ .issues-filters {
+ -webkit-flex: none;
+ flex: none;
+ }
+
+ .page-with-sidebar {
+ display: -webkit-flex;
+ display: flex;
+ min-height: 100vh;
+ max-height: 100vh;
+ padding-bottom: 0;
+ }
+
+ .issue-boards-content {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex: 1;
+ flex: 1;
+ width: 100%;
+
+ .content {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ width: 100%;
+ }
+ }
+}
+
+.boards-app-loading {
+ width: 100%;
+ font-size: 34px;
+}
+
+.boards-list {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex: 1;
+ flex: 1;
+ -webkit-flex-basis: 0;
+ flex-basis: 0;
+ min-height: calc(100vh - 152px);
+ max-height: calc(100vh - 152px);
+ padding-top: 25px;
+ padding-right: ($gl-padding / 2);
+ padding-left: ($gl-padding / 2);
+ overflow-x: scroll;
+
+ @media (min-width: $screen-sm-min) {
+ min-height: 475px;
+ max-height: none;
+ }
+}
+
+.board {
+ display: -webkit-flex;
+ display: flex;
+ min-width: calc(85vw - 15px);
+ max-width: calc(85vw - 15px);
+ margin-bottom: 25px;
+ padding-right: ($gl-padding / 2);
+ padding-left: ($gl-padding / 2);
+
+ @media (min-width: $screen-sm-min) {
+ min-width: 400px;
+ max-width: 400px;
+ }
+}
+
+.board-inner {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ width: 100%;
+ font-size: $issue-boards-font-size;
+ background: $background-color;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+}
+
+.board-header {
+ border-top-left-radius: $border-radius-default;
+ border-top-right-radius: $border-radius-default;
+
+ &.has-border {
+ border-top: 3px solid;
+
+ .board-title {
+ padding-top: ($gl-padding - 3px);
+ }
+ }
+}
+
+.board-header-loading-spinner {
+ margin-right: 10px;
+ color: $gray-darkest;
+}
+
+.board-inner-container {
+ border-bottom: 1px solid $border-color;
+ padding: $gl-padding;
+}
+
+.board-title {
+ position: relative;
+ margin: 0;
+ padding: $gl-padding;
+ font-size: 1em;
+ border-bottom: 1px solid $border-color;
+}
+
+.board-search-container {
+ position: relative;
+ background-color: #fff;
+
+ .form-control {
+ padding-right: 30px;
+ }
+}
+
+.board-search-icon,
+.board-search-clear-btn {
+ position: absolute;
+ right: $gl-padding + 10px;
+ top: 50%;
+ margin-top: -7px;
+ font-size: 14px;
+}
+
+.board-search-icon {
+ color: $gl-placeholder-color;
+}
+
+.board-search-clear-btn {
+ padding: 0;
+ line-height: 1;
+ background: transparent;
+ border: 0;
+ outline: 0;
+
+ &:hover {
+ color: $gl-link-color;
+ }
+}
+
+.board-delete {
+ margin-right: 10px;
+ padding: 0;
+ color: $gray-darkest;
+ background-color: transparent;
+ border: 0;
+ outline: 0;
+
+ &:hover {
+ color: $gl-link-color;
+ }
+}
+
+.board-blank-state {
+ height: 100%;
+ padding: $gl-padding;
+ background-color: #fff;
+}
+
+.board-blank-state-list {
+ list-style: none;
+
+ > li:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ .label-color {
+ position: relative;
+ top: 2px;
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin-right: 3px;
+ border-radius: $border-radius-default;
+ }
+}
+
+.board-list {
+ -webkit-flex: 1;
+ flex: 1;
+ height: 400px;
+ margin-bottom: 0;
+ padding: 5px;
+ overflow-y: scroll;
+ overflow-x: hidden;
+}
+
+.board-list-loading {
+ margin-top: 10px;
+ font-size: 26px;
+}
+
+.is-ghost {
+ opacity: 0.3;
+}
+
+.card {
+ position: relative;
+ width: 100%;
+ padding: 10px $gl-padding;
+ background: #fff;
+ border-radius: $border-radius-default;
+ box-shadow: 0 1px 2px rgba(186, 186, 186, 0.5);
+ list-style: none;
+
+ &.user-can-drag {
+ padding-left: $gl-padding;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 5px;
+ }
+
+ a {
+ cursor: pointer;
+ }
+
+ .label {
+ border: 0;
+ outline: 0;
+ }
+
+ .confidential-icon {
+ margin-right: 5px;
+ }
+}
+
+.card-title {
+ margin: 0;
+ font-size: 1em;
+
+ a {
+ color: inherit;
+ }
+}
+
+.card-footer {
+ margin-top: 5px;
+ line-height: 25px;
+
+ .label {
+ margin-right: 4px;
+ font-size: (14px / $issue-boards-font-size) * 1em;
+ }
+}
+
+.card-number {
+ margin-right: 8px;
+ font-weight: 500;
+}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index e26f8f7080d..c1bb250b42d 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -53,14 +53,6 @@
left: 70px;
}
}
-
- .nav-links {
- svg {
- position: relative;
- top: 2px;
- margin-right: 3px;
- }
- }
}
.build-header {
@@ -108,24 +100,101 @@
}
.right-sidebar.build-sidebar {
- padding-top: $gl-padding;
- padding-bottom: $gl-padding;
+ padding: $gl-padding 0;
&.right-sidebar-collapsed {
display: none;
}
+ .blocks-container {
+ padding: $gl-padding;
+ }
+
.block {
width: 100%;
}
.build-sidebar-header {
- padding-top: 0;
+ padding: 0 $gl-padding $gl-padding;
.gutter-toggle {
margin-top: 0;
}
}
+
+ .stage-item {
+ cursor: pointer;
+
+ &:hover {
+ color: $gl-text-color;
+ }
+ }
+
+ .build-dropdown {
+ padding: 0 $gl-padding;
+
+ .dropdown-menu-toggle {
+ margin-top: 8px;
+ }
+
+ .dropdown-menu {
+ right: $gl-padding;
+ left: $gl-padding;
+ width: auto;
+ }
+ }
+
+ .builds-container {
+ margin-top: $gl-padding;
+ background-color: $white-light;
+ border-top: 1px solid $border-color;
+ border-bottom: 1px solid $border-color;
+ max-height: 300px;
+ overflow: scroll;
+
+ svg {
+ position: relative;
+ top: 2px;
+ margin-right: 3px;
+ height: 13px;
+ }
+
+ a {
+ display: block;
+ padding: $gl-padding 10px $gl-padding 40px;
+ width: 270px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:hover {
+ color: $gl-text-color;
+ }
+ }
+
+ .build-job {
+ position: relative;
+
+ .fa {
+ position: absolute;
+ left: 15px;
+ top: 20px;
+ display: none;
+ }
+
+ &.active {
+ font-weight: bold;
+
+ .fa {
+ display: block;
+ }
+ }
+
+ &:hover {
+ background-color: $row-hover;
+ }
+ }
+ }
}
.build-detail-row {
diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss
index bbe0c6c5f1f..53ec0002afe 100644
--- a/app/assets/stylesheets/pages/commit.scss
+++ b/app/assets/stylesheets/pages/commit.scss
@@ -66,6 +66,15 @@
margin-left: 8px;
}
}
+
+ .ci-status-link {
+
+ svg {
+ position: relative;
+ top: 2px;
+ margin: 0 2px 0 3px;
+ }
+ }
}
.ci-status-link {
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index cbc980f52ff..6a58b445afa 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -1,8 +1,6 @@
.commits-compare-switch {
@include btn-default;
@include btn-white;
- background: image-url("switch_icon.png") no-repeat center center;
- text-indent: -9999px;
float: left;
margin-right: 9px;
}
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss
index 1b389d83525..4d9c73c6840 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/pages/detail_page.scss
@@ -34,11 +34,4 @@
}
}
}
-
- .wiki {
- code {
- white-space: pre-wrap;
- word-break: keep-all;
- }
- }
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 21b1c223c88..21cee2e3a70 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -164,7 +164,10 @@
line-height: 0;
img {
border: 1px solid #fff;
- background: image-url('trans_bg.gif');
+ background-image: linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5 100%),
+ linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5 100%);
+ background-size: 10px 10px;
+ background-position: 0 0, 5px 5px;
max-width: 100%;
}
&.deleted {
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index e160d676e35..55f9d4a0011 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -1,5 +1,35 @@
.environments {
+
.commit-title {
margin: 0;
}
+
+ .fa-play {
+ font-size: 14px;
+ }
+
+ .dropdown-new {
+ color: $table-text-gray;
+ }
+
+ .dropdown-menu {
+
+ .fa {
+ margin-right: 6px;
+ color: $table-text-gray;
+ }
+ }
+
+ .branch-name {
+ color: $gl-dark-link-color;
+ }
+}
+
+.table.builds.environments {
+ min-width: 500px;
+
+ .icon-container {
+ width: 20px;
+ text-align: center;
+ }
}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index 2a3acc3eb4c..b657ca47d38 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -23,15 +23,9 @@
}
.group-row {
- &.no-description {
- .group-name {
- line-height: 44px;
- }
- }
-
.stats {
float: right;
- line-height: 44px;
+ line-height: $list-text-height;
color: $gl-gray;
span {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 7a50bc9c832..46c4a11aa2e 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -395,3 +395,12 @@
display: inline-block;
line-height: 18px;
}
+
+.js-issuable-selector-wrap {
+ .js-issuable-selector {
+ width: 100%;
+ }
+ @media (max-width: $screen-sm-max) {
+ margin-bottom: $gl-padding;
+ }
+}
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 3b1e38fc07d..606459f82cd 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -182,6 +182,17 @@
.btn {
color: inherit;
}
+
+ a.btn {
+ padding: 0;
+
+ .has-tooltip {
+ top: 0;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ line-height: 1.1;
+ }
+ }
}
.label-options-toggle {
diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss
new file mode 100644
index 00000000000..1f499897c16
--- /dev/null
+++ b/app/assets/stylesheets/pages/merge_conflicts.scss
@@ -0,0 +1,238 @@
+$colors: (
+ white_header_head_neutral : #e1fad7,
+ white_line_head_neutral : #effdec,
+ white_button_head_neutral : #9adb84,
+
+ white_header_head_chosen : #baf0a8,
+ white_line_head_chosen : #e1fad7,
+ white_button_head_chosen : #52c22d,
+
+ white_header_origin_neutral : #e0f0ff,
+ white_line_origin_neutral : #f2f9ff,
+ white_button_origin_neutral : #87c2fa,
+
+ white_header_origin_chosen : #add8ff,
+ white_line_origin_chosen : #e0f0ff,
+ white_button_origin_chosen : #268ced,
+
+ white_header_not_chosen : #f0f0f0,
+ white_line_not_chosen : #f9f9f9,
+
+
+ dark_header_head_neutral : rgba(#3f3, .2),
+ dark_line_head_neutral : rgba(#3f3, .1),
+ dark_button_head_neutral : #40874f,
+
+ dark_header_head_chosen : rgba(#3f3, .33),
+ dark_line_head_chosen : rgba(#3f3, .2),
+ dark_button_head_chosen : #258537,
+
+ dark_header_origin_neutral : rgba(#2878c9, .4),
+ dark_line_origin_neutral : rgba(#2878c9, .3),
+ dark_button_origin_neutral : #2a5c8c,
+
+ dark_header_origin_chosen : rgba(#2878c9, .6),
+ dark_line_origin_chosen : rgba(#2878c9, .4),
+ dark_button_origin_chosen : #1d6cbf,
+
+ dark_header_not_chosen : rgba(#fff, .25),
+ dark_line_not_chosen : rgba(#fff, .1),
+
+
+ monokai_header_head_neutral : rgba(#a6e22e, .25),
+ monokai_line_head_neutral : rgba(#a6e22e, .1),
+ monokai_button_head_neutral : #376b20,
+
+ monokai_header_head_chosen : rgba(#a6e22e, .4),
+ monokai_line_head_chosen : rgba(#a6e22e, .25),
+ monokai_button_head_chosen : #39800d,
+
+ monokai_header_origin_neutral : rgba(#60d9f1, .35),
+ monokai_line_origin_neutral : rgba(#60d9f1, .15),
+ monokai_button_origin_neutral : #38848c,
+
+ monokai_header_origin_chosen : rgba(#60d9f1, .5),
+ monokai_line_origin_chosen : rgba(#60d9f1, .35),
+ monokai_button_origin_chosen : #3ea4b2,
+
+ monokai_header_not_chosen : rgba(#76715d, .24),
+ monokai_line_not_chosen : rgba(#76715d, .1),
+
+
+ solarized_light_header_head_neutral : rgba(#859900, .37),
+ solarized_light_line_head_neutral : rgba(#859900, .2),
+ solarized_light_button_head_neutral : #afb262,
+
+ solarized_light_header_head_chosen : rgba(#859900, .5),
+ solarized_light_line_head_chosen : rgba(#859900, .37),
+ solarized_light_button_head_chosen : #94993d,
+
+ solarized_light_header_origin_neutral : rgba(#2878c9, .37),
+ solarized_light_line_origin_neutral : rgba(#2878c9, .15),
+ solarized_light_button_origin_neutral : #60a1bf,
+
+ solarized_light_header_origin_chosen : rgba(#2878c9, .6),
+ solarized_light_line_origin_chosen : rgba(#2878c9, .37),
+ solarized_light_button_origin_chosen : #2482b2,
+
+ solarized_light_header_not_chosen : rgba(#839496, .37),
+ solarized_light_line_not_chosen : rgba(#839496, .2),
+
+
+ solarized_dark_header_head_neutral : rgba(#859900, .35),
+ solarized_dark_line_head_neutral : rgba(#859900, .15),
+ solarized_dark_button_head_neutral : #376b20,
+
+ solarized_dark_header_head_chosen : rgba(#859900, .5),
+ solarized_dark_line_head_chosen : rgba(#859900, .35),
+ solarized_dark_button_head_chosen : #39800d,
+
+ solarized_dark_header_origin_neutral : rgba(#2878c9, .35),
+ solarized_dark_line_origin_neutral : rgba(#2878c9, .15),
+ solarized_dark_button_origin_neutral : #086799,
+
+ solarized_dark_header_origin_chosen : rgba(#2878c9, .6),
+ solarized_dark_line_origin_chosen : rgba(#2878c9, .35),
+ solarized_dark_button_origin_chosen : #0082cc,
+
+ solarized_dark_header_not_chosen : rgba(#839496, .25),
+ solarized_dark_line_not_chosen : rgba(#839496, .15)
+);
+
+
+@mixin color-scheme($color) {
+ .header.line_content, .diff-line-num {
+ &.origin {
+ background-color: map-get($colors, #{$color}_header_origin_neutral);
+ border-color: map-get($colors, #{$color}_header_origin_neutral);
+
+ button {
+ background-color: map-get($colors, #{$color}_button_origin_neutral);
+ border-color: darken(map-get($colors, #{$color}_button_origin_neutral), 15);
+ }
+
+ &.selected {
+ background-color: map-get($colors, #{$color}_header_origin_chosen);
+ border-color: map-get($colors, #{$color}_header_origin_chosen);
+
+ button {
+ background-color: map-get($colors, #{$color}_button_origin_chosen);
+ border-color: darken(map-get($colors, #{$color}_button_origin_chosen), 15);
+ }
+ }
+
+ &.unselected {
+ background-color: map-get($colors, #{$color}_header_not_chosen);
+ border-color: map-get($colors, #{$color}_header_not_chosen);
+
+ button {
+ background-color: lighten(map-get($colors, #{$color}_button_origin_neutral), 15);
+ border-color: map-get($colors, #{$color}_button_origin_neutral);
+ }
+ }
+ }
+ &.head {
+ background-color: map-get($colors, #{$color}_header_head_neutral);
+ border-color: map-get($colors, #{$color}_header_head_neutral);
+
+ button {
+ background-color: map-get($colors, #{$color}_button_head_neutral);
+ border-color: darken(map-get($colors, #{$color}_button_head_neutral), 15);
+ }
+
+ &.selected {
+ background-color: map-get($colors, #{$color}_header_head_chosen);
+ border-color: map-get($colors, #{$color}_header_head_chosen);
+
+ button {
+ background-color: map-get($colors, #{$color}_button_head_chosen);
+ border-color: darken(map-get($colors, #{$color}_button_head_chosen), 15);
+ }
+ }
+
+ &.unselected {
+ background-color: map-get($colors, #{$color}_header_not_chosen);
+ border-color: map-get($colors, #{$color}_header_not_chosen);
+
+ button {
+ background-color: lighten(map-get($colors, #{$color}_button_head_neutral), 15);
+ border-color: map-get($colors, #{$color}_button_head_neutral);
+ }
+ }
+ }
+ }
+
+ .line_content {
+ &.origin {
+ background-color: map-get($colors, #{$color}_line_origin_neutral);
+
+ &.selected {
+ background-color: map-get($colors, #{$color}_line_origin_chosen);
+ }
+
+ &.unselected {
+ background-color: map-get($colors, #{$color}_line_not_chosen);
+ }
+ }
+ &.head {
+ background-color: map-get($colors, #{$color}_line_head_neutral);
+
+ &.selected {
+ background-color: map-get($colors, #{$color}_line_head_chosen);
+ }
+
+ &.unselected {
+ background-color: map-get($colors, #{$color}_line_not_chosen);
+ }
+ }
+ }
+}
+
+
+#conflicts {
+
+ .white {
+ @include color-scheme('white')
+ }
+
+ .dark {
+ @include color-scheme('dark')
+ }
+
+ .monokai {
+ @include color-scheme('monokai')
+ }
+
+ .solarized-light {
+ @include color-scheme('solarized_light')
+ }
+
+ .solarized-dark {
+ @include color-scheme('solarized_dark')
+ }
+
+ .diff-wrap-lines .line_content {
+ white-space: normal;
+ min-height: 19px;
+ }
+
+ .line_content.header {
+ position: relative;
+
+ button {
+ border-radius: 2px;
+ font-size: 10px;
+ position: absolute;
+ right: 10px;
+ padding: 0;
+ outline: none;
+ color: #fff;
+ width: 75px; // static width to make 2 buttons have same width
+ height: 19px;
+ }
+ }
+
+ .btn-success .fa-spinner {
+ color: #fff;
+ }
+}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 0a661e529f0..fcdaf671538 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -69,6 +69,10 @@
&.ci-success {
color: $gl-success;
+
+ a.environment {
+ color: inherit;
+ }
}
&.ci-success_with_warnings {
@@ -126,7 +130,6 @@
&.has-conflicts .fa-exclamation-triangle {
color: $gl-warning;
}
-
}
p:last-child {
@@ -371,3 +374,10 @@
}
}
}
+
+.merge-request-details {
+
+ .title {
+ margin-bottom: 20px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 3784010348a..bd875b9823f 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -159,6 +159,32 @@
}
}
+.discussion-with-resolve-btn {
+ display: table;
+ width: 100%;
+ border-collapse: separate;
+ table-layout: auto;
+
+ .btn-group {
+ display: table-cell;
+ float: none;
+ width: 1%;
+
+ &:first-child {
+ width: 100%;
+ padding-right: 5px;
+ }
+
+ &:last-child {
+ padding-left: 5px;
+ }
+ }
+
+ .btn {
+ width: 100%;
+ }
+}
+
.discussion-notes-count {
font-size: 16px;
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index a2b5437e503..08d1692c888 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -383,3 +383,80 @@ ul.notes {
color: $gl-link-color;
}
}
+
+.line-resolve-all-container {
+ .btn-group {
+ margin-top: -1px;
+ margin-left: -4px;
+ }
+
+ .discussion-next-btn {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+}
+
+.line-resolve-all {
+ display: inline-block;
+ padding: 5px 10px;
+ background-color: $background-color;
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+
+ &.has-next-btn {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ .line-resolve-btn {
+ vertical-align: middle;
+ margin-right: 5px;
+ }
+}
+
+.line-resolve-text {
+ vertical-align: middle;
+}
+
+.line-resolve-btn {
+ display: inline-block;
+ position: relative;
+ top: 2px;
+ padding: 0;
+ background-color: transparent;
+ border: none;
+ outline: 0;
+
+ &.is-disabled {
+ cursor: default;
+ }
+
+ &:not(.is-disabled):hover,
+ &:not(.is-disabled):focus,
+ &.is-active {
+ color: $gl-text-green;
+
+ svg path {
+ fill: $gl-text-green;
+ }
+ }
+
+ svg {
+ position: relative;
+ color: $notes-action-color;
+
+ path {
+ fill: $notes-action-color;
+ }
+ }
+}
+
+.discussion-next-btn {
+ svg {
+ margin: 0;
+
+ path {
+ fill: $gray-darkest;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 21919fe4d73..6fa097e3bf1 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -229,3 +229,203 @@
box-shadow: none;
}
}
+
+// Pipeline visualization
+
+.toggle-pipeline-btn {
+ background-color: $gray-dark;
+
+ .caret {
+ border-top: none;
+ border-bottom: 4px solid;
+ }
+
+ &.graph-collapsed {
+ background-color: $white-light;
+
+ .caret {
+ border-bottom: none;
+ border-top: 4px solid;
+ }
+ }
+}
+
+.pipeline-graph {
+ width: 100%;
+ overflow: auto;
+ white-space: nowrap;
+ max-height: 500px;
+ transition: max-height 0.3s, padding 0.3s;
+
+ &.graph-collapsed {
+ max-height: 0;
+ padding: 0 16px;
+ }
+}
+
+.pipeline-visualization {
+ position: relative;
+ min-width: 1220px;
+
+ ul {
+ padding: 0;
+ }
+}
+
+.stage-column {
+ display: inline-block;
+ vertical-align: top;
+ margin-right: 50px;
+
+ li {
+ list-style: none;
+ }
+
+ .stage-name {
+ margin-bottom: 15px;
+ font-weight: bold;
+ width: 150px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .build {
+ border: 1px solid $border-color;
+ position: relative;
+ padding: 6px 10px;
+ border-radius: 30px;
+ width: 150px;
+ margin-bottom: 10px;
+
+ &.playable {
+ background-color: $gray-light;
+
+ svg {
+ height: 12px;
+ width: 12px;
+ position: relative;
+ top: 1px;
+
+ path {
+ fill: $layout-link-gray;
+ }
+ }
+ }
+
+ .build-content {
+ width: 130px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ a {
+ color: $layout-link-gray;
+ }
+ }
+
+ svg {
+ position: relative;
+ top: 2px;
+ margin-right: 5px;
+ }
+
+ // Connect first build in each stage with right horizontal line
+ &:first-child {
+ &::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ right: -54px;
+ border-top: 2px solid $border-color;
+ width: 54px;
+ height: 1px;
+ }
+ }
+
+ // Connect each build (except for first) with curved lines
+ &:not(:first-child) {
+ &::after, &::before {
+ content: '';
+ top: -47px;
+ position: absolute;
+ border-bottom: 2px solid $border-color;
+ width: 20px;
+ height: 65px;
+ }
+
+ // Right connecting curves
+ &::after {
+ right: -20px;
+ border-right: 2px solid $border-color;
+ border-radius: 0 0 50px;
+ }
+
+ // Left connecting curves
+ &::before {
+ left: -20px;
+ border-left: 2px solid $border-color;
+ border-radius: 0 0 0 50px;
+ }
+ }
+
+ // Connect second build to first build with smaller curved line
+ &:nth-child(2) {
+ &::after, &::before {
+ height: 45px;
+ top: -26px;
+ }
+ }
+ }
+
+ &:last-child {
+ .build {
+ // Remove right connecting horizontal line from first build in last stage
+ &:first-child {
+ &::after, &::before {
+ border: none;
+ }
+ }
+ // Remove right curved connectors from all builds in last stage
+ &:not(:first-child) {
+ &::after {
+ border: none;
+ }
+ }
+ }
+ }
+
+ &:first-child {
+ .build {
+ // Remove left curved connectors from all builds in first stage
+ &:not(:first-child) {
+ &::before {
+ border: none;
+ }
+ }
+ }
+ }
+}
+
+.pipeline-actions {
+ border-bottom: none;
+}
+
+.toggle-pipeline-btn {
+
+ .fa {
+ color: $dropdown-header-color;
+ }
+}
+
+.pipelines.tab-pane {
+
+ .content-list.pipelines {
+ overflow: scroll;
+ }
+
+ .stage {
+ max-width: 60px;
+ width: 60px;
+ }
+}
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 46371ec6871..6f58203f49c 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -228,3 +228,9 @@
}
}
}
+
+table.u2f-registrations {
+ th:not(:last-child), td:not(:last-child) {
+ border-right: solid 1px transparent;
+ }
+} \ No newline at end of file
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 4409477916f..eaf2d3270b3 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -99,7 +99,7 @@
margin-left: auto;
margin-right: auto;
margin-bottom: 15px;
- max-width: 480px;
+ max-width: 700px;
> p {
margin-bottom: 0;
@@ -512,18 +512,12 @@ pre.light-well {
.project-row {
border-color: $table-border-color;
- &.no-description {
- .project {
- line-height: 40px;
- }
- }
-
.project-full-name {
@include str-truncated;
}
.controls {
- line-height: 40px;
+ line-height: $list-text-height;
a:hover {
text-decoration: none;
@@ -662,13 +656,9 @@ pre.light-well {
}
.new_protected_branch {
- .dropdown {
- display: inline;
- margin-left: 15px;
- }
-
label {
- min-width: 120px;
+ margin-top: 6px;
+ font-weight: normal;
}
}
@@ -684,6 +674,21 @@ pre.light-well {
font-weight: 600;
}
}
+
+ .settings-message {
+ margin: 0;
+ border-radius: 0 0 1px 1px;
+ padding: 20px 0;
+ border: none;
+ }
+
+ .table-bordered {
+ border-radius: 1px;
+
+ th:not(:last-child), td:not(:last-child) {
+ border-right: solid 1px transparent;
+ }
+ }
}
.custom-notifications-form {
@@ -714,3 +719,29 @@ pre.light-well {
width: 300px;
}
}
+
+.clearable-input {
+ position: relative;
+
+ .clear-icon {
+ @extend .fa-times;
+ display: none;
+ position: absolute;
+ right: 7px;
+ top: 7px;
+ color: $location-icon-color;
+
+ &:before {
+ font-family: FontAwesome;
+ font-weight: normal;
+ font-style: normal;
+ }
+ }
+
+ &.has-value {
+ .clear-icon {
+ cursor: pointer;
+ display: block;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index cf16d070cfe..0340526a53a 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -20,10 +20,43 @@
}
}
-.todo {
+.todos-list > .todo {
+ // workaround because we cannot use border-colapse
+ border-top: 1px solid transparent;
+ display: -webkit-flex;
+ display: flex;
+ -webkit-flex-direction: row;
+ flex-direction: row;
+
&:hover {
+ background-color: $row-hover;
+ border-color: $row-hover-border;
cursor: pointer;
}
+
+ // overwrite border style of .content-list
+ &:last-child {
+ border-bottom: 1px solid transparent;
+
+ &:hover {
+ border-color: $row-hover-border;
+ }
+ }
+
+ .todo-actions {
+ display: -webkit-flex;
+ display: flex;
+ -webkit-justify-content: center;
+ justify-content: center;
+ -webkit-flex-direction: column;
+ flex-direction: column;
+ margin-left: 10px;
+ }
+
+ .todo-item {
+ -webkit-flex: auto;
+ flex: auto;
+ }
}
.todo-item {
@@ -43,8 +76,6 @@
}
.todo-body {
- margin-right: 174px;
-
.todo-note {
word-wrap: break-word;
@@ -90,6 +121,12 @@
}
@media (max-width: $screen-xs-max) {
+ .todo {
+ .avatar {
+ display: none;
+ }
+ }
+
.todo-item {
.todo-title {
white-space: normal;
@@ -98,10 +135,6 @@
margin-bottom: 10px;
}
- .avatar {
- display: none;
- }
-
.todo-body {
margin: 0;
border-left: 2px solid #ddd;
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 390977297fb..9da40fe2b09 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -58,6 +58,10 @@
.tree_commit {
max-width: 320px;
+
+ .str-truncated {
+ max-width: 100%;
+ }
}
.tree_time_ago {
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 9e1dc15de84..6ef7cf0bae6 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -109,6 +109,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:sentry_dsn,
:akismet_enabled,
:akismet_api_key,
+ :koding_enabled,
+ :koding_url,
:email_author_in_body,
:repository_checks_enabled,
:metrics_packet_size,
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index f3a88a8e6c8..cdfa8d91a28 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -42,15 +42,15 @@ class Admin::GroupsController < Admin::ApplicationController
end
def members_update
- @group.add_users(params[:user_ids].split(','), params[:access_level], current_user)
+ @group.add_users(params[:user_ids].split(','), params[:access_level], current_user: current_user)
redirect_to [:admin, @group], notice: 'Users were successfully added.'
end
def destroy
- DestroyGroupService.new(@group, current_user).execute
+ DestroyGroupService.new(@group, current_user).async_execute
- redirect_to admin_groups_path, notice: 'Group was successfully deleted.'
+ redirect_to admin_groups_path, alert: "Group '#{@group.name}' was scheduled for deletion."
end
private
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 8be35f00a77..9433da02f64 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -7,7 +7,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController
warden.set_user(impersonator, scope: :user)
- Gitlab::AppLogger.info("User #{original_user.username} has stopped impersonating #{impersonator.username}")
+ Gitlab::AppLogger.info("User #{impersonator.username} has stopped impersonating #{original_user.username}")
session[:impersonator_id] = nil
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 3a2f0185315..2abfa22712d 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -14,4 +14,14 @@ class Admin::SpamLogsController < Admin::ApplicationController
head :ok
end
end
+
+ def mark_as_ham
+ spam_log = SpamLog.find(params[:id])
+
+ if HamService.new(spam_log).mark_as_ham!
+ redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.'
+ else
+ redirect_to admin_spam_logs_path, alert: 'Error with Akismet. Please check the logs for more info.'
+ end
+ end
end
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index d828d163c28..b48668eea87 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -1,5 +1,6 @@
class AutocompleteController < ApplicationController
skip_before_action :authenticate_user!, only: [:users]
+ before_action :load_project, only: [:users]
before_action :find_users, only: [:users]
def users
@@ -34,19 +35,13 @@ class AutocompleteController < ApplicationController
def projects
project = Project.find_by_id(params[:project_id])
-
- projects = current_user.authorized_projects
- projects = projects.search(params[:search]) if params[:search].present?
- projects = projects.select do |project|
- current_user.can?(:admin_issue, project)
- end
+ projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id])
no_project = {
id: 0,
name_with_namespace: 'No project',
}
- projects.unshift(no_project)
- projects.delete(project)
+ projects.unshift(no_project) unless params[:offset_id].present?
render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace)
end
@@ -55,11 +50,8 @@ class AutocompleteController < ApplicationController
def find_users
@users =
- if params[:project_id].present?
- project = Project.find(params[:project_id])
- return render_404 unless can?(current_user, :read_project, project)
-
- project.team.users
+ if @project
+ @project.team.users
elsif params[:group_id].present?
group = Group.find(params[:group_id])
return render_404 unless can?(current_user, :read_group, group)
@@ -71,4 +63,18 @@ class AutocompleteController < ApplicationController
User.none
end
end
+
+ def load_project
+ @project ||= begin
+ if params[:project_id].present?
+ project = Project.find(params[:project_id])
+ return render_404 unless can?(current_user, :read_project, project)
+ project
+ end
+ end
+ end
+
+ def projects_finder
+ MoveToProjectFinder.new(current_user)
+ end
end
diff --git a/app/controllers/concerns/diff_for_path.rb b/app/controllers/concerns/diff_for_path.rb
index 026d8b2e1e0..aeec3009f15 100644
--- a/app/controllers/concerns/diff_for_path.rb
+++ b/app/controllers/concerns/diff_for_path.rb
@@ -1,8 +1,8 @@
module DiffForPath
extend ActiveSupport::Concern
- def render_diff_for_path(diffs, diff_refs, project)
- diff_file = safe_diff_files(diffs, diff_refs: diff_refs, repository: project.repository).find do |diff|
+ def render_diff_for_path(diffs)
+ diff_file = diffs.diff_files.find do |diff|
diff.old_path == params[:old_path] && diff.new_path == params[:new_path]
end
@@ -14,7 +14,7 @@ module DiffForPath
locals = {
diff_file: diff_file,
diff_commit: diff_commit,
- diff_refs: diff_refs,
+ diff_refs: diffs.diff_refs,
blob: blob,
project: project
}
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index c802922e0af..b5e79099e39 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -66,6 +66,11 @@ module IssuableCollections
key = 'issuable_sort'
cookies[key] = params[:sort] if params[:sort].present?
+
+ # id_desc and id_asc are old values for these two.
+ cookies[key] = sort_value_recently_created if cookies[key] == 'id_desc'
+ cookies[key] = sort_value_oldest_created if cookies[key] == 'id_asc'
+
params[:sort] = cookies[key]
end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index 58877c5ad5d..a69877edfd4 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -7,8 +7,12 @@ module ServiceParams
:build_key, :server, :teamcity_url, :drone_url, :build_type,
:description, :issues_url, :new_issue_url, :restrict_to_branch, :channel,
:colorize_messages, :channels,
- # See app/helpers/services_helper.rb
- # for why we need issues_events and merge_requests_events.
+ # We're using `issues_events` and `merge_requests_events`
+ # in the view so we still need to explicitly state them
+ # here. `Service#event_names` would only give
+ # `issue_events` and `merge_request_events` (singular!)
+ # See app/helpers/services_helper.rb for how we
+ # make those event names plural as special case.
:issues_events, :merge_requests_events,
:notify_only_broken_builds, :notify_only_broken_pipelines,
:add_pusher, :send_from_committer_email, :disable_diffs,
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
new file mode 100644
index 00000000000..29e243c66a3
--- /dev/null
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -0,0 +1,25 @@
+module SpammableActions
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :authorize_submit_spammable!, only: :mark_as_spam
+ end
+
+ def mark_as_spam
+ if SpamService.new(spammable).mark_as_spam!
+ redirect_to spammable, notice: "#{spammable.class.to_s} was submitted to Akismet successfully."
+ else
+ redirect_to spammable, alert: 'Error with Akismet. Please check the logs for more info.'
+ end
+ end
+
+ private
+
+ def spammable
+ raise NotImplementedError, "#{self.class} does not implement #{__method__}"
+ end
+
+ def authorize_submit_spammable!
+ access_denied! unless current_user.admin?
+ end
+end
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 19a76a5b5d8..d425d0f9014 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -2,11 +2,12 @@ class Dashboard::TodosController < Dashboard::ApplicationController
before_action :find_todos, only: [:index, :destroy_all]
def index
+ @sort = params[:sort]
@todos = @todos.page(params[:page])
end
def destroy
- TodoService.new.mark_todos_as_done([todo], current_user)
+ TodoService.new.mark_todos_as_done_by_ids([params[:id]], current_user)
respond_to do |format|
format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' }
@@ -27,18 +28,14 @@ class Dashboard::TodosController < Dashboard::ApplicationController
private
- def todo
- @todo ||= find_todos.find(params[:id])
- end
-
def find_todos
@todos ||= TodosFinder.new(current_user, params).execute
end
def todos_counts
{
- count: TodosFinder.new(current_user, state: :pending).execute.count,
- done_count: TodosFinder.new(current_user, state: :done).execute.count
+ count: current_user.todos_pending_count,
+ done_count: current_user.todos_done_count
}
end
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 9fc41a12536..272164cd0cc 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -21,7 +21,12 @@ class Groups::GroupMembersController < Groups::ApplicationController
end
def create
- @group.add_users(params[:user_ids].split(','), params[:access_level], current_user)
+ @group.add_users(
+ params[:user_ids].split(','),
+ params[:access_level],
+ current_user: current_user,
+ expires_at: params[:expires_at]
+ )
redirect_to group_group_members_path(@group), notice: 'Users were successfully added.'
end
@@ -63,7 +68,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
protected
def member_params
- params.require(:group_member).permit(:access_level, :user_id)
+ params.require(:group_member).permit(:access_level, :user_id, :expires_at)
end
# MembershipActions concern
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 6780a6d4d87..cb82d62616c 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -87,9 +87,9 @@ class GroupsController < Groups::ApplicationController
end
def destroy
- DestroyGroupService.new(@group, current_user).execute
+ DestroyGroupService.new(@group, current_user).async_execute
- redirect_to root_path, alert: "Group '#{@group.name}' was successfully deleted."
+ redirect_to root_path, alert: "Group '#{@group.name}' was scheduled for deletion."
end
protected
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 30df1fb2fec..7d0eff37635 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -1,5 +1,6 @@
class Import::GitlabProjectsController < Import::BaseController
before_action :verify_gitlab_project_import_enabled
+ before_action :authenticate_admin!
def new
@namespace_id = project_params[:namespace_id]
@@ -12,13 +13,14 @@ class Import::GitlabProjectsController < Import::BaseController
return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive." })
end
- imported_file = project_params[:file].path + "-import"
+ import_upload_path = Gitlab::ImportExport.import_upload_path(filename: project_params[:file].original_filename)
- FileUtils.copy_entry(project_params[:file].path, imported_file)
+ FileUtils.mkdir_p(File.dirname(import_upload_path))
+ FileUtils.copy_entry(project_params[:file].path, import_upload_path)
@project = Gitlab::ImportExport::ProjectCreator.new(project_params[:namespace_id],
current_user,
- File.expand_path(imported_file),
+ import_upload_path,
project_params[:path]).execute
if @project.saved?
@@ -46,4 +48,8 @@ class Import::GitlabProjectsController < Import::BaseController
:path, :namespace_id, :file
)
end
+
+ def authenticate_admin!
+ render_404 unless current_user.is_admin?
+ end
end
diff --git a/app/controllers/koding_controller.rb b/app/controllers/koding_controller.rb
new file mode 100644
index 00000000000..f3759b4c0ea
--- /dev/null
+++ b/app/controllers/koding_controller.rb
@@ -0,0 +1,15 @@
+class KodingController < ApplicationController
+ before_action :check_integration!, :authenticate_user!, :reject_blocked!
+ layout 'koding'
+
+ def index
+ path = File.join(Rails.root, 'doc/user/project/koding.md')
+ @markdown = File.read(path)
+ end
+
+ private
+
+ def check_integration!
+ render_404 unless current_application_settings.koding_enabled?
+ end
+end
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index c780e0983f9..6217ec5ecef 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -50,6 +50,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
flash[:notice] = "Password was successfully updated. Please login with it"
redirect_to new_user_session_path
else
+ @user.reload
render 'edit'
end
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index e37e9e136db..9eb75bb3891 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -43,11 +43,11 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# A U2F (universal 2nd factor) device's information is stored after successful
# registration, which is then used while 2FA authentication is taking place.
def create_u2f
- @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, params[:device_response], session[:challenges])
+ @u2f_registration = U2fRegistration.register(current_user, u2f_app_id, u2f_registration_params, session[:challenges])
if @u2f_registration.persisted?
session.delete(:challenges)
- redirect_to profile_account_path, notice: "Your U2F device was registered!"
+ redirect_to profile_two_factor_auth_path, notice: "Your U2F device was registered!"
else
@qr_code = build_qr_code
setup_u2f_registration
@@ -91,15 +91,19 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
# Actual communication is performed using a Javascript API
def setup_u2f_registration
@u2f_registration ||= U2fRegistration.new
- @registration_key_handles = current_user.u2f_registrations.pluck(:key_handle)
+ @u2f_registrations = current_user.u2f_registrations
u2f = U2F::U2F.new(u2f_app_id)
registration_requests = u2f.registration_requests
- sign_requests = u2f.authentication_requests(@registration_key_handles)
+ sign_requests = u2f.authentication_requests(@u2f_registrations.map(&:key_handle))
session[:challenges] = registration_requests.map(&:challenge)
gon.push(u2f: { challenges: session[:challenges], app_id: u2f_app_id,
register_requests: registration_requests,
sign_requests: sign_requests })
end
+
+ def u2f_registration_params
+ params.require(:u2f_registration).permit(:device_response, :name)
+ end
end
diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
new file mode 100644
index 00000000000..c02fe85c3cc
--- /dev/null
+++ b/app/controllers/profiles/u2f_registrations_controller.rb
@@ -0,0 +1,7 @@
+class Profiles::U2fRegistrationsController < Profiles::ApplicationController
+ def destroy
+ u2f_registration = current_user.u2f_registrations.find(params[:id])
+ u2f_registration.destroy
+ redirect_to profile_two_factor_auth_path, notice: "Successfully deleted U2F device."
+ end
+end
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 996909a28c6..91315a07deb 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -83,6 +83,7 @@ class Projects::ApplicationController < ApplicationController
end
def apply_diff_view_cookie!
+ @show_changes_tab = params[:view].present?
cookies.permanent[:diff_view] = params.delete(:view) if params[:view].present?
end
diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb
index a9f482c8787..6c25cd83a24 100644
--- a/app/controllers/projects/badges_controller.rb
+++ b/app/controllers/projects/badges_controller.rb
@@ -4,12 +4,26 @@ class Projects::BadgesController < Projects::ApplicationController
before_action :no_cache_headers, except: [:index]
def build
- badge = Gitlab::Badge::Build.new(project, params[:ref])
+ build_status = Gitlab::Badge::Build::Status
+ .new(project, params[:ref])
+ render_badge build_status
+ end
+
+ def coverage
+ coverage_report = Gitlab::Badge::Coverage::Report
+ .new(project, params[:ref], params[:job])
+
+ render_badge coverage_report
+ end
+
+ private
+
+ def render_badge(badge)
respond_to do |format|
format.html { render_404 }
format.svg do
- send_data(badge.data, type: badge.type, disposition: 'inline')
+ render 'badge', locals: { badge: badge.template }
end
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index eda3727a28d..cdf9a04bacf 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -17,6 +17,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action :require_branch_head, only: [:edit, :update]
before_action :editor_variables, except: [:show, :preview, :diff]
before_action :validate_diff_params, only: :diff
+ before_action :set_last_commit_sha, only: [:edit, :update]
def new
commit unless @repository.empty?
@@ -33,7 +34,6 @@ class Projects::BlobController < Projects::ApplicationController
end
def edit
- @last_commit = Gitlab::Git::Commit.last_for_path(@repository, @ref, @path).sha
blob.load_all_data!(@repository)
end
@@ -55,6 +55,10 @@ class Projects::BlobController < Projects::ApplicationController
create_commit(Files::UpdateService, success_path: after_edit_path,
failure_view: :edit,
failure_path: namespace_project_blob_path(@project.namespace, @project, @id))
+
+ rescue Files::UpdateService::FileChangedError
+ @conflict = true
+ render :edit
end
def preview
@@ -76,6 +80,8 @@ class Projects::BlobController < Projects::ApplicationController
end
def diff
+ apply_diff_view_cookie!
+
@form = UnfoldForm.new(params)
@lines = Gitlab::Highlight.highlight_lines(repository, @ref, @path)
@lines = @lines[@form.since - 1..@form.to - 1]
@@ -150,7 +156,8 @@ class Projects::BlobController < Projects::ApplicationController
file_path: @file_path,
commit_message: params[:commit_message],
file_content: params[:content],
- file_content_encoding: params[:encoding]
+ file_content_encoding: params[:encoding],
+ last_commit_sha: params[:last_commit_sha]
}
end
@@ -159,4 +166,9 @@ class Projects::BlobController < Projects::ApplicationController
render nothing: true
end
end
+
+ def set_last_commit_sha
+ @last_commit_sha = Gitlab::Git::Commit.
+ last_for_path(@repository, @ref, @path).sha
+ end
end
diff --git a/app/controllers/projects/board_lists_controller.rb b/app/controllers/projects/board_lists_controller.rb
new file mode 100644
index 00000000000..3cfb08d5822
--- /dev/null
+++ b/app/controllers/projects/board_lists_controller.rb
@@ -0,0 +1,65 @@
+class Projects::BoardListsController < Projects::ApplicationController
+ respond_to :json
+
+ before_action :authorize_admin_list!
+
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
+
+ def create
+ list = Boards::Lists::CreateService.new(project, current_user, list_params).execute
+
+ if list.valid?
+ render json: list.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
+ else
+ render json: list.errors, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ service = Boards::Lists::MoveService.new(project, current_user, move_params)
+
+ if service.execute
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def destroy
+ service = Boards::Lists::DestroyService.new(project, current_user, params)
+
+ if service.execute
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def generate
+ service = Boards::Lists::GenerateService.new(project, current_user)
+
+ if service.execute
+ render json: project.board.lists.label.as_json(only: [:id, :list_type, :position], methods: [:title], include: { label: { only: [:id, :title, :description, :color, :priority] } })
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def authorize_admin_list!
+ return render_403 unless can?(current_user, :admin_list, project)
+ end
+
+ def list_params
+ params.require(:list).permit(:label_id)
+ end
+
+ def move_params
+ params.require(:list).permit(:position).merge(id: params[:id])
+ end
+
+ def record_not_found(exception)
+ render json: { error: exception.message }, status: :not_found
+ end
+end
diff --git a/app/controllers/projects/boards/application_controller.rb b/app/controllers/projects/boards/application_controller.rb
new file mode 100644
index 00000000000..dad38fff6b9
--- /dev/null
+++ b/app/controllers/projects/boards/application_controller.rb
@@ -0,0 +1,15 @@
+module Projects
+ module Boards
+ class ApplicationController < Projects::ApplicationController
+ respond_to :json
+
+ rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
+
+ private
+
+ def record_not_found(exception)
+ render json: { error: exception.message }, status: :not_found
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/boards/issues_controller.rb b/app/controllers/projects/boards/issues_controller.rb
new file mode 100644
index 00000000000..1a4f6b50e8f
--- /dev/null
+++ b/app/controllers/projects/boards/issues_controller.rb
@@ -0,0 +1,56 @@
+module Projects
+ module Boards
+ class IssuesController < Boards::ApplicationController
+ before_action :authorize_read_issue!, only: [:index]
+ before_action :authorize_update_issue!, only: [:update]
+
+ def index
+ issues = ::Boards::Issues::ListService.new(project, current_user, filter_params).execute
+ issues = issues.page(params[:page])
+
+ render json: issues.as_json(
+ only: [:iid, :title, :confidential],
+ include: {
+ assignee: { only: [:id, :name, :username], methods: [:avatar_url] },
+ labels: { only: [:id, :title, :description, :color, :priority], methods: [:text_color] }
+ })
+ end
+
+ def update
+ service = ::Boards::Issues::MoveService.new(project, current_user, move_params)
+
+ if service.execute(issue)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def issue
+ @issue ||=
+ IssuesFinder.new(current_user, project_id: project.id, state: 'all')
+ .execute
+ .where(iid: params[:id])
+ .first!
+ end
+
+ def authorize_read_issue!
+ return render_403 unless can?(current_user, :read_issue, project)
+ end
+
+ def authorize_update_issue!
+ return render_403 unless can?(current_user, :update_issue, issue)
+ end
+
+ def filter_params
+ params.merge(id: params[:list_id])
+ end
+
+ def move_params
+ params.permit(:id, :from_list_id, :to_list_id)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/boards/lists_controller.rb b/app/controllers/projects/boards/lists_controller.rb
new file mode 100644
index 00000000000..b995f586737
--- /dev/null
+++ b/app/controllers/projects/boards/lists_controller.rb
@@ -0,0 +1,81 @@
+module Projects
+ module Boards
+ class ListsController < Boards::ApplicationController
+ before_action :authorize_admin_list!, only: [:create, :update, :destroy, :generate]
+ before_action :authorize_read_list!, only: [:index]
+
+ def index
+ render json: serialize_as_json(project.board.lists)
+ end
+
+ def create
+ list = ::Boards::Lists::CreateService.new(project, current_user, list_params).execute
+
+ if list.valid?
+ render json: serialize_as_json(list)
+ else
+ render json: list.errors, status: :unprocessable_entity
+ end
+ end
+
+ def update
+ list = project.board.lists.movable.find(params[:id])
+ service = ::Boards::Lists::MoveService.new(project, current_user, move_params)
+
+ if service.execute(list)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def destroy
+ list = project.board.lists.destroyable.find(params[:id])
+ service = ::Boards::Lists::DestroyService.new(project, current_user, params)
+
+ if service.execute(list)
+ head :ok
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ def generate
+ service = ::Boards::Lists::GenerateService.new(project, current_user)
+
+ if service.execute
+ render json: serialize_as_json(project.board.lists.movable)
+ else
+ head :unprocessable_entity
+ end
+ end
+
+ private
+
+ def authorize_admin_list!
+ return render_403 unless can?(current_user, :admin_list, project)
+ end
+
+ def authorize_read_list!
+ return render_403 unless can?(current_user, :read_list, project)
+ end
+
+ def list_params
+ params.require(:list).permit(:label_id)
+ end
+
+ def move_params
+ params.require(:list).permit(:position)
+ end
+
+ def serialize_as_json(resource)
+ resource.as_json(
+ only: [:id, :list_type, :position],
+ methods: [:title],
+ include: {
+ label: { only: [:id, :title, :description, :color, :priority] }
+ })
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
new file mode 100644
index 00000000000..33206717089
--- /dev/null
+++ b/app/controllers/projects/boards_controller.rb
@@ -0,0 +1,15 @@
+class Projects::BoardsController < Projects::ApplicationController
+ respond_to :html
+
+ before_action :authorize_read_board!, only: [:show]
+
+ def show
+ ::Boards::CreateService.new(project, current_user).execute
+ end
+
+ private
+
+ def authorize_read_board!
+ return access_denied! unless can?(current_user, :read_board, project)
+ end
+end
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index e926043f3eb..2de8ada3e29 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -1,12 +1,13 @@
class Projects::BranchesController < Projects::ApplicationController
include ActionView::Helpers::SanitizeHelper
+ include SortingHelper
# Authorize
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :authorize_push_code!, only: [:new, :create, :destroy]
def index
- @sort = params[:sort].presence || 'name'
+ @sort = params[:sort].presence || sort_value_name
@branches = BranchesFinder.new(@repository, params).execute
@branches = Kaminari.paginate_array(@branches).page(params[:page])
@@ -14,6 +15,13 @@ class Projects::BranchesController < Projects::ApplicationController
diverging_commit_counts = repository.diverging_commit_counts(branch)
[memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max
end
+
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: @repository.branch_names
+ end
+ end
end
def recent
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index 553b62741a5..12195c3cbb8 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -6,7 +6,7 @@ class Projects::BuildsController < Projects::ApplicationController
def index
@scope = params[:scope]
- @all_builds = project.builds
+ @all_builds = project.builds.relevant
@builds = @all_builds.order('created_at DESC')
@builds =
case @scope
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 7ae034f9398..02fb3f56890 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -28,7 +28,7 @@ class Projects::CommitController < Projects::ApplicationController
end
def diff_for_path
- render_diff_for_path(@diffs, @commit.diff_refs, @project)
+ render_diff_for_path(@commit.diffs(diff_options))
end
def builds
@@ -93,7 +93,7 @@ class Projects::CommitController < Projects::ApplicationController
end
def commit
- @commit ||= @project.commit(params[:id])
+ @noteable = @commit ||= @project.commit(params[:id])
end
def pipelines
@@ -134,8 +134,8 @@ class Projects::CommitController < Projects::ApplicationController
end
def define_status_vars
- @statuses = CommitStatus.where(pipeline: pipelines)
- @builds = Ci::Build.where(pipeline: pipelines)
+ @statuses = CommitStatus.where(pipeline: pipelines).relevant
+ @builds = Ci::Build.where(pipeline: pipelines).relevant
end
def assign_change_commit_vars(mr_source_branch)
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 8c004724f02..bee3d56076c 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -21,7 +21,7 @@ class Projects::CompareController < Projects::ApplicationController
def diff_for_path
return render_404 unless @compare
- render_diff_for_path(@diffs, @diff_refs, @project)
+ render_diff_for_path(@compare.diffs(diff_options))
end
def create
@@ -40,18 +40,12 @@ class Projects::CompareController < Projects::ApplicationController
@compare = CompareService.new.execute(@project, @head_ref, @project, @start_ref)
if @compare
- @commits = Commit.decorate(@compare.commits, @project)
-
- @start_commit = @project.commit(@start_ref)
- @commit = @project.commit(@head_ref)
- @base_commit = @project.merge_base_commit(@start_ref, @head_ref)
+ @commits = @compare.commits
+ @start_commit = @compare.start_commit
+ @commit = @compare.commit
+ @base_commit = @compare.base_commit
@diffs = @compare.diffs(diff_options)
- @diff_refs = Gitlab::Diff::DiffRefs.new(
- base_sha: @base_commit.try(:sha),
- start_sha: @start_commit.try(:sha),
- head_sha: @commit.try(:sha)
- )
@diff_notes_disabled = true
@grouped_diff_discussions = {}
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index 83d5ced9be8..529e0aa2d33 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -12,8 +12,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def new
- redirect_to namespace_project_deploy_keys_path(@project.namespace,
- @project)
+ redirect_to namespace_project_deploy_keys_path(@project.namespace, @project)
end
def create
@@ -21,19 +20,16 @@ class Projects::DeployKeysController < Projects::ApplicationController
set_index_vars
if @key.valid? && @project.deploy_keys << @key
- redirect_to namespace_project_deploy_keys_path(@project.namespace,
- @project)
+ redirect_to namespace_project_deploy_keys_path(@project.namespace, @project)
else
render "index"
end
end
def enable
- @key = accessible_keys.find(params[:id])
- @project.deploy_keys << @key
+ Projects::EnableDeployKeyService.new(@project, current_user, params).execute
- redirect_to namespace_project_deploy_keys_path(@project.namespace,
- @project)
+ redirect_to namespace_project_deploy_keys_path(@project.namespace, @project)
end
def disable
@@ -45,9 +41,9 @@ class Projects::DeployKeysController < Projects::ApplicationController
protected
def set_index_vars
- @enabled_keys ||= @project.deploy_keys
+ @enabled_keys ||= @project.deploy_keys
- @available_keys ||= accessible_keys - @enabled_keys
+ @available_keys ||= current_user.accessible_deploy_keys - @enabled_keys
@available_project_keys ||= current_user.project_deploy_keys - @enabled_keys
@available_public_keys ||= DeployKey.are_public - @enabled_keys
@@ -56,10 +52,6 @@ class Projects::DeployKeysController < Projects::ApplicationController
@available_public_keys -= @available_project_keys
end
- def accessible_keys
- @accessible_keys ||= current_user.accessible_deploy_keys
- end
-
def deploy_key_params
params.require(:deploy_key).permit(:key, :title)
end
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
new file mode 100644
index 00000000000..b2e8733ccb7
--- /dev/null
+++ b/app/controllers/projects/discussions_controller.rb
@@ -0,0 +1,43 @@
+class Projects::DiscussionsController < Projects::ApplicationController
+ before_action :module_enabled
+ before_action :merge_request
+ before_action :discussion
+ before_action :authorize_resolve_discussion!
+
+ def resolve
+ discussion.resolve!(current_user)
+
+ MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request)
+
+ render json: {
+ resolved_by: discussion.resolved_by.try(:name),
+ discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
+ }
+ end
+
+ def unresolve
+ discussion.unresolve!
+
+ render json: {
+ discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion)
+ }
+ end
+
+ private
+
+ def merge_request
+ @merge_request ||= @project.merge_requests.find_by!(iid: params[:merge_request_id])
+ end
+
+ def discussion
+ @discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404
+ end
+
+ def authorize_resolve_discussion!
+ access_denied! unless discussion.can_resolve?(current_user)
+ end
+
+ def module_enabled
+ render_404 unless @project.merge_requests_enabled
+ end
+end
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
new file mode 100644
index 00000000000..a5b4031c30f
--- /dev/null
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -0,0 +1,120 @@
+# This file should be identical in GitLab Community Edition and Enterprise Edition
+
+class Projects::GitHttpClientController < Projects::ApplicationController
+ include ActionController::HttpAuthentication::Basic
+ include KerberosSpnegoHelper
+
+ attr_reader :user
+
+ # Git clients will not know what authenticity token to send along
+ skip_before_action :verify_authenticity_token
+ skip_before_action :repository
+ before_action :authenticate_user
+ before_action :ensure_project_found!
+
+ private
+
+ def authenticate_user
+ if project && project.public? && download_request?
+ return # Allow access
+ end
+
+ if allow_basic_auth? && basic_auth_provided?
+ login, password = user_name_and_password(request)
+ auth_result = Gitlab::Auth.find_for_git_client(login, password, project: project, ip: request.ip)
+
+ if auth_result.type == :ci && download_request?
+ @ci = true
+ elsif auth_result.type == :oauth && !download_request?
+ # Not allowed
+ elsif auth_result.type == :missing_personal_token
+ render_missing_personal_token
+ return # Render above denied access, nothing left to do
+ else
+ @user = auth_result.user
+ end
+
+ if ci? || user
+ return # Allow access
+ end
+ elsif allow_kerberos_spnego_auth? && spnego_provided?
+ @user = find_kerberos_user
+
+ if user
+ send_final_spnego_response
+ return # Allow access
+ end
+ end
+
+ send_challenges
+ render plain: "HTTP Basic: Access denied\n", status: 401
+ end
+
+ def basic_auth_provided?
+ has_basic_credentials?(request)
+ end
+
+ def send_challenges
+ challenges = []
+ challenges << 'Basic realm="GitLab"' if allow_basic_auth?
+ challenges << spnego_challenge if allow_kerberos_spnego_auth?
+ headers['Www-Authenticate'] = challenges.join("\n") if challenges.any?
+ end
+
+ def ensure_project_found!
+ render_not_found if project.blank?
+ end
+
+ def project
+ return @project if defined?(@project)
+
+ project_id, _ = project_id_with_suffix
+ if project_id.blank?
+ @project = nil
+ else
+ @project = Project.find_with_namespace("#{params[:namespace_id]}/#{project_id}")
+ end
+ end
+
+ # This method returns two values so that we can parse
+ # params[:project_id] (untrusted input!) in exactly one place.
+ def project_id_with_suffix
+ id = params[:project_id] || ''
+
+ %w[.wiki.git .git].each do |suffix|
+ if id.end_with?(suffix)
+ # Be careful to only remove the suffix from the end of 'id'.
+ # Accidentally removing it from the middle is how security
+ # vulnerabilities happen!
+ return [id.slice(0, id.length - suffix.length), suffix]
+ end
+ end
+
+ # Something is wrong with params[:project_id]; do not pass it on.
+ [nil, nil]
+ end
+
+ def render_missing_personal_token
+ render plain: "HTTP Basic: Access denied\n" \
+ "You have 2FA enabled, please use a personal access token for Git over HTTP.\n" \
+ "You can generate one at #{profile_personal_access_tokens_url}",
+ status: 401
+ end
+
+ def repository
+ _, suffix = project_id_with_suffix
+ if suffix == '.wiki.git'
+ project.wiki.repository
+ else
+ project.repository
+ end
+ end
+
+ def render_not_found
+ render plain: 'Not Found', status: :not_found
+ end
+
+ def ci?
+ @ci.present?
+ end
+end
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 40a8b7940d9..b4373ef89ef 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -1,17 +1,6 @@
# This file should be identical in GitLab Community Edition and Enterprise Edition
-class Projects::GitHttpController < Projects::ApplicationController
- include ActionController::HttpAuthentication::Basic
- include KerberosSpnegoHelper
-
- attr_reader :user
-
- # Git clients will not know what authenticity token to send along
- skip_before_action :verify_authenticity_token
- skip_before_action :repository
- before_action :authenticate_user
- before_action :ensure_project_found!
-
+class Projects::GitHttpController < Projects::GitHttpClientController
# GET /foo/bar.git/info/refs?service=git-upload-pack (git pull)
# GET /foo/bar.git/info/refs?service=git-receive-pack (git push)
def info_refs
@@ -20,9 +9,9 @@ class Projects::GitHttpController < Projects::ApplicationController
elsif receive_pack? && receive_pack_allowed?
render_ok
elsif http_blocked?
- render_not_allowed
+ render_http_not_allowed
else
- render_not_found
+ render_denied
end
end
@@ -31,7 +20,7 @@ class Projects::GitHttpController < Projects::ApplicationController
if upload_pack? && upload_pack_allowed?
render_ok
else
- render_not_found
+ render_denied
end
end
@@ -40,87 +29,14 @@ class Projects::GitHttpController < Projects::ApplicationController
if receive_pack? && receive_pack_allowed?
render_ok
else
- render_not_found
+ render_denied
end
end
private
- def authenticate_user
- if project && project.public? && upload_pack?
- return # Allow access
- end
-
- if allow_basic_auth? && basic_auth_provided?
- login, password = user_name_and_password(request)
- auth_result = Gitlab::Auth.find_for_git_client(login, password, project: project, ip: request.ip)
-
- if auth_result.type == :ci && upload_pack?
- @ci = true
- elsif auth_result.type == :oauth && !upload_pack?
- # Not allowed
- else
- @user = auth_result.user
- end
-
- if ci? || user
- return # Allow access
- end
- elsif allow_kerberos_spnego_auth? && spnego_provided?
- @user = find_kerberos_user
-
- if user
- send_final_spnego_response
- return # Allow access
- end
- end
-
- send_challenges
- render plain: "HTTP Basic: Access denied\n", status: 401
- end
-
- def basic_auth_provided?
- has_basic_credentials?(request)
- end
-
- def send_challenges
- challenges = []
- challenges << 'Basic realm="GitLab"' if allow_basic_auth?
- challenges << spnego_challenge if allow_kerberos_spnego_auth?
- headers['Www-Authenticate'] = challenges.join("\n") if challenges.any?
- end
-
- def ensure_project_found!
- render_not_found if project.blank?
- end
-
- def project
- return @project if defined?(@project)
-
- project_id, _ = project_id_with_suffix
- if project_id.blank?
- @project = nil
- else
- @project = Project.find_with_namespace("#{params[:namespace_id]}/#{project_id}")
- end
- end
-
- # This method returns two values so that we can parse
- # params[:project_id] (untrusted input!) in exactly one place.
- def project_id_with_suffix
- id = params[:project_id] || ''
-
- %w[.wiki.git .git].each do |suffix|
- if id.end_with?(suffix)
- # Be careful to only remove the suffix from the end of 'id'.
- # Accidentally removing it from the middle is how security
- # vulnerabilities happen!
- return [id.slice(0, id.length - suffix.length), suffix]
- end
- end
-
- # Something is wrong with params[:project_id]; do not pass it on.
- [nil, nil]
+ def download_request?
+ upload_pack?
end
def upload_pack?
@@ -143,47 +59,37 @@ class Projects::GitHttpController < Projects::ApplicationController
render json: Gitlab::Workhorse.git_http_ok(repository, user)
end
- def repository
- _, suffix = project_id_with_suffix
- if suffix == '.wiki.git'
- project.wiki.repository
- else
- project.repository
- end
- end
-
- def render_not_found
- render plain: 'Not Found', status: :not_found
+ def render_http_not_allowed
+ render plain: access_check.message, status: :forbidden
end
- def render_not_allowed
- render plain: download_access.message, status: :forbidden
- end
-
- def ci?
- @ci.present?
+ def render_denied
+ if user && user.can?(:read_project, project)
+ render plain: 'Access denied', status: :forbidden
+ else
+ # Do not leak information about project existence
+ render_not_found
+ end
end
def upload_pack_allowed?
return false unless Gitlab.config.gitlab_shell.upload_pack
if user
- download_access.allowed?
+ access_check.allowed?
else
ci? || project.public?
end
end
def access
- return @access if defined?(@access)
-
- @access = Gitlab::GitAccess.new(user, project, 'http')
+ @access ||= Gitlab::GitAccess.new(user, project, 'http')
end
- def download_access
- return @download_access if defined?(@download_access)
-
- @download_access = access.check('git-upload-pack')
+ def access_check
+ # Use the magic string '_any' to indicate we do not know what the
+ # changes are. This is also what gitlab-shell does.
+ @access_check ||= access.check(git_command, '_any')
end
def http_blocked?
@@ -193,8 +99,6 @@ class Projects::GitHttpController < Projects::ApplicationController
def receive_pack_allowed?
return false unless Gitlab.config.gitlab_shell.receive_pack
- # Skip user authorization on upload request.
- # It will be done by the pre-receive hook in the repository.
- user.present?
+ access_check.allowed?
end
end
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 606552fa853..d0c4550733c 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -11,7 +11,9 @@ class Projects::GroupLinksController < Projects::ApplicationController
return render_404 unless can?(current_user, :read_group, group)
project.project_group_links.create(
- group: group, group_access: params[:link_group_access]
+ group: group,
+ group_access: params[:link_group_access],
+ expires_at: params[:expires_at]
)
redirect_to namespace_project_group_links_path(project.namespace, project)
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 7f5c3ff3d6a..7b0189150f8 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -4,7 +4,9 @@ class Projects::IssuesController < Projects::ApplicationController
include IssuableActions
include ToggleAwardEmoji
include IssuableCollections
+ include SpammableActions
+ before_action :redirect_to_external_issue_tracker, only: [:index, :new]
before_action :module_enabled
before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests,
:related_branches, :can_create_branch]
@@ -123,6 +125,10 @@ class Projects::IssuesController < Projects::ApplicationController
render json: @issue.to_json(include: { milestone: {}, assignee: { methods: :avatar_url }, labels: { methods: :text_color } })
end
end
+
+ rescue ActiveRecord::StaleObjectError
+ @conflict = true
+ render :edit
end
def referenced_merge_requests
@@ -175,15 +181,12 @@ class Projects::IssuesController < Projects::ApplicationController
protected
def issue
- @issue ||= begin
- @project.issues.find_by!(iid: params[:id])
- rescue ActiveRecord::RecordNotFound
- redirect_old
- end
+ @noteable = @issue ||= @project.issues.find_by(iid: params[:id]) || redirect_old
end
alias_method :subscribable_resource, :issue
alias_method :issuable, :issue
alias_method :awardable, :issue
+ alias_method :spammable, :issue
def authorize_read_issue!
return render_404 unless can?(current_user, :read_issue, @issue)
@@ -201,6 +204,18 @@ class Projects::IssuesController < Projects::ApplicationController
return render_404 unless @project.issues_enabled && @project.default_issues_tracker?
end
+ def redirect_to_external_issue_tracker
+ external = @project.external_issue_tracker
+
+ return unless external
+
+ if action_name == 'new'
+ redirect_to external.new_issue_path
+ else
+ redirect_to external.issues_url
+ end
+ end
+
# Since iids are implemented only in 6.1
# user may navigate to issue page using old global ids.
#
@@ -211,7 +226,6 @@ class Projects::IssuesController < Projects::ApplicationController
if issue
redirect_to issue_path(issue)
- return
else
raise ActiveRecord::RecordNotFound.new
end
@@ -220,7 +234,7 @@ class Projects::IssuesController < Projects::ApplicationController
def issue_params
params.require(:issue).permit(
:title, :assignee_id, :position, :description, :confidential,
- :milestone_id, :due_date, :state_event, :task_num, label_ids: []
+ :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: []
)
end
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
new file mode 100644
index 00000000000..ece49dcd922
--- /dev/null
+++ b/app/controllers/projects/lfs_api_controller.rb
@@ -0,0 +1,94 @@
+class Projects::LfsApiController < Projects::GitHttpClientController
+ include LfsHelper
+
+ before_action :require_lfs_enabled!
+ before_action :lfs_check_access!, except: [:deprecated]
+
+ def batch
+ unless objects.present?
+ render_lfs_not_found
+ return
+ end
+
+ if download_request?
+ render json: { objects: download_objects! }
+ elsif upload_request?
+ render json: { objects: upload_objects! }
+ else
+ raise "Never reached"
+ end
+ end
+
+ def deprecated
+ render(
+ json: {
+ message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.',
+ documentation_url: "#{Gitlab.config.gitlab.url}/help",
+ },
+ status: 501
+ )
+ end
+
+ private
+
+ def objects
+ @objects ||= (params[:objects] || []).to_a
+ end
+
+ def existing_oids
+ @existing_oids ||= begin
+ storage_project.lfs_objects.where(oid: objects.map { |o| o['oid'].to_s }).pluck(:oid)
+ end
+ end
+
+ def download_objects!
+ objects.each do |object|
+ if existing_oids.include?(object[:oid])
+ object[:actions] = download_actions(object)
+ else
+ object[:error] = {
+ code: 404,
+ message: "Object does not exist on the server or you don't have permissions to access it",
+ }
+ end
+ end
+ objects
+ end
+
+ def upload_objects!
+ objects.each do |object|
+ object[:actions] = upload_actions(object) unless existing_oids.include?(object[:oid])
+ end
+ objects
+ end
+
+ def download_actions(object)
+ {
+ download: {
+ href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}",
+ header: {
+ Authorization: request.headers['Authorization']
+ }.compact
+ }
+ }
+ end
+
+ def upload_actions(object)
+ {
+ upload: {
+ href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}",
+ header: {
+ Authorization: request.headers['Authorization']
+ }.compact
+ }
+ }
+ end
+
+ def download_request?
+ params[:operation] == 'download'
+ end
+
+ def upload_request?
+ params[:operation] == 'upload'
+ end
+end
diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb
new file mode 100644
index 00000000000..69066cb40e6
--- /dev/null
+++ b/app/controllers/projects/lfs_storage_controller.rb
@@ -0,0 +1,92 @@
+class Projects::LfsStorageController < Projects::GitHttpClientController
+ include LfsHelper
+
+ before_action :require_lfs_enabled!
+ before_action :lfs_check_access!
+
+ def download
+ lfs_object = LfsObject.find_by_oid(oid)
+ unless lfs_object && lfs_object.file.exists?
+ render_lfs_not_found
+ return
+ end
+
+ send_file lfs_object.file.path, content_type: "application/octet-stream"
+ end
+
+ def upload_authorize
+ render(
+ json: {
+ StoreLFSPath: "#{Gitlab.config.lfs.storage_path}/tmp/upload",
+ LfsOid: oid,
+ LfsSize: size,
+ },
+ content_type: 'application/json; charset=utf-8'
+ )
+ end
+
+ def upload_finalize
+ unless tmp_filename
+ render_lfs_forbidden
+ return
+ end
+
+ if store_file(oid, size, tmp_filename)
+ head 200
+ else
+ render plain: 'Unprocessable entity', status: 422
+ end
+ end
+
+ private
+
+ def download_request?
+ action_name == 'download'
+ end
+
+ def upload_request?
+ %w[upload_authorize upload_finalize].include? action_name
+ end
+
+ def oid
+ params[:oid].to_s
+ end
+
+ def size
+ params[:size].to_i
+ end
+
+ def tmp_filename
+ name = request.headers['X-Gitlab-Lfs-Tmp']
+ return if name.include?('/')
+ return unless oid.present? && name.start_with?(oid)
+ name
+ end
+
+ def store_file(oid, size, tmp_file)
+ # Define tmp_file_path early because we use it in "ensure"
+ tmp_file_path = File.join("#{Gitlab.config.lfs.storage_path}/tmp/upload", tmp_file)
+
+ object = LfsObject.find_or_create_by(oid: oid, size: size)
+ file_exists = object.file.exists? || move_tmp_file_to_storage(object, tmp_file_path)
+ file_exists && link_to_project(object)
+ ensure
+ FileUtils.rm_f(tmp_file_path)
+ end
+
+ def move_tmp_file_to_storage(object, path)
+ File.open(path) do |f|
+ object.file = f
+ end
+
+ object.file.store!
+ object.save
+ end
+
+ def link_to_project(object)
+ if object && !object.projects.exists?(storage_project.id)
+ object.projects << storage_project
+ object.save
+ end
+ end
+end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 03166294ddd..6a8c7166b39 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -9,15 +9,15 @@ class Projects::MergeRequestsController < Projects::ApplicationController
before_action :module_enabled
before_action :merge_request, only: [
- :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check,
- :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip
+ :edit, :update, :show, :diffs, :commits, :conflicts, :builds, :pipelines, :merge, :merge_check,
+ :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip, :resolve_conflicts
]
- before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds]
- before_action :define_show_vars, only: [:show, :diffs, :commits, :builds]
+ before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds, :pipelines]
+ before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :builds, :pipelines]
before_action :define_widget_vars, only: [:merge, :cancel_merge_when_build_succeeds, :merge_check]
before_action :define_commit_vars, only: [:diffs]
before_action :define_diff_comment_vars, only: [:diffs]
- before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds]
+ before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :pipelines]
# Allow read any merge_request
before_action :authorize_read_merge_request!
@@ -28,6 +28,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
# Allow modify merge_request
before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
+ before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :resolve_conflicts]
+
def index
terms = params['issue_search']
@merge_requests = merge_requests_collection
@@ -85,7 +87,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController
respond_to do |format|
format.html { define_discussion_vars }
- format.json { render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") } }
+ format.json do
+ @diffs = @merge_request.diffs(diff_options)
+
+ render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") }
+ end
end
end
@@ -103,9 +109,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
define_commit_vars
- diffs = @merge_request.diffs(diff_options)
- render_diff_for_path(diffs, @merge_request.diff_refs, @merge_request.project)
+ render_diff_for_path(@merge_request.diffs(diff_options))
end
def commits
@@ -127,6 +132,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
+ def conflicts
+ respond_to do |format|
+ format.html { define_discussion_vars }
+
+ format.json do
+ if @merge_request.conflicts_can_be_resolved_in_ui?
+ render json: @merge_request.conflicts
+ elsif @merge_request.can_be_merged?
+ render json: {
+ message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
+ type: 'error'
+ }
+ else
+ render json: {
+ message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
+ type: 'error'
+ }
+ end
+ end
+ end
+ end
+
+ def resolve_conflicts
+ return render_404 unless @merge_request.conflicts_can_be_resolved_in_ui?
+
+ if @merge_request.can_be_merged?
+ render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
+ return
+ end
+
+ begin
+ MergeRequests::ResolveService.new(@merge_request.source_project, current_user, params).execute(@merge_request)
+
+ flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
+
+ render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) }
+ rescue Gitlab::Conflict::File::MissingResolution => e
+ render status: :bad_request, json: { message: e.message }
+ end
+ end
+
def builds
respond_to do |format|
format.html do
@@ -138,7 +184,22 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
+ def pipelines
+ @pipelines = @merge_request.all_pipelines
+
+ respond_to do |format|
+ format.html do
+ define_discussion_vars
+
+ render 'show'
+ end
+ format.json { render json: { html: view_to_html_string('projects/merge_requests/show/_pipelines') } }
+ end
+ end
+
def new
+ apply_diff_view_cookie!
+
build_merge_request
@noteable = @merge_request
@@ -153,11 +214,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@commits = @merge_request.compare_commits.reverse
@commit = @merge_request.diff_head_commit
@base_commit = @merge_request.diff_base_commit
- @diffs = @merge_request.compare.diffs(diff_options) if @merge_request.compare
+ @diffs = @merge_request.diffs(diff_options) if @merge_request.compare
@diff_notes_disabled = true
-
@pipeline = @merge_request.pipeline
- @statuses = @pipeline.statuses if @pipeline
+ @statuses = @pipeline.statuses.relevant if @pipeline
@note_counts = Note.where(commit_id: @commits.map(&:id)).
group(:commit_id).count
@@ -198,6 +258,9 @@ class Projects::MergeRequestsController < Projects::ApplicationController
else
render "edit"
end
+ rescue ActiveRecord::StaleObjectError
+ @conflict = true
+ render :edit
end
def remove_wip
@@ -321,7 +384,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
def merge_request
- @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
+ @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id])
end
alias_method :subscribable_resource, :merge_request
alias_method :issuable, :merge_request
@@ -335,6 +398,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController
return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
end
+ def authorize_can_resolve_conflicts!
+ return render_404 unless @merge_request.conflicts_can_be_resolved_by?(current_user)
+ end
+
def module_enabled
return render_404 unless @project.merge_requests_enabled
end
@@ -359,7 +426,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@commits_count = @merge_request.commits.count
@pipeline = @merge_request.pipeline
- @statuses = @pipeline.statuses if @pipeline
+ @statuses = @pipeline.statuses.relevant if @pipeline
if @merge_request.locked_long_ago?
@merge_request.unlock_mr
@@ -371,12 +438,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController
# :show, :diff, :commits, :builds. but not when request the data through AJAX
def define_discussion_vars
# Build a note object for comment form
- @note = @project.notes.new(noteable: @noteable)
+ @note = @project.notes.new(noteable: @merge_request)
+
+ @discussions = @merge_request.discussions
- @discussions = @noteable.mr_and_commit_notes.
- inc_author_project_award_emoji.
- fresh.
- discussions
+ preload_noteable_for_regular_notes(@discussions.flat_map(&:notes))
# This is not executed lazily
@notes = Banzai::NoteRenderer.render(
@@ -407,8 +473,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
noteable_id: @merge_request.id
}
- @use_legacy_diff_notes = !@merge_request.support_new_diff_notes?
- @grouped_diff_discussions = @merge_request.notes.inc_author_project_award_emoji.grouped_diff_discussions
+ @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
+ @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions
Banzai::NoteRenderer.render(
@grouped_diff_discussions.values.flat_map(&:notes),
@@ -430,7 +496,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
:title, :assignee_id, :source_project_id, :source_branch,
:target_project_id, :target_branch, :milestone_id,
:state_event, :description, :task_num, :force_remove_source_branch,
- label_ids: []
+ :lock_version, label_ids: []
)
end
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index 766b7e9cf22..0948ad21649 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -5,6 +5,7 @@ class Projects::NotesController < Projects::ApplicationController
before_action :authorize_read_note!
before_action :authorize_create_note!, only: [:create]
before_action :authorize_admin_note!, only: [:update, :destroy]
+ before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
before_action :find_current_user_notes, only: [:index]
def index
@@ -66,6 +67,33 @@ class Projects::NotesController < Projects::ApplicationController
end
end
+ def resolve
+ return render_404 unless note.resolvable?
+
+ note.resolve!(current_user)
+
+ MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(note.noteable)
+
+ discussion = note.discussion
+
+ render json: {
+ resolved_by: note.resolved_by.try(:name),
+ discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
+ }
+ end
+
+ def unresolve
+ return render_404 unless note.resolvable?
+
+ note.unresolve!
+
+ discussion = note.discussion
+
+ render json: {
+ discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion)
+ }
+ end
+
private
def note
@@ -125,7 +153,7 @@ class Projects::NotesController < Projects::ApplicationController
id: note.id,
name: note.name
}
- elsif note.valid?
+ elsif note.persisted?
Banzai::NoteRenderer.render([note], @project, current_user)
attrs = {
@@ -138,7 +166,7 @@ class Projects::NotesController < Projects::ApplicationController
}
if note.diff_note?
- discussion = Discussion.new([note])
+ discussion = note.to_discussion
attrs.merge!(
diff_discussion_html: diff_discussion_html(discussion),
@@ -175,6 +203,10 @@ class Projects::NotesController < Projects::ApplicationController
return access_denied! unless can?(current_user, :admin_note, note)
end
+ def authorize_resolve_note!
+ return access_denied! unless can?(current_user, :resolve_note, note)
+ end
+
def note_params
params.require(:note).permit(
:note, :noteable, :noteable_id, :noteable_type, :project_id,
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 487963fdcd7..b0c72cfe4b4 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -19,7 +19,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def create
- @pipeline = Ci::CreatePipelineService.new(project, current_user, create_params).execute
+ @pipeline = Ci::CreatePipelineService.new(project, current_user, create_params).execute(ignore_skip_ci: true, save_on_errors: false)
unless @pipeline.persisted?
render 'new'
return
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index 85ba706e5cd..9136633b87a 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -3,7 +3,13 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
def show
@ref = params[:ref] || @project.default_branch || 'master'
- @build_badge = Gitlab::Badge::Build.new(@project, @ref)
+
+ @badges = [Gitlab::Badge::Build::Status,
+ Gitlab::Badge::Coverage::Report]
+
+ @badges.map! do |badge|
+ badge.new(@project, @ref).metadata
+ end
end
def update
diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb
index 3435a118964..42a7e5a2c30 100644
--- a/app/controllers/projects/project_members_controller.rb
+++ b/app/controllers/projects/project_members_controller.rb
@@ -36,7 +36,12 @@ class Projects::ProjectMembersController < Projects::ApplicationController
end
def create
- @project.team.add_users(params[:user_ids].split(','), params[:access_level], current_user)
+ @project.team.add_users(
+ params[:user_ids].split(','),
+ params[:access_level],
+ expires_at: params[:expires_at],
+ current_user: current_user
+ )
redirect_to namespace_project_project_members_path(@project.namespace, @project)
end
@@ -94,7 +99,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController
protected
def member_params
- params.require(:project_member).permit(:user_id, :access_level)
+ params.require(:project_member).permit(:user_id, :access_level, :expires_at)
end
# MembershipActions concern
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index d28ec6e2eac..9a438d5512c 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -9,16 +9,16 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
def index
@protected_branch = @project.protected_branches.new
- load_protected_branches_gon_variables
+ load_gon_index
end
def create
- @protected_branch = ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute
+ @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute
if @protected_branch.persisted?
redirect_to namespace_project_protected_branches_path(@project.namespace, @project)
else
load_protected_branches
- load_protected_branches_gon_variables
+ load_gon_index
render :index
end
end
@@ -28,7 +28,7 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
end
def update
- @protected_branch = ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch)
+ @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch)
if @protected_branch.valid?
respond_to do |format|
@@ -58,17 +58,23 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
def protected_branch_params
params.require(:protected_branch).permit(:name,
- merge_access_level_attributes: [:access_level],
- push_access_level_attributes: [:access_level])
+ merge_access_levels_attributes: [:access_level, :id],
+ push_access_levels_attributes: [:access_level, :id])
end
def load_protected_branches
@protected_branches = @project.protected_branches.order(:name).page(params[:page])
end
- def load_protected_branches_gon_variables
- gon.push({ open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } },
- push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } },
- merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text } } })
+ def access_levels_options
+ {
+ push_access_levels: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } },
+ merge_access_levels: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } }
+ }
+ end
+
+ def load_gon_index
+ params = { open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } }
+ gon.push(params.merge(access_levels_options))
end
end
diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb
new file mode 100644
index 00000000000..694b468c8d3
--- /dev/null
+++ b/app/controllers/projects/templates_controller.rb
@@ -0,0 +1,19 @@
+class Projects::TemplatesController < Projects::ApplicationController
+ before_action :authenticate_user!, :get_template_class
+
+ def show
+ template = @template_type.find(params[:key], project)
+
+ respond_to do |format|
+ format.json { render json: template.to_json }
+ end
+ end
+
+ private
+
+ def get_template_class
+ template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access
+ @template_type = template_types[params[:template_type]]
+ render json: [], status: 404 unless @template_type
+ end
+end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 607fe9c7fed..177ccf5eec9 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -91,7 +91,7 @@ class Projects::WikisController < Projects::ApplicationController
)
end
- def markdown_preview
+ def preview_markdown
text = params[:text]
ext = Gitlab::ReferenceExtractor.new(@project, current_user)
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index a6e1aa5ccc1..2a6385c1029 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -5,7 +5,7 @@ class ProjectsController < Projects::ApplicationController
before_action :project, except: [:new, :create]
before_action :repository, except: [:new, :create]
before_action :assign_ref_vars, only: [:show], if: :repo_exists?
- before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?]
+ before_action :assign_tree_vars, only: [:show], if: [:repo_exists?, :project_view_files?]
# Authorize
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
@@ -125,7 +125,7 @@ class ProjectsController < Projects::ApplicationController
def destroy
return access_denied! unless can?(current_user, :remove_project, @project)
- ::Projects::DestroyService.new(@project, current_user, {}).pending_delete!
+ ::Projects::DestroyService.new(@project, current_user, {}).async_execute
flash[:alert] = "Project '#{@project.name}' will be deleted."
redirect_to dashboard_projects_path
@@ -134,10 +134,22 @@ class ProjectsController < Projects::ApplicationController
end
def autocomplete_sources
- note_type = params['type']
- note_id = params['type_id']
+ noteable =
+ case params[:type]
+ when 'Issue'
+ IssuesFinder.new(current_user, project_id: @project.id, state: 'all').
+ execute.find_by(iid: params[:type_id])
+ when 'MergeRequest'
+ MergeRequestsFinder.new(current_user, project_id: @project.id, state: 'all').
+ execute.find_by(iid: params[:type_id])
+ when 'Commit'
+ @project.commit(params[:type_id])
+ else
+ nil
+ end
+
autocomplete = ::Projects::AutocompleteService.new(@project, current_user)
- participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id)
+ participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
@suggestions = {
emojis: Gitlab::AwardEmoji.urls,
@@ -145,7 +157,8 @@ class ProjectsController < Projects::ApplicationController
milestones: autocomplete.milestones,
mergerequests: autocomplete.merge_requests,
labels: autocomplete.labels,
- members: participants
+ members: participants,
+ commands: autocomplete.commands(noteable, params[:type])
}
respond_to do |format|
@@ -238,7 +251,7 @@ class ProjectsController < Projects::ApplicationController
}
end
- def markdown_preview
+ def preview_markdown
text = params[:text]
ext = Gitlab::ReferenceExtractor.new(@project, current_user)
@@ -319,4 +332,11 @@ class ProjectsController < Projects::ApplicationController
def get_id
project.repository.root_ref
end
+
+ # ExtractsPath will set @id = project.path on the show route, but it has to be the
+ # branch name for the tree view to work correctly.
+ def assign_tree_vars
+ @id = get_id
+ tree
+ end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 75b78a49eab..3327f4f2b87 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -33,7 +33,7 @@ class RegistrationsController < Devise::RegistrationsController
protected
- def build_resource(hash=nil)
+ def build_resource(hash = nil)
super
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 17aed816cbd..5d7ecfeacf4 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -101,7 +101,7 @@ class SessionsController < Devise::SessionsController
# Prevent alert from popping up on the first page shown after authentication.
flash[:alert] = nil
- redirect_to user_omniauth_authorize_path(provider.to_sym)
+ redirect_to omniauth_authorize_path(:user, provider)
end
def valid_otp_attempt?(user)
diff --git a/app/finders/move_to_project_finder.rb b/app/finders/move_to_project_finder.rb
new file mode 100644
index 00000000000..79eb45568be
--- /dev/null
+++ b/app/finders/move_to_project_finder.rb
@@ -0,0 +1,20 @@
+class MoveToProjectFinder
+ PAGE_SIZE = 50
+
+ def initialize(user)
+ @user = user
+ end
+
+ def execute(from_project, search: nil, offset_id: nil)
+ projects = @user.projects_where_can_admin_issues
+ projects = projects.search(search) if search.present?
+ projects = projects.excluding_project(from_project)
+
+ # infinite scroll using offset
+ projects = projects.where('projects.id < ?', offset_id) if offset_id.present?
+ projects = projects.limit(PAGE_SIZE)
+
+ # to ask for Project#name_with_namespace
+ projects.includes(namespace: :owner)
+ end
+end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 2f0a9659d15..c7911736812 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -1,6 +1,7 @@
class ProjectsFinder < UnionFinder
- def execute(current_user = nil, options = {})
+ def execute(current_user = nil, project_ids_relation = nil)
segments = all_projects(current_user)
+ segments.map! { |s| s.where(id: project_ids_relation) } if project_ids_relation
find_union(segments, Project)
end
diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb
index ff866c2faa5..06b3e8a9502 100644
--- a/app/finders/todos_finder.rb
+++ b/app/finders/todos_finder.rb
@@ -17,7 +17,7 @@ class TodosFinder
attr_accessor :current_user, :params
- def initialize(current_user, params)
+ def initialize(current_user, params = {})
@current_user = current_user
@params = params
end
@@ -27,11 +27,13 @@ class TodosFinder
items = by_action_id(items)
items = by_action(items)
items = by_author(items)
- items = by_project(items)
items = by_state(items)
items = by_type(items)
+ # Filtering by project HAS TO be the last because we use
+ # the project IDs yielded by the todos query thus far
+ items = by_project(items)
- items.reorder(id: :desc)
+ sort(items)
end
private
@@ -91,14 +93,9 @@ class TodosFinder
@project
end
- def projects
- return @projects if defined?(@projects)
-
- if project?
- @projects = project
- else
- @projects = ProjectsFinder.new.execute(current_user)
- end
+ def projects(items)
+ item_project_ids = items.reorder(nil).select(:project_id)
+ ProjectsFinder.new.execute(current_user, item_project_ids)
end
def type?
@@ -109,6 +106,10 @@ class TodosFinder
params[:type]
end
+ def sort(items)
+ params[:sort] ? items.sort(params[:sort]) : items.reorder(id: :desc)
+ end
+
def by_action(items)
if action?
items = items.where(action: to_action_id)
@@ -136,8 +137,9 @@ class TodosFinder
def by_project(items)
if project?
items = items.where(project: project)
- elsif projects
- items = items.merge(projects).joins(:project)
+ else
+ item_projects = projects(items)
+ items = items.merge(item_projects).joins(:project)
end
items
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index e12a1052988..de13e7a1fc2 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -32,6 +32,8 @@ module AppearancesHelper
end
def custom_icon(icon_name, size: 16)
+ # We can't simply do the below, because there are some .erb SVGs.
+ # File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe
render "shared/icons/#{icon_name}.svg", size: size
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 50de93d4bdf..f3733b01721 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -163,9 +163,13 @@ module ApplicationHelper
# `html_class` argument is provided.
#
# Returns an HTML-safe String
- def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago', skip_js: false)
+ def time_ago_with_tooltip(time, placement: 'top', html_class: '', skip_js: false, short_format: false)
+ css_classes = short_format ? 'js-short-timeago' : 'js-timeago'
+ css_classes << " #{html_class}" unless html_class.blank?
+ css_classes << ' js-timeago-pending' unless skip_js
+
element = content_tag :time, time.to_s,
- class: "#{html_class} js-timeago #{"js-timeago-pending" unless skip_js}",
+ class: css_classes,
datetime: time.to_time.getutc.iso8601,
title: time.to_time.in_time_zone.to_s(:medium),
data: { toggle: 'tooltip', placement: placement, container: 'body' }
@@ -316,4 +320,8 @@ module ApplicationHelper
capture(&block)
end
end
+
+ def page_class
+ "issue-boards-page" if current_controller?(:boards)
+ end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 78c0b79d2bd..6de25bea654 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -31,6 +31,10 @@ module ApplicationSettingsHelper
current_application_settings.akismet_enabled?
end
+ def koding_enabled?
+ current_application_settings.koding_enabled?
+ end
+
def allowed_protocols_present?
current_application_settings.enabled_git_access_protocol.present?
end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 6ff40c6b461..aa8acbe7567 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -1,5 +1,4 @@
module AvatarsHelper
-
def author_avatar(commit_or_event, options = {})
user_avatar(options.merge({
user: commit_or_event.author,
@@ -8,8 +7,6 @@ module AvatarsHelper
}))
end
- private
-
def user_avatar(options = {})
avatar_size = options[:size] || 16
user_name = options[:user].try(:name) || options[:user_name]
@@ -26,5 +23,4 @@ module AvatarsHelper
mail_to(options[:user_email], avatar)
end
end
-
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 48c27828219..e13b7cdd707 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -11,17 +11,14 @@ module BlobHelper
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
return unless current_user
- blob = project.repository.blob_at(ref, path) rescue nil
+ blob = options.delete(:blob)
+ blob ||= project.repository.blob_at(ref, path) rescue nil
return unless blob
- from_mr = options[:from_merge_request_id]
- link_opts = {}
- link_opts[:from_merge_request_id] = from_mr if from_mr
-
edit_path = namespace_project_edit_blob_path(project.namespace, project,
tree_join(ref, path),
- link_opts)
+ options[:link_opts])
if !on_top_of_branch?(project, ref)
button_tag "Edit", class: "btn disabled has-tooltip btn-file-option", title: "You can only edit files when you are on a branch", data: { container: 'body' }
@@ -182,17 +179,50 @@ module BlobHelper
}
end
+ def selected_template(issuable)
+ templates = issuable_templates(issuable)
+ params[:issuable_template] if templates.include?(params[:issuable_template])
+ end
+
+ def can_add_template?(issuable)
+ names = issuable_templates(issuable)
+ names.empty? && can?(current_user, :push_code, @project) && !@project.private?
+ end
+
+ def merge_request_template_names
+ @merge_request_templates ||= Gitlab::Template::MergeRequestTemplate.dropdown_names(ref_project)
+ end
+
+ def issue_template_names
+ @issue_templates ||= Gitlab::Template::IssueTemplate.dropdown_names(ref_project)
+ end
+
+ def issuable_templates(issuable)
+ @issuable_templates ||=
+ if issuable.is_a?(Issue)
+ issue_template_names
+ elsif issuable.is_a?(MergeRequest)
+ merge_request_template_names
+ end
+ end
+
+ def ref_project
+ @ref_project ||= @target_project || @project
+ end
+
def gitignore_names
- @gitignore_names ||=
- Gitlab::Template::Gitignore.categories.keys.map do |k|
- [k, Gitlab::Template::Gitignore.by_category(k).map { |t| { name: t.name } }]
- end.to_h
+ @gitignore_names ||= Gitlab::Template::GitignoreTemplate.dropdown_names
end
def gitlab_ci_ymls
- @gitlab_ci_ymls ||=
- Gitlab::Template::GitlabCiYml.categories.keys.map do |k|
- [k, Gitlab::Template::GitlabCiYml.by_category(k).map { |t| { name: t.name } }]
- end.to_h
+ @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names
+ end
+
+ def blob_editor_paths
+ {
+ 'relative-url-root' => Rails.application.config.relative_url_root,
+ 'assets-prefix' => Gitlab::Application.config.assets.prefix,
+ 'blob-language' => @blob && @blob.language.try(:ace_mode)
+ }
end
end
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index ea2f5f9281a..bb285a17baf 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -38,6 +38,10 @@ module CiStatusHelper
'icon_status_pending'
when 'running'
'icon_status_running'
+ when 'play'
+ 'icon_play'
+ when 'created'
+ 'icon_status_pending'
else
'icon_status_cancel'
end
@@ -48,13 +52,13 @@ module CiStatusHelper
def render_commit_status(commit, tooltip_placement: 'auto left')
project = commit.project
path = builds_namespace_project_commit_path(project.namespace, project, commit)
- render_status_with_link('commit', commit.status, path, tooltip_placement)
+ render_status_with_link('commit', commit.status, path, tooltip_placement: tooltip_placement)
end
def render_pipeline_status(pipeline, tooltip_placement: 'auto left')
project = pipeline.project
path = namespace_project_pipeline_path(project.namespace, project, pipeline)
- render_status_with_link('pipeline', pipeline.status, path, tooltip_placement)
+ render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement)
end
def no_runners_for_project?(project)
@@ -62,13 +66,17 @@ module CiStatusHelper
Ci::Runner.shared.blank?
end
- private
+ def render_status_with_link(type, status, path = nil, tooltip_placement: 'auto left', cssclass: '')
+ klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}"
+ title = "#{type.titleize}: #{ci_label_for_status(status)}"
+ data = { toggle: 'tooltip', placement: tooltip_placement }
- def render_status_with_link(type, status, path, tooltip_placement, cssclass: '')
- link_to ci_icon_for_status(status),
- path,
- class: "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}",
- title: "#{type.titleize}: #{ci_label_for_status(status)}",
- data: { toggle: 'tooltip', placement: tooltip_placement }
+ if path
+ link_to ci_icon_for_status(status), path,
+ class: klass, title: title, data: data
+ else
+ content_tag :span, ci_icon_for_status(status),
+ class: klass, title: title, data: data
+ end
end
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index f497626e21a..33dcee49aee 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -98,28 +98,31 @@ module CommitsHelper
end
def link_to_browse_code(project, commit)
- if current_controller?(:projects, :commits)
- if @repo.blob_at(commit.id, @path)
- return link_to(
- "Browse File",
- namespace_project_blob_path(project.namespace, project,
- tree_join(commit.id, @path)),
- class: "btn btn-default"
- )
- elsif @path.present?
- return link_to(
- "Browse Directory",
- namespace_project_tree_path(project.namespace, project,
- tree_join(commit.id, @path)),
- class: "btn btn-default"
- )
- end
+ if @path.blank?
+ return link_to(
+ "Browse Files",
+ namespace_project_tree_path(project.namespace, project, commit),
+ class: "btn btn-default"
+ )
+ end
+
+ return unless current_controller?(:projects, :commits)
+
+ if @repo.blob_at(commit.id, @path)
+ return link_to(
+ "Browse File",
+ namespace_project_blob_path(project.namespace, project,
+ tree_join(commit.id, @path)),
+ class: "btn btn-default"
+ )
+ elsif @path.present?
+ return link_to(
+ "Browse Directory",
+ namespace_project_tree_path(project.namespace, project,
+ tree_join(commit.id, @path)),
+ class: "btn btn-default"
+ )
end
- link_to(
- "Browse Files",
- namespace_project_tree_path(project.namespace, project, commit),
- class: "btn btn-default"
- )
end
def revert_commit_link(commit, continue_to_path, btn_class: nil, has_tooltip: true)
@@ -206,10 +209,10 @@ module CommitsHelper
end
end
- def view_file_btn(commit_sha, diff, project)
+ def view_file_btn(commit_sha, diff_new_path, project)
link_to(
namespace_project_blob_path(project.namespace, project,
- tree_join(commit_sha, diff.new_path)),
+ tree_join(commit_sha, diff_new_path)),
class: 'btn view-file js-view-file btn-file-option'
) do
raw('View file @') + content_tag(:span, commit_sha[0..6],
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index f35e2f6ddcd..0725c3f4c56 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -13,12 +13,11 @@ module DiffHelper
end
def diff_view
- diff_views = %w(inline parallel)
-
- if diff_views.include?(cookies[:diff_view])
- cookies[:diff_view]
- else
- diff_views.first
+ @diff_view ||= begin
+ diff_views = %w(inline parallel)
+ diff_view = cookies[:diff_view]
+ diff_view = diff_views.first unless diff_views.include?(diff_view)
+ diff_view.to_sym
end
end
@@ -30,19 +29,26 @@ module DiffHelper
options[:paths] = params.values_at(:old_path, :new_path)
end
- Commit.max_diff_options.merge(options)
+ options
end
- def safe_diff_files(diffs, diff_refs: nil, repository: nil)
- diffs.decorate! { |diff| Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) }
- end
+ def diff_match_line(old_pos, new_pos, text: '', view: :inline, bottom: false)
+ content = content_tag :td, text, class: "line_content match #{view == :inline ? '' : view}"
+ cls = ['diff-line-num', 'unfold', 'js-unfold']
+ cls << 'js-unfold-bottom' if bottom
- def unfold_bottom_class(bottom)
- bottom ? 'js-unfold js-unfold-bottom' : ''
- end
+ html = ''
+ if old_pos
+ html << content_tag(:td, '...', class: cls + ['old_line'], data: { linenumber: old_pos })
+ html << content unless view == :inline
+ end
+
+ if new_pos
+ html << content_tag(:td, '...', class: cls + ['new_line'], data: { linenumber: new_pos })
+ html << content
+ end
- def unfold_class(unfold)
- unfold ? 'unfold js-unfold' : ''
+ html.html_safe
end
def diff_line_content(line, line_type = nil)
@@ -71,11 +77,11 @@ module DiffHelper
end
def inline_diff_btn
- diff_btn('Inline', 'inline', diff_view == 'inline')
+ diff_btn('Inline', 'inline', diff_view == :inline)
end
def parallel_diff_btn
- diff_btn('Side-by-side', 'parallel', diff_view == 'parallel')
+ diff_btn('Side-by-side', 'parallel', diff_view == :parallel)
end
def submodule_link(blob, ref, repository = @repository)
@@ -103,11 +109,11 @@ module DiffHelper
end
end
- def diff_file_html_data(project, diff_file)
- commit = commit_for_diff(diff_file)
+ def diff_file_html_data(project, diff_file_path, diff_commit_id)
{
blob_diff_path: namespace_project_blob_diff_path(project.namespace, project,
- tree_join(commit.id, diff_file.file_path))
+ tree_join(diff_commit_id, diff_file_path)),
+ view: diff_view
}
end
diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb
index 337b0aacbb5..2b1f3825adc 100644
--- a/app/helpers/explore_helper.rb
+++ b/app/helpers/explore_helper.rb
@@ -1,5 +1,5 @@
module ExploreHelper
- def filter_projects_path(options={})
+ def filter_projects_path(options = {})
exist_opts = {
sort: params[:sort],
scope: params[:scope],
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 47d174361db..b9baeb1d6c4 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -72,6 +72,15 @@ module IssuablesHelper
end
end
+ def issuable_labels_tooltip(labels, limit: 5)
+ first, last = labels.partition.with_index{ |_, i| i < limit }
+
+ label_names = first.collect(&:name)
+ label_names << "and #{last.size} more" unless last.empty?
+
+ label_names.join(', ')
+ end
+
private
def sidebar_gutter_collapsed?
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 2b0defd1dda..8b212b0327a 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -13,38 +13,6 @@ module IssuesHelper
OpenStruct.new(id: 0, title: 'None (backlog)', name: 'Unassigned')
end
- def url_for_project_issues(project = @project, options = {})
- return '' if project.nil?
-
- url =
- if options[:only_path]
- project.issues_tracker.project_path
- else
- project.issues_tracker.project_url
- end
-
- # Ensure we return a valid URL to prevent possible XSS.
- URI.parse(url).to_s
- rescue URI::InvalidURIError
- ''
- end
-
- def url_for_new_issue(project = @project, options = {})
- return '' if project.nil?
-
- url =
- if options[:only_path]
- project.issues_tracker.new_issue_path
- else
- project.issues_tracker.new_issue_url
- end
-
- # Ensure we return a valid URL to prevent possible XSS.
- URI.parse(url).to_s
- rescue URI::InvalidURIError
- ''
- end
-
def url_for_issue(issue_iid, project = @project, options = {})
return '' if project.nil?
@@ -146,9 +114,17 @@ module IssuesHelper
end
def award_user_list(awards, current_user)
- awards.map do |award|
- award.user == current_user ? 'me' : award.user.name
- end.join(', ')
+ names = awards.map do |award|
+ award.user == current_user ? 'You' : award.user.name
+ end
+
+ # Take first 9 OR current user + first 9
+ current_user_name = names.delete('You')
+ names = names.first(9).insert(0, current_user_name).compact
+
+ names << "#{awards.size - names.size} more." if awards.size > names.size
+
+ names.to_sentence
end
def award_active_class(awards, current_user)
diff --git a/app/helpers/lfs_helper.rb b/app/helpers/lfs_helper.rb
new file mode 100644
index 00000000000..eb651e3687e
--- /dev/null
+++ b/app/helpers/lfs_helper.rb
@@ -0,0 +1,67 @@
+module LfsHelper
+ def require_lfs_enabled!
+ return if Gitlab.config.lfs.enabled
+
+ render(
+ json: {
+ message: 'Git LFS is not enabled on this GitLab server, contact your admin.',
+ documentation_url: "#{Gitlab.config.gitlab.url}/help",
+ },
+ status: 501
+ )
+ end
+
+ def lfs_check_access!
+ return if download_request? && lfs_download_access?
+ return if upload_request? && lfs_upload_access?
+
+ if project.public? || (user && user.can?(:read_project, project))
+ render_lfs_forbidden
+ else
+ render_lfs_not_found
+ end
+ end
+
+ def lfs_download_access?
+ project.public? || ci? || (user && user.can?(:download_code, project))
+ end
+
+ def lfs_upload_access?
+ user && user.can?(:push_code, project)
+ end
+
+ def render_lfs_forbidden
+ render(
+ json: {
+ message: 'Access forbidden. Check your access level.',
+ documentation_url: "#{Gitlab.config.gitlab.url}/help",
+ },
+ content_type: "application/vnd.git-lfs+json",
+ status: 403
+ )
+ end
+
+ def render_lfs_not_found
+ render(
+ json: {
+ message: 'Not found.',
+ documentation_url: "#{Gitlab.config.gitlab.url}/help",
+ },
+ content_type: "application/vnd.git-lfs+json",
+ status: 404
+ )
+ end
+
+ def storage_project
+ @storage_project ||= begin
+ result = project
+
+ loop do
+ break unless result.forked?
+ result = result.forked_from_project
+ end
+
+ result
+ end
+ end
+end
diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb
index ec106418f2d..877c77050be 100644
--- a/app/helpers/members_helper.rb
+++ b/app/helpers/members_helper.rb
@@ -6,12 +6,6 @@ module MembersHelper
"#{action}_#{member.type.underscore}".to_sym
end
- def default_show_roles(member)
- can?(current_user, action_member_permission(:update, member), member) ||
- can?(current_user, action_member_permission(:destroy, member), member) ||
- can?(current_user, action_member_permission(:admin, member), member.source)
- end
-
def remove_member_message(member, user: nil)
user = current_user if defined?(current_user)
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 3ff8be5e284..6c1cc6ef072 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -24,6 +24,7 @@ module NavHelper
current_path?('merge_requests#diffs') ||
current_path?('merge_requests#commits') ||
current_path?('merge_requests#builds') ||
+ current_path?('merge_requests#conflicts') ||
current_path?('issues#show')
if cookies[:collapsed_gutter] == 'true'
"page-gutter right-sidebar-collapsed"
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 0c47abe0fba..da230f76bae 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -49,7 +49,7 @@ module NotesHelper
}
if use_legacy_diff_note
- discussion_id = LegacyDiffNote.build_discussion_id(
+ discussion_id = LegacyDiffNote.discussion_id(
@comments_target[:noteable_type],
@comments_target[:noteable_id] || @comments_target[:commit_id],
line_code
@@ -60,7 +60,7 @@ module NotesHelper
discussion_id: discussion_id
)
else
- discussion_id = DiffNote.build_discussion_id(
+ discussion_id = DiffNote.discussion_id(
@comments_target[:noteable_type],
@comments_target[:noteable_id] || @comments_target[:commit_id],
position
@@ -81,10 +81,8 @@ module NotesHelper
data = discussion.reply_attributes.merge(line_type: line_type)
- content_tag(:div, class: "discussion-reply-holder") do
- button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
- data: data, title: 'Add a reply'
- end
+ button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
+ data: data, title: 'Add a reply'
end
def preload_max_access_for_authors(notes, project)
@@ -92,6 +90,10 @@ module NotesHelper
project.team.max_member_access_for_user_ids(user_ids)
end
+ def preload_noteable_for_regular_notes(notes)
+ ActiveRecord::Associations::Preloader.new.preload(notes.select { |note| !note.for_commit? }, :noteable)
+ end
+
def note_max_access_for_user(note)
note.project.team.human_max_access(note.author_id)
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index a733dff1579..249d18c4486 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -236,6 +236,60 @@ module ProjectsHelper
)
end
+ def add_koding_stack_path(project)
+ namespace_project_new_blob_path(
+ project.namespace,
+ project,
+ project.default_branch || 'master',
+ file_name: '.koding.yml',
+ commit_message: "Add Koding stack script",
+ content: <<-CONTENT.strip_heredoc
+ provider:
+ aws:
+ access_key: '${var.aws_access_key}'
+ secret_key: '${var.aws_secret_key}'
+ resource:
+ aws_instance:
+ #{project.path}-vm:
+ instance_type: t2.nano
+ user_data: |-
+
+ # Created by GitLab UI for :>
+
+ echo _KD_NOTIFY_@Installing Base packages...@
+
+ apt-get update -y
+ apt-get install git -y
+
+ echo _KD_NOTIFY_@Cloning #{project.name}...@
+
+ export KODING_USER=${var.koding_user_username}
+ export REPO_URL=#{root_url}${var.koding_queryString_repo}.git
+ export BRANCH=${var.koding_queryString_branch}
+
+ sudo -i -u $KODING_USER git clone $REPO_URL -b $BRANCH
+
+ echo _KD_NOTIFY_@#{project.name} cloned.@
+ CONTENT
+ )
+ end
+
+ def koding_project_url(project = nil, branch = nil, sha = nil)
+ if project
+ import_path = "/Home/Stacks/import"
+
+ repo = project.path_with_namespace
+ branch ||= project.default_branch
+ sha ||= project.commit.short_id
+
+ path = "#{import_path}?repo=#{repo}&branch=#{branch}&sha=#{sha}"
+
+ return URI.join(current_application_settings.koding_url, path).to_s
+ end
+
+ current_application_settings.koding_url
+ end
+
def contribution_guide_path(project)
if project && contribution_guide = project.repository.contribution_guide
namespace_project_blob_path(
@@ -263,6 +317,10 @@ module ProjectsHelper
filename_path(project, :version)
end
+ def ci_configuration_path(project)
+ filename_path(project, :gitlab_ci_yml)
+ end
+
def project_wiki_path_with_version(proj, page, version, is_newest)
url_params = is_newest ? {} : { version_id: version }
namespace_project_wiki_path(proj.namespace, proj, page, url_params)
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index a2bba139c17..c0195713f4a 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -107,7 +107,7 @@ module SearchHelper
Sanitize.clean(str)
end
- def search_filter_path(options={})
+ def search_filter_path(options = {})
exist_opts = {
search: params[:search],
project_id: params[:project_id],
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index e1c0b497550..8b138a8e69f 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -20,13 +20,19 @@ module SortingHelper
end
def projects_sort_options_hash
- {
+ options = {
sort_value_name => sort_title_name,
sort_value_recently_updated => sort_title_recently_updated,
sort_value_oldest_updated => sort_title_oldest_updated,
sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created,
}
+
+ if current_controller?('admin/projects')
+ options.merge!(sort_value_largest_repo => sort_title_largest_repo)
+ end
+
+ options
end
def sort_title_priority
diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb
index 790001222f1..271e839692a 100644
--- a/app/helpers/time_helper.rb
+++ b/app/helpers/time_helper.rb
@@ -15,20 +15,9 @@ module TimeHelper
"#{from.to_s(:short)} - #{to.to_s(:short)}"
end
- def duration_in_numbers(finished_at, started_at)
- interval = interval_in_seconds(started_at, finished_at)
- time_format = interval < 1.hour ? "%M:%S" : "%H:%M:%S"
+ def duration_in_numbers(duration)
+ time_format = duration < 1.hour ? "%M:%S" : "%H:%M:%S"
- Time.at(interval).utc.strftime(time_format)
- end
-
- private
-
- def interval_in_seconds(started_at, finished_at = nil)
- if started_at && finished_at
- finished_at.to_i - started_at.to_i
- elsif started_at
- Time.now.to_i - started_at.to_i
- end
+ Time.at(duration).utc.strftime(time_format)
end
end
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index e3a208f826a..0465327060e 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -1,10 +1,10 @@
module TodosHelper
def todos_pending_count
- @todos_pending_count ||= TodosFinder.new(current_user, state: :pending).execute.count
+ @todos_pending_count ||= current_user.todos_pending_count
end
def todos_done_count
- @todos_done_count ||= TodosFinder.new(current_user, state: :done).execute.count
+ @todos_done_count ||= current_user.todos_done_count
end
def todo_action_name(todo)
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index dbedf417fa5..4a76c679bad 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -4,23 +4,11 @@ module TreeHelper
#
# contents - A Grit::Tree object for the current tree
def render_tree(tree)
- # Render Folders before Files/Submodules
+ # Sort submodules and folders together by name ahead of files
folders, files, submodules = tree.trees, tree.blobs, tree.submodules
-
tree = ""
-
- # Render folders if we have any
- tree << render(partial: 'projects/tree/tree_item', collection: folders,
- locals: { type: 'folder' }) if folders.present?
-
- # Render files if we have any
- tree << render(partial: 'projects/tree/blob_item', collection: files,
- locals: { type: 'file' }) if files.present?
-
- # Render submodules if we have any
- tree << render(partial: 'projects/tree/submodule_item',
- collection: submodules) if submodules.present?
-
+ items = (folders + submodules).sort_by(&:name) + files
+ tree << render(partial: "projects/tree/tree_row", collection: items) if items.present?
tree.html_safe
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 6f54c42146c..d64e48f774b 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -6,6 +6,11 @@ module Emails
mail_new_thread(@issue, issue_thread_options(@issue.author_id, recipient_id))
end
+ def new_mention_in_issue_email(recipient_id, issue_id, updated_by_user_id)
+ setup_issue_mail(issue_id, recipient_id)
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id))
+ end
+
def reassigned_issue_email(recipient_id, issue_id, previous_assignee_id, updated_by_user_id)
setup_issue_mail(issue_id, recipient_id)
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 9dd11d20ea6..ec27ac517db 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -6,6 +6,11 @@ module Emails
mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id))
end
+ def new_mention_in_merge_request_email(recipient_id, merge_request_id, updated_by_user_id)
+ setup_merge_request_mail(merge_request_id, recipient_id)
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
+ end
+
def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id)
setup_merge_request_mail(merge_request_id, recipient_id)
@@ -42,6 +47,13 @@ module Emails
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id))
end
+ def resolved_all_discussions_email(recipient_id, merge_request_id, resolved_by_user_id)
+ setup_merge_request_mail(merge_request_id, recipient_id)
+
+ @resolved_by = User.find(resolved_by_user_id)
+ mail_answer_thread(@merge_request, merge_request_thread_options(resolved_by_user_id, recipient_id))
+ end
+
private
def setup_merge_request_mail(merge_request_id, recipient_id)
diff --git a/app/models/ability.rb b/app/models/ability.rb
index d95a2507199..a49dd703926 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -6,6 +6,10 @@ class Ability
return [] unless user.is_a?(User)
return [] if user.blocked?
+ abilities_by_subject_class(user: user, subject: subject)
+ end
+
+ def abilities_by_subject_class(user:, subject:)
case subject
when CommitStatus then commit_status_abilities(user, subject)
when Project then project_abilities(user, subject)
@@ -86,6 +90,8 @@ class Ability
if project && project.public?
rules = [
:read_project,
+ :read_board,
+ :read_list,
:read_wiki,
:read_label,
:read_milestone,
@@ -160,38 +166,44 @@ class Ability
end
def project_abilities(user, project)
- rules = []
key = "/user/#{user.id}/project/#{project.id}"
- RequestStore.store[key] ||= begin
- # Push abilities on the users team role
- rules.push(*project_team_rules(project.team, user))
+ if RequestStore.active?
+ RequestStore.store[key] ||= uncached_project_abilities(user, project)
+ else
+ uncached_project_abilities(user, project)
+ end
+ end
- owner = user.admin? ||
- project.owner == user ||
- (project.group && project.group.has_owner?(user))
+ def uncached_project_abilities(user, project)
+ rules = []
+ # Push abilities on the users team role
+ rules.push(*project_team_rules(project.team, user))
- if owner
- rules.push(*project_owner_rules)
- end
+ owner = user.admin? ||
+ project.owner == user ||
+ (project.group && project.group.has_owner?(user))
- if project.public? || (project.internal? && !user.external?)
- rules.push(*public_project_rules)
+ if owner
+ rules.push(*project_owner_rules)
+ end
- # Allow to read builds for internal projects
- rules << :read_build if project.public_builds?
+ if project.public? || (project.internal? && !user.external?)
+ rules.push(*public_project_rules)
- unless owner || project.team.member?(user) || project_group_member?(project, user)
- rules << :request_access if project.request_access_enabled
- end
- end
+ # Allow to read builds for internal projects
+ rules << :read_build if project.public_builds?
- if project.archived?
- rules -= project_archived_rules
+ unless owner || project.team.member?(user) || project_group_member?(project, user)
+ rules << :request_access if project.request_access_enabled
end
+ end
- rules - project_disabled_features_rules(project)
+ if project.archived?
+ rules -= project_archived_rules
end
+
+ (rules - project_disabled_features_rules(project)).uniq
end
def project_team_rules(team, user)
@@ -224,6 +236,8 @@ class Ability
:read_project,
:read_wiki,
:read_issue,
+ :read_board,
+ :read_list,
:read_label,
:read_milestone,
:read_project_snippet,
@@ -245,6 +259,7 @@ class Ability
:update_issue,
:admin_issue,
:admin_label,
+ :admin_list,
:read_commit_status,
:read_build,
:read_container_image,
@@ -267,6 +282,7 @@ class Ability
:create_merge_request,
:create_wiki,
:push_code,
+ :resolve_note,
:create_container_image,
:update_container_image,
:create_environment,
@@ -448,7 +464,8 @@ class Ability
rules += [
:read_note,
:update_note,
- :admin_note
+ :admin_note,
+ :resolve_note
]
end
@@ -456,6 +473,10 @@ class Ability
rules += project_abilities(user, note.project)
end
+ if note.for_merge_request? && note.noteable.author == user
+ rules << :resolve_note
+ end
+
rules
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 8c19d9dc9c8..f0bcb2d7cda 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -55,6 +55,10 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
if: :akismet_enabled
+ validates :koding_url,
+ presence: true,
+ if: :koding_enabled
+
validates :max_attachment_size,
presence: true,
numericality: { only_integer: true, greater_than: 0 }
@@ -149,6 +153,8 @@ class ApplicationSetting < ActiveRecord::Base
two_factor_grace_period: 48,
recaptcha_enabled: false,
akismet_enabled: false,
+ koding_enabled: false,
+ koding_url: nil,
repository_checks_enabled: true,
disabled_oauth_sign_in_sources: [],
send_user_confirmation_email: false,
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 0df2805e448..12cc5aaafba 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -3,6 +3,9 @@ class Blob < SimpleDelegator
CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute
CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour
+ # The maximum size of an SVG that can be displayed.
+ MAXIMUM_SVG_SIZE = 2.megabytes
+
# Wrap a Gitlab::Git::Blob object, or return nil when given nil
#
# This method prevents the decorated object from evaluating to "truthy" when
@@ -31,6 +34,10 @@ class Blob < SimpleDelegator
text? && language && language.name == 'SVG'
end
+ def size_within_svg_limits?
+ size <= MAXIMUM_SVG_SIZE
+ end
+
def video?
UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.'))
end
diff --git a/app/models/board.rb b/app/models/board.rb
new file mode 100644
index 00000000000..3240c4bede3
--- /dev/null
+++ b/app/models/board.rb
@@ -0,0 +1,7 @@
+class Board < ActiveRecord::Base
+ belongs_to :project
+
+ has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all
+
+ validates :project, presence: true
+end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index a22caa7d296..096b3b801af 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -16,7 +16,7 @@ module Ci
scope :with_artifacts_not_expired, ->() { with_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
- scope :manual_actions, ->() { where(when: :manual) }
+ scope :manual_actions, ->() { where(when: :manual).relevant }
mount_uploader :artifacts_file, ArtifactUploader
mount_uploader :artifacts_metadata, ArtifactUploader
@@ -42,40 +42,36 @@ module Ci
end
def retry(build, user = nil)
- new_build = Ci::Build.new(status: 'pending')
- new_build.ref = build.ref
- new_build.tag = build.tag
- new_build.options = build.options
- new_build.commands = build.commands
- new_build.tag_list = build.tag_list
- new_build.project = build.project
- new_build.pipeline = build.pipeline
- new_build.name = build.name
- new_build.allow_failure = build.allow_failure
- new_build.stage = build.stage
- new_build.stage_idx = build.stage_idx
- new_build.trigger_request = build.trigger_request
- new_build.yaml_variables = build.yaml_variables
- new_build.when = build.when
- new_build.user = user
- new_build.environment = build.environment
- new_build.save
+ new_build = Ci::Build.create(
+ ref: build.ref,
+ tag: build.tag,
+ options: build.options,
+ commands: build.commands,
+ tag_list: build.tag_list,
+ project: build.project,
+ pipeline: build.pipeline,
+ name: build.name,
+ allow_failure: build.allow_failure,
+ stage: build.stage,
+ stage_idx: build.stage_idx,
+ trigger_request: build.trigger_request,
+ yaml_variables: build.yaml_variables,
+ when: build.when,
+ user: user,
+ environment: build.environment,
+ status_event: 'enqueue'
+ )
MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build)
+ build.pipeline.mark_as_processable_after_stage(build.stage_idx)
new_build
end
end
- state_machine :status, initial: :pending do
+ state_machine :status do
after_transition pending: :running do |build|
build.execute_hooks
end
- # We use around_transition to create builds for next stage as soon as possible, before the `after_*` is executed
- around_transition any => [:success, :failed, :canceled] do |build, block|
- block.call
- build.pipeline.create_next_builds(build) if build.pipeline
- end
-
after_transition any => [:success, :failed, :canceled] do |build|
build.update_coverage
build.execute_hooks
@@ -102,12 +98,12 @@ module Ci
end
def playable?
- project.builds_enabled? && commands.present? && manual?
+ project.builds_enabled? && commands.present? && manual? && skipped?
end
def play(current_user = nil)
# Try to queue a current build
- if self.queue
+ if self.enqueue
self.update(user: current_user)
self
else
@@ -349,7 +345,7 @@ module Ci
def execute_hooks
return unless project
- build_data = Gitlab::BuildDataBuilder.build(self)
+ build_data = Gitlab::DataBuilder::Build.build(self)
project.execute_hooks(build_data.dup, :build_hooks)
project.execute_services(build_data.dup, :build_hooks)
project.running_or_pending_build_count(force: true)
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 4e6ccf48c68..087abe4cbb1 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -13,13 +13,57 @@ module Ci
has_many :trigger_requests, dependent: :destroy, class_name: 'Ci::TriggerRequest', foreign_key: :commit_id
validates_presence_of :sha
+ validates_presence_of :ref
validates_presence_of :status
validate :valid_commit_sha
- # Invalidate object and save if when touched
- after_touch :update_state
after_save :keep_around_commits
+ delegate :stages, to: :statuses
+
+ state_machine :status, initial: :created do
+ event :enqueue do
+ transition created: :pending
+ transition [:success, :failed, :canceled, :skipped] => :running
+ end
+
+ event :run do
+ transition any => :running
+ end
+
+ event :skip do
+ transition any => :skipped
+ end
+
+ event :drop do
+ transition any => :failed
+ end
+
+ event :succeed do
+ transition any => :success
+ end
+
+ event :cancel do
+ transition any => :canceled
+ end
+
+ before_transition [:created, :pending] => :running do |pipeline|
+ pipeline.started_at = Time.now
+ end
+
+ before_transition any => [:success, :failed, :canceled] do |pipeline|
+ pipeline.finished_at = Time.now
+ end
+
+ before_transition do |pipeline|
+ pipeline.update_duration
+ end
+
+ after_transition do |pipeline, transition|
+ pipeline.execute_hooks unless transition.loopback?
+ end
+ end
+
# ref can't be HEAD or SHA, can only be branch/tag name
scope :latest_successful_for, ->(ref = default_branch) do
where(ref: ref).success.order(id: :desc).limit(1)
@@ -34,6 +78,14 @@ module Ci
CommitStatus.where(pipeline: pluck(:id)).stages
end
+ def self.total_duration
+ where.not(duration: nil).sum(:duration)
+ end
+
+ def stages_with_latest_statuses
+ statuses.latest.order(:stage_idx).group_by(&:stage)
+ end
+
def project_id
project.id
end
@@ -98,6 +150,10 @@ module Ci
end
end
+ def mark_as_processable_after_stage(stage_idx)
+ builds.skipped.where('stage_idx > ?', stage_idx).find_each(&:process)
+ end
+
def latest?
return false unless ref
commit = project.commit(ref)
@@ -109,37 +165,6 @@ module Ci
trigger_requests.any?
end
- def create_builds(user, trigger_request = nil)
- ##
- # We persist pipeline only if there are builds available
- #
- return unless config_processor
-
- build_builds_for_stages(config_processor.stages, user,
- 'success', trigger_request) && save
- end
-
- def create_next_builds(build)
- return unless config_processor
-
- # don't create other builds if this one is retried
- latest_builds = builds.latest
- return unless latest_builds.exists?(build.id)
-
- # get list of stages after this build
- next_stages = config_processor.stages.drop_while { |stage| stage != build.stage }
- next_stages.delete(build.stage)
-
- # get status for all prior builds
- prior_builds = latest_builds.where.not(stage: next_stages)
- prior_status = prior_builds.status
-
- # build builds for next stage that has builds available
- # and save pipeline if we have builds
- build_builds_for_stages(next_stages, build.user, prior_status,
- build.trigger_request) && save
- end
-
def retried
@retried ||= (statuses.order(id: :desc) - statuses.latest)
end
@@ -151,6 +176,14 @@ module Ci
end
end
+ def config_builds_attributes
+ return [] unless config_processor
+
+ config_processor.
+ builds_for_ref(ref, tag?, trigger_requests.first).
+ sort_by { |build| build[:stage_idx] }
+ end
+
def has_warnings?
builds.latest.ignored.any?
end
@@ -182,10 +215,6 @@ module Ci
end
end
- def skip_ci?
- git_commit_message =~ /\[(ci skip|skip ci)\]/i if git_commit_message
- end
-
def environments
builds.where.not(environment: nil).success.pluck(:environment).uniq
end
@@ -207,45 +236,47 @@ module Ci
Note.for_commit_id(sha)
end
+ def process!
+ Ci::ProcessPipelineService.new(project, user).execute(self)
+ end
+
+ def build_updated
+ case latest_builds_status
+ when 'pending' then enqueue
+ when 'running' then run
+ when 'success' then succeed
+ when 'failed' then drop
+ when 'canceled' then cancel
+ when 'skipped' then skip
+ end
+ end
+
def predefined_variables
[
{ key: 'CI_PIPELINE_ID', value: id.to_s, public: true }
]
end
- private
+ def update_duration
+ self.duration = calculate_duration
+ end
- def build_builds_for_stages(stages, user, status, trigger_request)
- ##
- # Note that `Array#any?` implements a short circuit evaluation, so we
- # build builds only for the first stage that has builds available.
- #
- stages.any? do |stage|
- CreateBuildsService.new(self).
- execute(stage, user, status, trigger_request).
- any?(&:active?)
- end
+ def execute_hooks
+ data = pipeline_data
+ project.execute_hooks(data, :pipeline_hooks)
+ project.execute_services(data, :pipeline_hooks)
end
- def update_state
- statuses.reload
- self.status = if yaml_errors.blank?
- statuses.latest.status || 'skipped'
- else
- 'failed'
- end
- self.started_at = statuses.started_at
- self.finished_at = statuses.finished_at
- self.duration = statuses.latest.duration
- saved = save
- execute_hooks if saved && !skip_ci?
- saved
+ private
+
+ def pipeline_data
+ Gitlab::DataBuilder::Pipeline.build(self)
end
- def execute_hooks
- pipeline_data = Gitlab::DataBuilder::PipelineDataBuilder.build(self)
- project.execute_hooks(pipeline_data, :pipeline_hooks)
- project.execute_services(pipeline_data.dup, :pipeline_hooks)
+ def latest_builds_status
+ return 'failed' unless yaml_errors.blank?
+
+ statuses.latest.status || 'skipped'
end
def keep_around_commits
diff --git a/app/models/commit.rb b/app/models/commit.rb
index 486ad6714d9..817d063e4a2 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -104,7 +104,7 @@ class Commit
end
def diff_line_count
- @diff_line_count ||= Commit::diff_line_count(self.diffs)
+ @diff_line_count ||= Commit::diff_line_count(raw_diffs)
@diff_line_count
end
@@ -123,15 +123,17 @@ class Commit
# In case this first line is longer than 100 characters, it is cut off
# after 80 characters and ellipses (`&hellp;`) are appended.
def title
- title = safe_message
+ full_title.length > 100 ? full_title[0..79] << "โ€ฆ" : full_title
+ end
- return no_commit_message if title.blank?
+ # Returns the full commits title
+ def full_title
+ return @full_title if @full_title
- title_end = title.index("\n")
- if (!title_end && title.length > 100) || (title_end && title_end > 100)
- title[0..79] << "โ€ฆ"
+ if safe_message.blank?
+ @full_title = no_commit_message
else
- title.split("\n", 2).first
+ @full_title = safe_message.split("\n", 2).first
end
end
@@ -227,7 +229,7 @@ class Commit
def diff_refs
Gitlab::Diff::DiffRefs.new(
- base_sha: self.parent_id || self.sha,
+ base_sha: self.parent_id || Gitlab::Git::BLANK_SHA,
head_sha: self.sha
)
end
@@ -315,6 +317,14 @@ class Commit
nil
end
+ def raw_diffs(*args)
+ raw.diffs(*args)
+ end
+
+ def diffs(diff_options = nil)
+ Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options)
+ end
+
private
def find_author_by_any_email
@@ -324,7 +334,7 @@ class Commit
def repo_changes
changes = { added: [], modified: [], removed: [] }
- diffs.each do |diff|
+ raw_diffs(deltas_only: true).each do |diff|
if diff.deleted_file
changes[:removed] << diff.old_path
elsif diff.renamed_file || diff.new_file
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 2d185c28809..84ceeac7d3e 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -5,7 +5,7 @@ class CommitStatus < ActiveRecord::Base
self.table_name = 'ci_builds'
belongs_to :project, class_name: '::Project', foreign_key: :gl_project_id
- belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id, touch: true
+ belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
belongs_to :user
delegate :commit, to: :pipeline
@@ -21,32 +21,45 @@ class CommitStatus < ActiveRecord::Base
where(id: max_id.group(:name, :commit_id))
end
+
scope :retried, -> { where.not(id: latest) }
scope :ordered, -> { order(:name) }
scope :ignored, -> { where(allow_failure: true, status: [:failed, :canceled]) }
- state_machine :status, initial: :pending do
- event :queue do
- transition skipped: :pending
+ state_machine :status do
+ event :enqueue do
+ transition [:created, :skipped] => :pending
+ end
+
+ event :process do
+ transition skipped: :created
end
event :run do
transition pending: :running
end
+ event :skip do
+ transition [:created, :pending] => :skipped
+ end
+
event :drop do
- transition [:pending, :running] => :failed
+ transition [:created, :pending, :running] => :failed
end
event :success do
- transition [:pending, :running] => :success
+ transition [:created, :pending, :running] => :success
end
event :cancel do
- transition [:pending, :running] => :canceled
+ transition [:created, :pending, :running] => :canceled
+ end
+
+ after_transition created: [:pending, :running] do |commit_status|
+ commit_status.update_attributes queued_at: Time.now
end
- after_transition pending: :running do |commit_status|
+ after_transition [:created, :pending] => :running do |commit_status|
commit_status.update_attributes started_at: Time.now
end
@@ -54,7 +67,18 @@ class CommitStatus < ActiveRecord::Base
commit_status.update_attributes finished_at: Time.now
end
- after_transition [:pending, :running] => :success do |commit_status|
+ # We use around_transition to process pipeline on next stages as soon as possible, before the `after_*` is executed
+ around_transition any => [:success, :failed, :canceled] do |commit_status, block|
+ block.call
+
+ commit_status.pipeline.try(:process!)
+ end
+
+ after_transition do |commit_status, transition|
+ commit_status.pipeline.try(:build_updated) unless transition.loopback?
+ end
+
+ after_transition [:created, :pending, :running] => :success do |commit_status|
MergeRequests::MergeWhenBuildSucceedsService.new(commit_status.pipeline.project, nil).trigger(commit_status)
end
@@ -88,13 +112,7 @@ class CommitStatus < ActiveRecord::Base
end
def duration
- duration =
- if started_at && finished_at
- finished_at - started_at
- elsif started_at
- Time.now - started_at
- end
- duration
+ calculate_duration
end
def stuck?
diff --git a/app/models/compare.rb b/app/models/compare.rb
new file mode 100644
index 00000000000..4856510f526
--- /dev/null
+++ b/app/models/compare.rb
@@ -0,0 +1,66 @@
+class Compare
+ delegate :same, :head, :base, to: :@compare
+
+ attr_reader :project
+
+ def self.decorate(compare, project)
+ if compare.is_a?(Compare)
+ compare
+ else
+ self.new(compare, project)
+ end
+ end
+
+ def initialize(compare, project)
+ @compare = compare
+ @project = project
+ end
+
+ def commits
+ @commits ||= Commit.decorate(@compare.commits, project)
+ end
+
+ def start_commit
+ return @start_commit if defined?(@start_commit)
+
+ commit = @compare.base
+ @start_commit = commit ? ::Commit.new(commit, project) : nil
+ end
+
+ def head_commit
+ return @head_commit if defined?(@head_commit)
+
+ commit = @compare.head
+ @head_commit = commit ? ::Commit.new(commit, project) : nil
+ end
+ alias_method :commit, :head_commit
+
+ def base_commit
+ return @base_commit if defined?(@base_commit)
+
+ @base_commit = if start_commit && head_commit
+ project.merge_base_commit(start_commit.id, head_commit.id)
+ else
+ nil
+ end
+ end
+
+ def raw_diffs(*args)
+ @compare.diffs(*args)
+ end
+
+ def diffs(diff_options = nil)
+ Gitlab::Diff::FileCollection::Compare.new(self,
+ project: project,
+ diff_options: diff_options,
+ diff_refs: diff_refs)
+ end
+
+ def diff_refs
+ Gitlab::Diff::DiffRefs.new(
+ base_sha: base_commit.try(:sha),
+ start_sha: start_commit.try(:sha),
+ head_sha: commit.try(:sha)
+ )
+ end
+end
diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb
new file mode 100644
index 00000000000..be93435453b
--- /dev/null
+++ b/app/models/concerns/expirable.rb
@@ -0,0 +1,15 @@
+module Expirable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :expired, -> { where('expires_at <= ?', Time.current) }
+ end
+
+ def expires?
+ expires_at.present?
+ end
+
+ def expires_soon?
+ expires_at < 7.days.from_now
+ end
+end
diff --git a/app/models/concerns/faster_cache_keys.rb b/app/models/concerns/faster_cache_keys.rb
new file mode 100644
index 00000000000..5b14723fa2d
--- /dev/null
+++ b/app/models/concerns/faster_cache_keys.rb
@@ -0,0 +1,16 @@
+module FasterCacheKeys
+ # A faster version of Rails' "cache_key" method.
+ #
+ # Rails' default "cache_key" method uses all kind of complex logic to figure
+ # out the cache key. In many cases this complexity and overhead may not be
+ # needed.
+ #
+ # This method does not do any timestamp parsing as this process is quite
+ # expensive and not needed when generating cache keys. This method also relies
+ # on the table name instead of the cache namespace name as the latter uses
+ # complex logic to generate the exact same value (as when using the table
+ # name) in 99% of the cases.
+ def cache_key
+ "#{self.class.table_name}/#{id}-#{read_attribute_before_type_cast(:updated_at)}"
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index cbae1cd439b..8e11d4f57cf 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -87,6 +87,12 @@ module Issuable
User.find(assignee_id_was).update_cache_counts if assignee_id_was
assignee.update_cache_counts if assignee
end
+
+ # We want to use optimistic lock for cases when only title or description are involved
+ # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
+ def locking_enabled?
+ title_changed? || description_changed?
+ end
end
module ClassMethods
@@ -131,7 +137,10 @@ module Issuable
end
def order_labels_priority(excluded_labels: [])
- select("#{table_name}.*, (#{highest_label_priority(excluded_labels).to_sql}) AS highest_priority").
+ condition_field = "#{table_name}.id"
+ highest_priority = highest_label_priority(name, condition_field, excluded_labels: excluded_labels).to_sql
+
+ select("#{table_name}.*, (#{highest_priority}) AS highest_priority").
group(arel_table[:id]).
reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
end
@@ -159,20 +168,6 @@ module Issuable
grouping_columns
end
-
- private
-
- def highest_label_priority(excluded_labels)
- query = Label.select(Label.arel_table[:priority].minimum).
- joins(:label_links).
- where(label_links: { target_type: name }).
- where("label_links.target_id = #{table_name}.id").
- reorder(nil)
-
- query.where.not(title: excluded_labels) if excluded_labels.present?
-
- query
- end
end
def today?
diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb
index 4be6a2f621b..a881fb83b7f 100644
--- a/app/models/concerns/note_on_diff.rb
+++ b/app/models/concerns/note_on_diff.rb
@@ -17,6 +17,10 @@ module NoteOnDiff
raise NotImplementedError
end
+ def original_line_code
+ raise NotImplementedError
+ end
+
def diff_attributes
raise NotImplementedError
end
diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb
new file mode 100644
index 00000000000..5a7b36070e7
--- /dev/null
+++ b/app/models/concerns/protected_branch_access.rb
@@ -0,0 +1,7 @@
+module ProtectedBranchAccess
+ extend ActiveSupport::Concern
+
+ def humanize
+ self.class.human_access_levels[self.access_level]
+ end
+end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index 8b47b9e0abd..1ebecd86af9 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -35,5 +35,19 @@ module Sortable
all
end
end
+
+ private
+
+ def highest_label_priority(object_types, condition_field, excluded_labels: [])
+ query = Label.select(Label.arel_table[:priority].minimum).
+ joins(:label_links).
+ where(label_links: { target_type: object_types }).
+ where("label_links.target_id = #{condition_field}").
+ reorder(nil)
+
+ query.where.not(title: excluded_labels) if excluded_labels.present?
+
+ query
+ end
end
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 3b8e6df2da9..1aa97debe42 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -1,9 +1,32 @@
module Spammable
extend ActiveSupport::Concern
+ module ClassMethods
+ def attr_spammable(attr, options = {})
+ spammable_attrs << [attr.to_s, options]
+ end
+ end
+
included do
+ has_one :user_agent_detail, as: :subject, dependent: :destroy
+
attr_accessor :spam
+
after_validation :check_for_spam, on: :create
+
+ cattr_accessor :spammable_attrs, instance_accessor: false do
+ []
+ end
+
+ delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true
+ end
+
+ def submittable_as_spam?
+ if user_agent_detail
+ user_agent_detail.submittable? && current_application_settings.akismet_enabled
+ else
+ false
+ end
end
def spam?
@@ -13,4 +36,33 @@ module Spammable
def check_for_spam
self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam?
end
+
+ def spam_title
+ attr = self.class.spammable_attrs.find do |_, options|
+ options.fetch(:spam_title, false)
+ end
+
+ public_send(attr.first) if attr && respond_to?(attr.first.to_sym)
+ end
+
+ def spam_description
+ attr = self.class.spammable_attrs.find do |_, options|
+ options.fetch(:spam_description, false)
+ end
+
+ public_send(attr.first) if attr && respond_to?(attr.first.to_sym)
+ end
+
+ def spammable_text
+ result = self.class.spammable_attrs.map do |attr|
+ public_send(attr.first)
+ end
+
+ result.reject(&:blank?).join("\n")
+ end
+
+ # Override in Spammable if further checks are necessary
+ def check_for_spam?
+ true
+ end
end
diff --git a/app/models/concerns/statuseable.rb b/app/models/concerns/statuseable.rb
index 44c6b30f278..750f937b724 100644
--- a/app/models/concerns/statuseable.rb
+++ b/app/models/concerns/statuseable.rb
@@ -1,18 +1,22 @@
module Statuseable
extend ActiveSupport::Concern
- AVAILABLE_STATUSES = %w(pending running success failed canceled skipped)
+ AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped]
+ STARTED_STATUSES = %w[running success failed skipped]
+ ACTIVE_STATUSES = %w[pending running]
+ COMPLETED_STATUSES = %w[success failed canceled]
class_methods do
def status_sql
- builds = all.select('count(*)').to_sql
- success = all.success.select('count(*)').to_sql
- ignored = all.ignored.select('count(*)').to_sql if all.respond_to?(:ignored)
+ scope = all.relevant
+ builds = scope.select('count(*)').to_sql
+ success = scope.success.select('count(*)').to_sql
+ ignored = scope.ignored.select('count(*)').to_sql if scope.respond_to?(:ignored)
ignored ||= '0'
- pending = all.pending.select('count(*)').to_sql
- running = all.running.select('count(*)').to_sql
- canceled = all.canceled.select('count(*)').to_sql
- skipped = all.skipped.select('count(*)').to_sql
+ pending = scope.pending.select('count(*)').to_sql
+ running = scope.running.select('count(*)').to_sql
+ canceled = scope.canceled.select('count(*)').to_sql
+ skipped = scope.skipped.select('count(*)').to_sql
deduce_status = "(CASE
WHEN (#{builds})=0 THEN NULL
@@ -31,11 +35,6 @@ module Statuseable
all.pluck(self.status_sql).first
end
- def duration
- duration_array = all.map(&:duration).compact
- duration_array.reduce(:+)
- end
-
def started_at
all.minimum(:started_at)
end
@@ -48,7 +47,8 @@ module Statuseable
included do
validates :status, inclusion: { in: AVAILABLE_STATUSES }
- state_machine :status, initial: :pending do
+ state_machine :status, initial: :created do
+ state :created, value: 'created'
state :pending, value: 'pending'
state :running, value: 'running'
state :failed, value: 'failed'
@@ -57,6 +57,8 @@ module Statuseable
state :skipped, value: 'skipped'
end
+ scope :created, -> { where(status: 'created') }
+ scope :relevant, -> { where.not(status: 'created') }
scope :running, -> { where(status: 'running') }
scope :pending, -> { where(status: 'pending') }
scope :success, -> { where(status: 'success') }
@@ -68,14 +70,24 @@ module Statuseable
end
def started?
- !pending? && !canceled? && started_at
+ STARTED_STATUSES.include?(status) && started_at
end
def active?
- running? || pending?
+ ACTIVE_STATUSES.include?(status)
end
def complete?
- canceled? || success? || failed?
+ COMPLETED_STATUSES.include?(status)
+ end
+
+ private
+
+ def calculate_duration
+ if started_at && finished_at
+ finished_at - started_at
+ elsif started_at
+ Time.now - started_at
+ end
end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 1a7cd60817e..1e338889714 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -36,4 +36,10 @@ class Deployment < ActiveRecord::Base
def manual_actions
deployable.try(:other_actions)
end
+
+ def includes_commit?(commit)
+ return false unless commit
+
+ project.repository.is_ancestor?(commit.id, sha)
+ end
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 9671955db36..c8320ff87fa 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -9,11 +9,16 @@ class DiffNote < Note
validates :diff_line, presence: true
validates :line_code, presence: true, line_code: true
validates :noteable_type, inclusion: { in: ['Commit', 'MergeRequest'] }
+ validates :resolved_by, presence: true, if: :resolved?
validate :positions_complete
validate :verify_supported
+ after_initialize :ensure_original_discussion_id
before_validation :set_original_position, :update_position, on: :create
- before_validation :set_line_code
+ before_validation :set_line_code, :set_original_discussion_id
+ # We need to do this again, because it's already in `Note`, but is affected by
+ # `update_position` and needs to run after that.
+ before_validation :set_discussion_id
after_save :keep_around_commits
class << self
@@ -30,14 +35,6 @@ class DiffNote < Note
{ position: position.to_json }
end
- def discussion_id
- @discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
- end
-
- def original_discussion_id
- @original_discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
- end
-
def position=(new_position)
if new_position.is_a?(String)
new_position = JSON.parse(new_position) rescue nil
@@ -63,19 +60,69 @@ class DiffNote < Note
diff_file.position(line) == self.original_position
end
+ def original_line_code
+ self.diff_file.line_code(self.diff_line)
+ end
+
def active?(diff_refs = nil)
return false unless supported?
return true if for_commit?
- diff_refs ||= self.noteable.diff_refs
+ diff_refs ||= noteable_diff_refs
self.position.diff_refs == diff_refs
end
+ def resolvable?
+ !system? && for_merge_request?
+ end
+
+ def resolved?
+ return false unless resolvable?
+
+ self.resolved_at.present?
+ end
+
+ def resolve!(current_user)
+ return unless resolvable?
+ return if resolved?
+
+ self.resolved_at = Time.now
+ self.resolved_by = current_user
+ save!
+ end
+
+ def unresolve!
+ return unless resolvable?
+ return unless resolved?
+
+ self.resolved_at = nil
+ self.resolved_by = nil
+ save!
+ end
+
+ def discussion
+ return unless resolvable?
+
+ self.noteable.find_diff_discussion(self.discussion_id)
+ end
+
+ def to_discussion
+ Discussion.new([self])
+ end
+
private
def supported?
- !self.for_merge_request? || self.noteable.support_new_diff_notes?
+ for_commit? || self.noteable.has_complete_diff_refs?
+ end
+
+ def noteable_diff_refs
+ if noteable.respond_to?(:diff_sha_refs)
+ noteable.diff_sha_refs
+ else
+ noteable.diff_refs
+ end
end
def set_original_position
@@ -86,6 +133,26 @@ class DiffNote < Note
self.line_code = self.position.line_code(self.project.repository)
end
+ def ensure_original_discussion_id
+ return unless self.persisted?
+ return if self.original_discussion_id
+
+ set_original_discussion_id
+ update_column(:original_discussion_id, self.original_discussion_id)
+ end
+
+ def set_original_discussion_id
+ self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id)
+ end
+
+ def build_discussion_id
+ self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
+ end
+
+ def build_original_discussion_id
+ self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
+ end
+
def update_position
return unless supported?
return if for_commit?
@@ -96,7 +163,7 @@ class DiffNote < Note
self.project,
nil,
old_diff_refs: self.position.diff_refs,
- new_diff_refs: self.noteable.diff_refs,
+ new_diff_refs: noteable_diff_refs,
paths: self.position.paths
).execute(self)
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index 74facfd1c9c..9676bc03470 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -1,7 +1,7 @@
class Discussion
NUMBER_OF_TRUNCATED_DIFF_LINES = 16
- attr_reader :first_note, :notes
+ attr_reader :first_note, :last_note, :notes
delegate :created_at,
:project,
@@ -12,12 +12,19 @@ class Discussion
:for_merge_request?,
:line_code,
+ :original_line_code,
:diff_file,
:for_line?,
:active?,
to: :first_note
+ delegate :resolved_at,
+ :resolved_by,
+
+ to: :last_resolved_note,
+ allow_nil: true
+
delegate :blob, :highlighted_diff_lines, to: :diff_file, allow_nil: true
def self.for_notes(notes)
@@ -30,13 +37,30 @@ class Discussion
def initialize(notes)
@first_note = notes.first
+ @last_note = notes.last
@notes = notes
end
+ def last_resolved_note
+ return unless resolved?
+
+ @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last
+ end
+
+ def last_updated_at
+ last_note.created_at
+ end
+
+ def last_updated_by
+ last_note.author
+ end
+
def id
first_note.discussion_id
end
+ alias_method :to_param, :id
+
def diff_discussion?
first_note.diff_note?
end
@@ -45,12 +69,74 @@ class Discussion
notes.any?(&:legacy_diff_note?)
end
+ def resolvable?
+ return @resolvable if defined?(@resolvable)
+
+ @resolvable = diff_discussion? && notes.any?(&:resolvable?)
+ end
+
+ def resolved?
+ return @resolved if defined?(@resolved)
+
+ @resolved = resolvable? && notes.none?(&:to_be_resolved?)
+ end
+
+ def resolved_notes
+ notes.select(&:resolved?)
+ end
+
+ def to_be_resolved?
+ resolvable? && !resolved?
+ end
+
+ def can_resolve?(current_user)
+ return false unless current_user
+ return false unless resolvable?
+
+ current_user == self.noteable.author ||
+ current_user.can?(:resolve_note, self.project)
+ end
+
+ def resolve!(current_user)
+ return unless resolvable?
+
+ notes.each do |note|
+ note.resolve!(current_user) if note.resolvable?
+ end
+ end
+
+ def unresolve!
+ return unless resolvable?
+
+ notes.each do |note|
+ note.unresolve! if note.resolvable?
+ end
+ end
+
def for_target?(target)
self.noteable == target && !diff_discussion?
end
+ def active?
+ return @active if defined?(@active)
+
+ @active = first_note.active?
+ end
+
+ def collapsed?
+ return false unless diff_discussion?
+
+ if resolvable?
+ # New diff discussions only disappear once they are marked resolved
+ resolved?
+ else
+ # Old diff discussions disappear once they become outdated
+ !active?
+ end
+ end
+
def expanded?
- !diff_discussion? || active?
+ !collapsed?
end
def reply_attributes
diff --git a/app/models/environment.rb b/app/models/environment.rb
index baed106e8c8..75e6f869786 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -25,4 +25,10 @@ class Environment < ActiveRecord::Base
def nullify_external_url
self.external_url = nil if self.external_url.blank?
end
+
+ def includes_commit?(commit)
+ return false unless last_deployment
+
+ last_deployment.includes_commit?(commit)
+ end
end
diff --git a/app/models/group.rb b/app/models/group.rb
index 37631b99701..c48869ae465 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -95,34 +95,40 @@ class Group < Namespace
end
end
- def add_users(user_ids, access_level, current_user = nil)
+ def add_users(user_ids, access_level, current_user: nil, expires_at: nil)
user_ids.each do |user_id|
- Member.add_user(self.group_members, user_id, access_level, current_user)
+ Member.add_user(
+ self.group_members,
+ user_id,
+ access_level,
+ current_user: current_user,
+ expires_at: expires_at
+ )
end
end
- def add_user(user, access_level, current_user = nil)
- add_users([user], access_level, current_user)
+ def add_user(user, access_level, current_user: nil, expires_at: nil)
+ add_users([user], access_level, current_user: current_user, expires_at: expires_at)
end
def add_guest(user, current_user = nil)
- add_user(user, Gitlab::Access::GUEST, current_user)
+ add_user(user, Gitlab::Access::GUEST, current_user: current_user)
end
def add_reporter(user, current_user = nil)
- add_user(user, Gitlab::Access::REPORTER, current_user)
+ add_user(user, Gitlab::Access::REPORTER, current_user: current_user)
end
def add_developer(user, current_user = nil)
- add_user(user, Gitlab::Access::DEVELOPER, current_user)
+ add_user(user, Gitlab::Access::DEVELOPER, current_user: current_user)
end
def add_master(user, current_user = nil)
- add_user(user, Gitlab::Access::MASTER, current_user)
+ add_user(user, Gitlab::Access::MASTER, current_user: current_user)
end
def add_owner(user, current_user = nil)
- add_user(user, Gitlab::Access::OWNER, current_user)
+ add_user(user, Gitlab::Access::OWNER, current_user: current_user)
end
def has_owner?(user)
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 11f734cfc6d..788611305fe 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -7,6 +7,7 @@ class Issue < ActiveRecord::Base
include Sortable
include Taskable
include Spammable
+ include FasterCacheKeys
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -35,6 +36,9 @@ class Issue < ActiveRecord::Base
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
+ attr_spammable :title, spam_title: true
+ attr_spammable :description, spam_description: true
+
state_machine :state, initial: :opened do
event :close do
transition [:reopened, :opened] => :closed
@@ -261,4 +265,9 @@ class Issue < ActiveRecord::Base
def overdue?
due_date.try(:past?) || false
end
+
+ # Only issues on public projects should be checked for spam
+ def check_for_spam?
+ project.public?
+ end
end
diff --git a/app/models/key.rb b/app/models/key.rb
index b9bc38a0436..568a60b8af3 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -26,8 +26,9 @@ class Key < ActiveRecord::Base
end
def publishable_key
- # Removes anything beyond the keytype and key itself
- self.key.split[0..1].join(' ')
+ # Strip out the keys comment so we don't leak email addresses
+ # Replace with simple ident of user_name (hostname)
+ self.key.split[0..1].push("#{self.user_name} (#{Gitlab.config.gitlab.host})").join(' ')
end
# projects that has this key
diff --git a/app/models/label.rb b/app/models/label.rb
index 35e678001dc..a23140b7d64 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -13,6 +13,8 @@ class Label < ActiveRecord::Base
default_value_for :color, DEFAULT_COLOR
belongs_to :project
+
+ has_many :lists, dependent: :destroy
has_many :label_links, dependent: :destroy
has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb
index 865712268a0..40277a9b139 100644
--- a/app/models/legacy_diff_note.rb
+++ b/app/models/legacy_diff_note.rb
@@ -8,8 +8,8 @@ class LegacyDiffNote < Note
before_create :set_diff
class << self
- def build_discussion_id(noteable_type, noteable_id, line_code, active = true)
- [super(noteable_type, noteable_id), line_code, active].join("-")
+ def build_discussion_id(noteable_type, noteable_id, line_code)
+ [super(noteable_type, noteable_id), line_code].join("-")
end
end
@@ -21,10 +21,6 @@ class LegacyDiffNote < Note
{ line_code: line_code }
end
- def discussion_id
- @discussion_id ||= self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
- end
-
def project_repository
if RequestStore.active?
RequestStore.fetch("project:#{project_id}:repository") { self.project.repository }
@@ -53,6 +49,10 @@ class LegacyDiffNote < Note
!line.meta? && diff_file.line_code(line) == self.line_code
end
+ def original_line_code
+ self.line_code
+ end
+
# Check if this note is part of an "active" discussion
#
# This will always return true for anything except MergeRequest noteables,
@@ -85,7 +85,7 @@ class LegacyDiffNote < Note
return nil unless noteable
return @diff if defined?(@diff)
- @diff = noteable.diffs(Commit.max_diff_options).find do |d|
+ @diff = noteable.raw_diffs(Commit.max_diff_options).find do |d|
d.new_path && Digest::SHA1.hexdigest(d.new_path) == diff_file_hash
end
end
@@ -116,7 +116,11 @@ class LegacyDiffNote < Note
# Find the diff on noteable that matches our own
def find_noteable_diff
- diffs = noteable.diffs(Commit.max_diff_options)
+ diffs = noteable.raw_diffs(Commit.max_diff_options)
diffs.find { |d| d.new_path == self.diff.new_path }
end
+
+ def build_discussion_id
+ self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
+ end
end
diff --git a/app/models/list.rb b/app/models/list.rb
new file mode 100644
index 00000000000..eb87decdbc8
--- /dev/null
+++ b/app/models/list.rb
@@ -0,0 +1,34 @@
+class List < ActiveRecord::Base
+ belongs_to :board
+ belongs_to :label
+
+ enum list_type: { backlog: 0, label: 1, done: 2 }
+
+ validates :board, :list_type, presence: true
+ validates :label, :position, presence: true, if: :label?
+ validates :label_id, uniqueness: { scope: :board_id }, if: :label?
+ validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label?
+
+ before_destroy :can_be_destroyed
+
+ scope :destroyable, -> { where(list_type: list_types[:label]) }
+ scope :movable, -> { where(list_type: list_types[:label]) }
+
+ def destroyable?
+ label?
+ end
+
+ def movable?
+ label?
+ end
+
+ def title
+ label? ? label.name : list_type.humanize
+ end
+
+ private
+
+ def can_be_destroyed
+ destroyable?
+ end
+end
diff --git a/app/models/member.rb b/app/models/member.rb
index 24ab1276ee9..64e0d33fb20 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -1,6 +1,7 @@
class Member < ActiveRecord::Base
include Sortable
include Importable
+ include Expirable
include Gitlab::Access
attr_accessor :raw_invite_token
@@ -73,7 +74,7 @@ class Member < ActiveRecord::Base
user
end
- def add_user(members, user_id, access_level, current_user = nil)
+ def add_user(members, user_id, access_level, current_user: nil, expires_at: nil)
user = user_for_id(user_id)
# `user` can be either a User object or an email to be invited
@@ -87,6 +88,7 @@ class Member < ActiveRecord::Base
if can_update_member?(current_user, member) || project_creator?(member, access_level)
member.created_by ||= current_user
member.access_level = access_level
+ member.expires_at = expires_at
member.save
end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index f39afc61ce9..ec2d40eb11c 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -8,6 +8,7 @@ class ProjectMember < Member
# Make sure project member points only to project as it source
default_value_for :source_type, SOURCE_TYPE
validates_format_of :source_type, with: /\AProject\z/
+ validates :access_level, inclusion: { in: Gitlab::Access.values }
default_scope { where(source_type: SOURCE_TYPE) }
scope :in_project, ->(project) { where(source_id: project.id) }
@@ -21,19 +22,19 @@ class ProjectMember < Member
# or symbol like :master representing role
#
# Ex.
- # add_users_into_projects(
+ # add_users_to_projects(
# project_ids,
# user_ids,
# ProjectMember::MASTER
# )
#
- # add_users_into_projects(
+ # add_users_to_projects(
# project_ids,
# user_ids,
# :master
# )
#
- def add_users_into_projects(project_ids, user_ids, access, current_user = nil)
+ def add_users_to_projects(project_ids, user_ids, access, current_user: nil, expires_at: nil)
access_level = if roles_hash.has_key?(access)
roles_hash[access]
elsif roles_hash.values.include?(access.to_i)
@@ -49,7 +50,13 @@ class ProjectMember < Member
project = Project.find(project_id)
users.each do |user|
- Member.add_user(project.project_members, user, access_level, current_user)
+ Member.add_user(
+ project.project_members,
+ user,
+ access_level,
+ current_user: current_user,
+ expires_at: expires_at
+ )
end
end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index f1b9c1d6feb..5330a07ee35 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -104,6 +104,7 @@ class MergeRequest < ActiveRecord::Base
scope :from_project, ->(project) { where(source_project_id: project.id) }
scope :merged, -> { with_state(:merged) }
scope :closed_and_merged, -> { with_states(:closed, :merged) }
+ scope :from_source_branches, ->(branches) { where(source_branch: branches) }
scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) }
@@ -164,8 +165,16 @@ class MergeRequest < ActiveRecord::Base
merge_request_diff ? merge_request_diff.first_commit : compare_commits.first
end
- def diffs(*args)
- merge_request_diff ? merge_request_diff.diffs(*args) : compare.diffs(*args)
+ def raw_diffs(*args)
+ merge_request_diff ? merge_request_diff.raw_diffs(*args) : compare.raw_diffs(*args)
+ end
+
+ def diffs(diff_options = nil)
+ if self.compare
+ self.compare.diffs(diff_options)
+ else
+ Gitlab::Diff::FileCollection::MergeRequest.new(self, diff_options: diff_options)
+ end
end
def diff_size
@@ -255,6 +264,19 @@ class MergeRequest < ActiveRecord::Base
)
end
+ # Return diff_refs instance trying to not touch the git repository
+ def diff_sha_refs
+ if merge_request_diff && merge_request_diff.diff_refs_by_sha?
+ return Gitlab::Diff::DiffRefs.new(
+ base_sha: merge_request_diff.base_commit_sha,
+ start_sha: merge_request_diff.start_commit_sha,
+ head_sha: merge_request_diff.head_commit_sha
+ )
+ else
+ diff_refs
+ end
+ end
+
def validate_branches
if target_project == source_project && target_branch == source_branch
errors.add :branch_conflict, "You can not use same project/branch for source and target"
@@ -300,6 +322,8 @@ class MergeRequest < ActiveRecord::Base
merge_request_diff.reload_content
+ MergeRequests::MergeRequestDiffCacheService.new.execute(self)
+
new_diff_refs = self.diff_refs
update_diff_notes_positions(
@@ -394,6 +418,32 @@ class MergeRequest < ActiveRecord::Base
)
end
+ def discussions
+ @discussions ||= self.mr_and_commit_notes.
+ inc_relations_for_view.
+ fresh.
+ discussions
+ end
+
+ def diff_discussions
+ @diff_discussions ||= self.notes.diff_notes.discussions
+ end
+
+ def find_diff_discussion(discussion_id)
+ notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a
+ return if notes.empty?
+
+ Discussion.new(notes)
+ end
+
+ def discussions_resolvable?
+ diff_discussions.any?(&:resolvable?)
+ end
+
+ def discussions_resolved?
+ discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?)
+ end
+
def hook_attrs
attrs = {
source: source_project.try(:hook_attrs),
@@ -567,6 +617,14 @@ class MergeRequest < ActiveRecord::Base
!pipeline || pipeline.success?
end
+ def environments
+ return unless diff_head_commit
+
+ target_project.environments.select do |environment|
+ environment.includes_commit?(diff_head_commit)
+ end
+ end
+
def state_human_name
if merged?
"Merged"
@@ -642,10 +700,21 @@ class MergeRequest < ActiveRecord::Base
diverged_commits_count > 0
end
+ def commits_sha
+ commits.map(&:sha)
+ end
+
def pipeline
@pipeline ||= source_project.pipeline(diff_head_sha, source_branch) if diff_head_sha && source_project
end
+ def all_pipelines
+ @all_pipelines ||=
+ if diff_head_sha && source_project
+ source_project.pipelines.order(id: :desc).where(sha: commits_sha, ref: source_branch)
+ end
+ end
+
def merge_commit
@merge_commit ||= project.commit(merge_commit_sha) if merge_commit_sha
end
@@ -658,12 +727,12 @@ class MergeRequest < ActiveRecord::Base
merge_commit
end
- def support_new_diff_notes?
- diff_refs && diff_refs.complete?
+ def has_complete_diff_refs?
+ diff_sha_refs && diff_sha_refs.complete?
end
def update_diff_notes_positions(old_diff_refs:, new_diff_refs:)
- return unless support_new_diff_notes?
+ return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs
active_diff_notes = self.notes.diff_notes.select do |note|
@@ -691,4 +760,26 @@ class MergeRequest < ActiveRecord::Base
def keep_around_commit
project.repository.keep_around(self.merge_commit_sha)
end
+
+ def conflicts
+ @conflicts ||= Gitlab::Conflict::FileCollection.new(self)
+ end
+
+ def conflicts_can_be_resolved_by?(user)
+ access = ::Gitlab::UserAccess.new(user, project: source_project)
+ access.can_push_to_branch?(source_branch)
+ end
+
+ def conflicts_can_be_resolved_in_ui?
+ return @conflicts_can_be_resolved_in_ui if defined?(@conflicts_can_be_resolved_in_ui)
+
+ return @conflicts_can_be_resolved_in_ui = false unless cannot_be_merged?
+ return @conflicts_can_be_resolved_in_ui = false unless has_complete_diff_refs?
+
+ begin
+ @conflicts_can_be_resolved_in_ui = conflicts.files.each(&:lines)
+ rescue Gitlab::Conflict::Parser::ParserError, Gitlab::Conflict::FileCollection::ConflictSideMissing
+ @conflicts_can_be_resolved_in_ui = false
+ end
+ end
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 3f520c8f3ff..32cc6a3bfea 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -33,12 +33,12 @@ class MergeRequestDiff < ActiveRecord::Base
end
def size
- real_size.presence || diffs.size
+ real_size.presence || raw_diffs.size
end
- def diffs(options={})
+ def raw_diffs(options = {})
if options[:ignore_whitespace_change]
- @diffs_no_whitespace ||= begin
+ @raw_diffs_no_whitespace ||= begin
compare = Gitlab::Git::Compare.new(
repository.raw_repository,
self.start_commit_sha || self.target_branch_sha,
@@ -47,8 +47,8 @@ class MergeRequestDiff < ActiveRecord::Base
compare.diffs(options)
end
else
- @diffs ||= {}
- @diffs[options] ||= load_diffs(st_diffs, options)
+ @raw_diffs ||= {}
+ @raw_diffs[options] ||= load_diffs(st_diffs, options)
end
end
@@ -82,6 +82,10 @@ class MergeRequestDiff < ActiveRecord::Base
project.commit(self.head_commit_sha)
end
+ def diff_refs_by_sha?
+ base_commit_sha? && head_commit_sha? && start_commit_sha?
+ end
+
def compare
@compare ||=
begin
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 8b52cc824cd..7c29d27ce97 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -1,4 +1,6 @@
class Namespace < ActiveRecord::Base
+ acts_as_paranoid
+
include Sortable
include Gitlab::ShellAdapter
diff --git a/app/models/note.rb b/app/models/note.rb
index b6b2ac6aa42..f2656df028b 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -5,6 +5,7 @@ class Note < ActiveRecord::Base
include Mentionable
include Awardable
include Importable
+ include FasterCacheKeys
# Attribute containing rendered and redacted Markdown as generated by
# Banzai::ObjectRenderer.
@@ -24,6 +25,9 @@ class Note < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User"
+ # Only used by DiffNote, but defined here so that it can be used in `Note.includes`
+ belongs_to :resolved_by, class_name: "User"
+
has_many :todos, dependent: :destroy
has_many :events, as: :target, dependent: :destroy
@@ -58,7 +62,7 @@ class Note < ActiveRecord::Base
scope :fresh, ->{ order(created_at: :asc, id: :asc) }
scope :inc_author_project, ->{ includes(:project, :author) }
scope :inc_author, ->{ includes(:author) }
- scope :inc_author_project_award_emoji, ->{ includes(:project, :author, :award_emoji) }
+ scope :inc_relations_for_view, ->{ includes(:project, :author, :updated_by, :resolved_by, :award_emoji) }
scope :diff_notes, ->{ where(type: ['LegacyDiffNote', 'DiffNote']) }
scope :non_diff_notes, ->{ where(type: ['Note', nil]) }
@@ -69,7 +73,9 @@ class Note < ActiveRecord::Base
project: [:project_members, { group: [:group_members] }])
end
+ after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code
+ before_validation :set_discussion_id
after_save :keep_around_commit
class << self
@@ -81,13 +87,18 @@ class Note < ActiveRecord::Base
[:discussion, noteable_type.try(:underscore), noteable_id].join("-")
end
+ def discussion_id(*args)
+ Digest::SHA1.hexdigest(build_discussion_id(*args))
+ end
+
def discussions
Discussion.for_notes(all)
end
def grouped_diff_discussions
- notes = diff_notes.fresh.select(&:active?)
- Discussion.for_diff_notes(notes).map { |d| [d.line_code, d] }.to_h
+ active_notes = diff_notes.fresh.select(&:active?)
+ Discussion.for_diff_notes(active_notes).
+ map { |d| [d.line_code, d] }.to_h
end
# Searches for notes matching the given query.
@@ -128,13 +139,16 @@ class Note < ActiveRecord::Base
true
end
- def discussion_id
- @discussion_id ||=
- if for_merge_request?
- [:discussion, :note, id].join("-")
- else
- self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
- end
+ def resolvable?
+ false
+ end
+
+ def resolved?
+ false
+ end
+
+ def to_be_resolved?
+ resolvable? && !resolved?
end
def max_attachment_size
@@ -242,4 +256,28 @@ class Note < ActiveRecord::Base
def nullify_blank_line_code
self.line_code = nil if self.line_code.blank?
end
+
+ def ensure_discussion_id
+ return unless self.persisted?
+ # Needed in case the SELECT statement doesn't ask for `discussion_id`
+ return unless self.has_attribute?(:discussion_id)
+ return if self.discussion_id
+
+ set_discussion_id
+ update_column(:discussion_id, self.discussion_id)
+ end
+
+ def set_discussion_id
+ self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id)
+ end
+
+ def build_discussion_id
+ if for_merge_request?
+ # Notes on merge requests are always in a discussion of their own,
+ # so we generate a unique discussion ID.
+ [:discussion, :note, SecureRandom.hex].join("-")
+ else
+ self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
+ end
+ end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 83b848ded8b..1855760e694 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -62,6 +62,8 @@ class Project < ActiveRecord::Base
belongs_to :group, -> { where(type: Group) }, foreign_key: 'namespace_id'
belongs_to :namespace
+ has_one :board, dependent: :destroy
+
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event', foreign_key: 'project_id'
# Project services
@@ -197,6 +199,8 @@ class Project < ActiveRecord::Base
scope :active, -> { joins(:issues, :notes, :merge_requests).order('issues.created_at, notes.created_at, merge_requests.created_at DESC') }
scope :abandoned, -> { where('projects.last_activity_at < ?', 6.months.ago) }
+ scope :excluding_project, ->(project) { where.not(id: project) }
+
state_machine :import_status, initial: :none do
event :import_start do
transition [:none, :finished] => :started
@@ -379,9 +383,10 @@ class Project < ActiveRecord::Base
joins(join_body).reorder('join_note_counts.amount DESC')
end
- # Deletes gitlab project export files older than 24 hours
- def remove_gitlab_exports!
- Gitlab::Popen.popen(%W(find #{Gitlab::ImportExport.storage_path} -not -path #{Gitlab::ImportExport.storage_path} -mmin +1440 -delete))
+ def cached_count
+ Rails.cache.fetch('total_project_count', expires_in: 5.minutes) do
+ Project.count
+ end
end
end
@@ -606,7 +611,10 @@ class Project < ActiveRecord::Base
end
def new_issue_address(author)
- if Gitlab::IncomingEmail.enabled? && author
+ # This feature is disabled for the time being.
+ return nil
+
+ if Gitlab::IncomingEmail.enabled? && author # rubocop:disable Lint/UnreachableCode
Gitlab::IncomingEmail.reply_address(
"#{path_with_namespace}+#{author.authentication_token}")
end
@@ -870,10 +878,16 @@ class Project < ActiveRecord::Base
# Check if current branch name is marked as protected in the system
def protected_branch?(branch_name)
+ return true if empty_repo? && default_branch_protected?
+
@protected_branches ||= self.protected_branches.to_a
ProtectedBranch.matching(branch_name, protected_branches: @protected_branches).present?
end
+ def user_can_push_to_empty_repo?(user)
+ !default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
+ end
+
def forked?
!(forked_project_link.nil? || forked_project_link.forked_from_project.nil?)
end
@@ -992,6 +1006,10 @@ class Project < ActiveRecord::Base
project_members.find_by(user_id: user)
end
+ def add_user(user, access_level, current_user: nil, expires_at: nil)
+ team.add_user(user, access_level, current_user: current_user, expires_at: expires_at)
+ end
+
def default_branch
@default_branch ||= repository.root_ref if repository.exists?
end
@@ -1154,16 +1172,6 @@ class Project < ActiveRecord::Base
@wiki ||= ProjectWiki.new(self, self.owner)
end
- def schedule_delete!(user_id, params)
- # Queue this task for after the commit, so once we mark pending_delete it will run
- run_after_commit do
- job_id = ProjectDestroyWorker.perform_async(id, user_id, params)
- Rails.logger.info("User #{user_id} scheduled destruction of project #{path_with_namespace} with job ID #{job_id}")
- end
-
- update_attribute(:pending_delete, true)
- end
-
def running_or_pending_build_count(force: false)
Rails.cache.fetch(['projects', id, 'running_or_pending_build_count'], force: force) do
builds.running_or_pending.count(:all)
@@ -1265,6 +1273,11 @@ class Project < ActiveRecord::Base
private
+ def default_branch_protected?
+ current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
+ current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
+ end
+
def authorized_for_user_by_group?(user, min_access_level)
member = user.group_members.find_by(source_id: group)
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index e52a6bd7c84..7613cbdea93 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -1,4 +1,6 @@
class ProjectGroupLink < ActiveRecord::Base
+ include Expirable
+
GUEST = 10
REPORTER = 20
DEVELOPER = 30
@@ -26,7 +28,7 @@ class ProjectGroupLink < ActiveRecord::Base
self.class.access_options.key(self.group_access)
end
- private
+ private
def different_group
if self.group && self.project && self.project.group == self.group
diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb
index 5e166471077..fa66e5864b8 100644
--- a/app/models/project_services/builds_email_service.rb
+++ b/app/models/project_services/builds_email_service.rb
@@ -51,8 +51,7 @@ class BuildsEmailService < Service
end
def test_data(project = nil, user = nil)
- build = project.builds.last
- Gitlab::BuildDataBuilder.build(build)
+ Gitlab::DataBuilder::Build.build(project.builds.last)
end
def fields
diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb
index 511b2eac792..5af93860d09 100644
--- a/app/models/project_services/campfire_service.rb
+++ b/app/models/project_services/campfire_service.rb
@@ -1,4 +1,6 @@
class CampfireService < Service
+ include HTTParty
+
prop_accessor :token, :subdomain, :room
validates :token, presence: true, if: :activated?
@@ -29,18 +31,53 @@ class CampfireService < Service
def execute(data)
return unless supported_events.include?(data[:object_kind])
- room = gate.find_room_by_name(self.room)
- return true unless room
-
+ self.class.base_uri base_uri
message = build_message(data)
-
- room.speak(message)
+ speak(self.room, message, auth)
end
private
- def gate
- @gate ||= Tinder::Campfire.new(subdomain, token: token)
+ def base_uri
+ @base_uri ||= "https://#{subdomain}.campfirenow.com"
+ end
+
+ def auth
+ # use a dummy password, as explained in the Campfire API doc:
+ # https://github.com/basecamp/campfire-api#authentication
+ @auth ||= {
+ basic_auth: {
+ username: token,
+ password: 'X'
+ }
+ }
+ end
+
+ # Post a message into a room, returns the message Hash in case of success.
+ # Returns nil otherwise.
+ # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message
+ def speak(room_name, message, auth)
+ room = rooms(auth).find { |r| r["name"] == room_name }
+ return nil unless room
+
+ path = "/room/#{room["id"]}/speak.json"
+ body = {
+ body: {
+ message: {
+ type: 'TextMessage',
+ body: message
+ }
+ }
+ }
+ res = self.class.post(path, auth.merge(body))
+ res.code == 201 ? res : nil
+ end
+
+ # Returns a list of rooms, or [].
+ # https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms
+ def rooms(auth)
+ res = self.class.get("/rooms.json", auth)
+ res.code == 200 ? res["rooms"] : []
end
def build_message(push)
diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb
index ad19b7795da..5301f9fa0ff 100644
--- a/app/models/project_services/pivotaltracker_service.rb
+++ b/app/models/project_services/pivotaltracker_service.rb
@@ -1,7 +1,9 @@
class PivotaltrackerService < Service
include HTTParty
- prop_accessor :token
+ API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits'
+
+ prop_accessor :token, :restrict_to_branch
validates :token, presence: true, if: :activated?
def title
@@ -18,7 +20,17 @@ class PivotaltrackerService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: '' }
+ {
+ type: 'text',
+ name: 'token',
+ placeholder: 'Pivotal Tracker API token.'
+ },
+ {
+ type: 'text',
+ name: 'restrict_to_branch',
+ placeholder: 'Comma-separated list of branches which will be ' \
+ 'automatically inspected. Leave blank to include all branches.'
+ }
]
end
@@ -28,8 +40,8 @@ class PivotaltrackerService < Service
def execute(data)
return unless supported_events.include?(data[:object_kind])
+ return unless allowed_branch?(data[:ref])
- url = 'https://www.pivotaltracker.com/services/v5/source_commits'
data[:commits].each do |commit|
message = {
'source_commit' => {
@@ -40,7 +52,7 @@ class PivotaltrackerService < Service
}
}
PivotaltrackerService.post(
- url,
+ API_ENDPOINT,
body: message.to_json,
headers: {
'Content-Type' => 'application/json',
@@ -49,4 +61,15 @@ class PivotaltrackerService < Service
)
end
end
+
+ private
+
+ def allowed_branch?(ref)
+ return true unless ref.present? && restrict_to_branch.present?
+
+ branch = Gitlab::Git.ref_name(ref)
+ allowed_branches = restrict_to_branch.split(',').map(&:strip)
+
+ branch.present? && allowed_branches.include?(branch)
+ end
end
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index fdfaf052730..ab6ea2aae36 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -15,9 +15,9 @@ class ProjectTeam
users, access, current_user = *args
if users.respond_to?(:each)
- add_users(users, access, current_user)
+ add_users(users, access, current_user: current_user)
else
- add_user(users, access, current_user)
+ add_user(users, access, current_user: current_user)
end
end
@@ -33,17 +33,18 @@ class ProjectTeam
member
end
- def add_users(users, access, current_user = nil)
- ProjectMember.add_users_into_projects(
+ def add_users(users, access, current_user: nil, expires_at: nil)
+ ProjectMember.add_users_to_projects(
[project.id],
users,
access,
- current_user
+ current_user: current_user,
+ expires_at: expires_at
)
end
- def add_user(user, access, current_user = nil)
- add_users([user], access, current_user)
+ def add_user(user, access, current_user: nil, expires_at: nil)
+ add_users([user], access, current_user: current_user, expires_at: expires_at)
end
# Remove all users from project team
@@ -138,8 +139,13 @@ class ProjectTeam
def max_member_access_for_user_ids(user_ids)
user_ids = user_ids.uniq
key = "max_member_access:#{project.id}"
- RequestStore.store[key] ||= {}
- access = RequestStore.store[key]
+
+ access = {}
+
+ if RequestStore.active?
+ RequestStore.store[key] ||= {}
+ access = RequestStore.store[key]
+ end
# Lookup only the IDs we need
user_ids = user_ids - access.keys
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index a255710f577..46f70da2452 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -56,6 +56,10 @@ class ProjectWiki
end
end
+ def repository_exists?
+ !!repository.exists?
+ end
+
def empty?
pages.empty?
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 226b3f54342..6240912a6e1 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -5,11 +5,14 @@ class ProtectedBranch < ActiveRecord::Base
validates :name, presence: true
validates :project, presence: true
- has_one :merge_access_level, dependent: :destroy
- has_one :push_access_level, dependent: :destroy
+ has_many :merge_access_levels, dependent: :destroy
+ has_many :push_access_levels, dependent: :destroy
- accepts_nested_attributes_for :push_access_level
- accepts_nested_attributes_for :merge_access_level
+ validates_length_of :merge_access_levels, is: 1, message: "are restricted to a single instance per protected branch."
+ validates_length_of :push_access_levels, is: 1, message: "are restricted to a single instance per protected branch."
+
+ accepts_nested_attributes_for :push_access_levels
+ accepts_nested_attributes_for :merge_access_levels
def commit
project.commit(self.name)
diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb
index b1112ee737d..806b3ccd275 100644
--- a/app/models/protected_branch/merge_access_level.rb
+++ b/app/models/protected_branch/merge_access_level.rb
@@ -1,4 +1,6 @@
class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
+ include ProtectedBranchAccess
+
belongs_to :protected_branch
delegate :project, to: :protected_branch
@@ -17,8 +19,4 @@ class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
project.team.max_member_access(user.id) >= access_level
end
-
- def humanize
- self.class.human_access_levels[self.access_level]
- end
end
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index 6a5e49cf453..92e9c51d883 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -1,4 +1,6 @@
class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
+ include ProtectedBranchAccess
+
belongs_to :protected_branch
delegate :project, to: :protected_branch
@@ -20,8 +22,4 @@ class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
project.team.max_member_access(user.id) >= access_level
end
-
- def humanize
- self.class.human_access_levels[self.access_level]
- end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index bac37483c47..bdc3b9d1c1c 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -277,7 +277,7 @@ class Repository
def cache_keys
%i(size commit_count
readme version contribution_guide changelog
- license_blob license_key gitignore)
+ license_blob license_key gitignore koding_yml)
end
# Keys for data on branch/tag operations.
@@ -372,7 +372,7 @@ class Repository
# We don't want to flush the cache if the commit didn't actually make any
# changes to any of the possible avatar files.
if revision && commit = self.commit(revision)
- return unless commit.diffs.
+ return unless commit.raw_diffs(deltas_only: true).
any? { |diff| AVATAR_FILES.include?(diff.new_path) }
end
@@ -391,6 +391,8 @@ class Repository
expire_exists_cache
expire_root_ref_cache
expire_emptiness_caches
+
+ repository_event(:create_repository)
end
# Runs code just before a repository is deleted.
@@ -407,6 +409,8 @@ class Repository
expire_root_ref_cache
expire_emptiness_caches
expire_exists_cache
+
+ repository_event(:remove_repository)
end
# Runs code just before the HEAD of a repository is changed.
@@ -414,6 +418,8 @@ class Repository
# Cached divergent commit counts are based on repository head
expire_branch_cache
expire_root_ref_cache
+
+ repository_event(:change_default_branch)
end
# Runs code before pushing (= creating or removing) a tag.
@@ -421,12 +427,16 @@ class Repository
expire_cache
expire_tags_cache
expire_tag_count_cache
+
+ repository_event(:push_tag)
end
# Runs code before removing a tag.
def before_remove_tag
expire_tags_cache
expire_tag_count_cache
+
+ repository_event(:remove_tag)
end
def before_import
@@ -443,6 +453,8 @@ class Repository
# Runs code after a new commit has been pushed.
def after_push_commit(branch_name, revision)
expire_cache(branch_name, revision)
+
+ repository_event(:push_commit, branch: branch_name)
end
# Runs code after a new branch has been created.
@@ -450,11 +462,15 @@ class Repository
expire_branches_cache
expire_has_visible_content_cache
expire_branch_count_cache
+
+ repository_event(:push_branch)
end
# Runs code before removing an existing branch.
def before_remove_branch
expire_branches_cache
+
+ repository_event(:remove_branch)
end
# Runs code after an existing branch has been removed.
@@ -537,6 +553,14 @@ class Repository
end
end
+ def koding_yml
+ return nil unless head_exists?
+
+ cache.fetch(:koding_yml) do
+ file_on_head(/\A\.koding\.yml\z/)
+ end
+ end
+
def gitlab_ci_yml
return nil unless head_exists?
@@ -601,7 +625,7 @@ class Repository
commit(sha)
end
- def next_branch(name, opts={})
+ def next_branch(name, opts = {})
branch_ids = self.branch_names.map do |n|
next 1 if n == name
result = n.match(/\A#{name}-([0-9]+)\z/)
@@ -636,9 +660,7 @@ class Repository
def tags_sorted_by(value)
case value
when 'name'
- # Would be better to use `sort_by` but `version_sorter` only exposes
- # `sort` and `rsort`
- VersionSorter.rsort(tag_names).map { |tag_name| find_tag(tag_name) }
+ VersionSorter.rsort(tags) { |tag| tag.name }
when 'updated_desc'
tags_sorted_by_committed_date.reverse
when 'updated_asc'
@@ -871,6 +893,14 @@ class Repository
end
end
+ def resolve_conflicts(user, branch, params)
+ commit_with_hooks(user, branch) do
+ committer = user_to_committer(user)
+
+ Rugged::Commit.create(rugged, params.merge(author: committer, committer: committer))
+ end
+ end
+
def check_revert_content(commit, base_branch)
source_sha = find_branch(base_branch).target.sha
args = [commit.id, source_sha]
@@ -1061,4 +1091,8 @@ class Repository
def keep_around_ref_name(sha)
"refs/keep-around/#{sha}"
end
+
+ def repository_event(event, tags = {})
+ Gitlab::Metrics.add_event(event, { path: path_with_namespace }.merge(tags))
+ end
end
diff --git a/app/models/service.rb b/app/models/service.rb
index e4cd44f542a..09b4717a523 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -80,7 +80,7 @@ class Service < ActiveRecord::Base
end
def test_data(project, user)
- Gitlab::PushDataBuilder.build_sample(project, user)
+ Gitlab::DataBuilder::Push.build_sample(project, user)
end
def event_channel_names
diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb
index 12df68ef83b..3b8b9833565 100644
--- a/app/models/spam_log.rb
+++ b/app/models/spam_log.rb
@@ -7,4 +7,8 @@ class SpamLog < ActiveRecord::Base
user.block
user.destroy
end
+
+ def text
+ [title, description].join("\n")
+ end
end
diff --git a/app/models/spam_report.rb b/app/models/spam_report.rb
deleted file mode 100644
index cdc7321b08e..00000000000
--- a/app/models/spam_report.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-class SpamReport < ActiveRecord::Base
- belongs_to :user
-
- validates :user, presence: true
-end
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 8d7a5965aa1..6ae9956ade5 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -1,4 +1,6 @@
class Todo < ActiveRecord::Base
+ include Sortable
+
ASSIGNED = 1
MENTIONED = 2
BUILD_FAILED = 3
@@ -41,6 +43,23 @@ class Todo < ActiveRecord::Base
after_save :keep_around_commit
+ class << self
+ def sort(method)
+ method == "priority" ? order_by_labels_priority : order_by(method)
+ end
+
+ # Order by priority depending on which issue/merge request the Todo belongs to
+ # Todos with highest priority first then oldest todos
+ # Need to order by created_at last because of differences on Mysql and Postgres when joining by type "Merge_request/Issue"
+ def order_by_labels_priority
+ highest_priority = highest_label_priority(["Issue", "MergeRequest"], "todos.target_id").to_sql
+
+ select("#{table_name}.*, (#{highest_priority}) AS highest_priority").
+ order(Gitlab::Database.nulls_last_order('highest_priority', 'ASC')).
+ order('todos.created_at')
+ end
+ end
+
def build_failed?
action == BUILD_FAILED
end
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
index 00b19686d48..808acec098f 100644
--- a/app/models/u2f_registration.rb
+++ b/app/models/u2f_registration.rb
@@ -3,18 +3,19 @@
class U2fRegistration < ActiveRecord::Base
belongs_to :user
- def self.register(user, app_id, json_response, challenges)
+ def self.register(user, app_id, params, challenges)
u2f = U2F::U2F.new(app_id)
registration = self.new
begin
- response = U2F::RegisterResponse.load_from_json(json_response)
+ response = U2F::RegisterResponse.load_from_json(params[:device_response])
registration_data = u2f.register!(challenges, response)
registration.update(certificate: registration_data.certificate,
key_handle: registration_data.key_handle,
public_key: registration_data.public_key,
counter: registration_data.counter,
- user: user)
+ user: user,
+ name: params[:name])
rescue JSON::ParserError, NoMethodError, ArgumentError
registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.')
rescue U2F::Error => e
diff --git a/app/models/user.rb b/app/models/user.rb
index db747434959..48e83ab7e56 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -23,13 +23,13 @@ class User < ActiveRecord::Base
default_value_for :theme_id, gitlab_config.default_theme
attr_encrypted :otp_secret,
- key: Gitlab::Application.config.secret_key_base,
+ key: Gitlab::Application.secrets.otp_key_base,
mode: :per_attribute_iv_and_salt,
insecure_mode: true,
algorithm: 'aes-256-cbc'
devise :two_factor_authenticatable,
- otp_secret_encryption_key: Gitlab::Application.config.secret_key_base
+ otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base
devise :two_factor_backupable, otp_number_of_backup_codes: 10
serialize :otp_backup_codes, JSON
@@ -429,6 +429,13 @@ class User < ActiveRecord::Base
owned_groups.select(:id), namespace.id).joins(:namespace)
end
+ # Returns projects which user can admin issues on (for example to move an issue to that project).
+ #
+ # This logic is duplicated from `Ability#project_abilities` into a SQL form.
+ def projects_where_can_admin_issues
+ authorized_projects(Gitlab::Access::REPORTER).non_archived.where.not(issues_enabled: false)
+ end
+
def is_admin?
admin
end
@@ -809,13 +816,13 @@ class User < ActiveRecord::Base
def todos_done_count(force: false)
Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do
- todos.done.count
+ TodosFinder.new(self, state: :done).execute.count
end
end
def todos_pending_count(force: false)
Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force) do
- todos.pending.count
+ TodosFinder.new(self, state: :pending).execute.count
end
end
diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb
new file mode 100644
index 00000000000..0949c6ef083
--- /dev/null
+++ b/app/models/user_agent_detail.rb
@@ -0,0 +1,9 @@
+class UserAgentDetail < ActiveRecord::Base
+ belongs_to :subject, polymorphic: true
+
+ validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true
+
+ def submittable?
+ !submitted?
+ end
+end
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
new file mode 100644
index 00000000000..5c60addbe7c
--- /dev/null
+++ b/app/services/akismet_service.rb
@@ -0,0 +1,79 @@
+class AkismetService
+ attr_accessor :owner, :text, :options
+
+ def initialize(owner, text, options = {})
+ @owner = owner
+ @text = text
+ @options = options
+ end
+
+ def is_spam?
+ return false unless akismet_enabled?
+
+ params = {
+ type: 'comment',
+ text: text,
+ created_at: DateTime.now,
+ author: owner.name,
+ author_email: owner.email,
+ referrer: options[:referrer],
+ }
+
+ begin
+ is_spam, is_blatant = akismet_client.check(options[:ip_address], options[:user_agent], params)
+ is_spam || is_blatant
+ rescue => e
+ Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check")
+ false
+ end
+ end
+
+ def submit_ham
+ return false unless akismet_enabled?
+
+ params = {
+ type: 'comment',
+ text: text,
+ author: owner.name,
+ author_email: owner.email
+ }
+
+ begin
+ akismet_client.submit_ham(options[:ip_address], options[:user_agent], params)
+ true
+ rescue => e
+ Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
+ false
+ end
+ end
+
+ def submit_spam
+ return false unless akismet_enabled?
+
+ params = {
+ type: 'comment',
+ text: text,
+ author: owner.name,
+ author_email: owner.email
+ }
+
+ begin
+ akismet_client.submit_spam(options[:ip_address], options[:user_agent], params)
+ true
+ rescue => e
+ Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
+ false
+ end
+ end
+
+ private
+
+ def akismet_client
+ @akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key,
+ Gitlab.config.gitlab.url)
+ end
+
+ def akismet_enabled?
+ current_application_settings.akismet_enabled
+ end
+end
diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb
new file mode 100644
index 00000000000..b2069ca825a
--- /dev/null
+++ b/app/services/boards/base_service.rb
@@ -0,0 +1,5 @@
+module Boards
+ class BaseService < ::BaseService
+ delegate :board, to: :project
+ end
+end
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
new file mode 100644
index 00000000000..072a0749285
--- /dev/null
+++ b/app/services/boards/create_service.rb
@@ -0,0 +1,16 @@
+module Boards
+ class CreateService < Boards::BaseService
+ def execute
+ create_board! unless project.board.present?
+ project.board
+ end
+
+ private
+
+ def create_board!
+ project.create_board
+ project.board.lists.create(list_type: :backlog)
+ project.board.lists.create(list_type: :done)
+ end
+ end
+end
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
new file mode 100644
index 00000000000..435a8c6e681
--- /dev/null
+++ b/app/services/boards/issues/list_service.rb
@@ -0,0 +1,63 @@
+module Boards
+ module Issues
+ class ListService < Boards::BaseService
+ def execute
+ issues = IssuesFinder.new(current_user, filter_params).execute
+ issues = without_board_labels(issues) unless list.movable?
+ issues = with_list_label(issues) if list.movable?
+ issues
+ end
+
+ private
+
+ def list
+ @list ||= board.lists.find(params[:id])
+ end
+
+ def filter_params
+ set_default_scope
+ set_default_sort
+ set_project
+ set_state
+
+ params
+ end
+
+ def set_default_scope
+ params[:scope] = 'all'
+ end
+
+ def set_default_sort
+ params[:sort] = 'priority'
+ end
+
+ def set_project
+ params[:project_id] = project.id
+ end
+
+ def set_state
+ params[:state] = list.done? ? 'closed' : 'opened'
+ end
+
+ def board_label_ids
+ @board_label_ids ||= board.lists.movable.pluck(:label_id)
+ end
+
+ def without_board_labels(issues)
+ return issues unless board_label_ids.any?
+
+ issues.where.not(
+ LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
+ .where(label_id: board_label_ids).limit(1).arel.exists
+ )
+ end
+
+ def with_list_label(issues)
+ issues.where(
+ LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
+ .where("label_links.label_id = ?", list.label_id).limit(1).arel.exists
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
new file mode 100644
index 00000000000..84dc3f70e76
--- /dev/null
+++ b/app/services/boards/issues/move_service.rb
@@ -0,0 +1,59 @@
+module Boards
+ module Issues
+ class MoveService < Boards::BaseService
+ def execute(issue)
+ return false unless can?(current_user, :update_issue, issue)
+ return false unless valid_move?
+
+ update_service.execute(issue)
+ end
+
+ private
+
+ def valid_move?
+ moving_from_list.present? && moving_to_list.present? &&
+ moving_from_list != moving_to_list
+ end
+
+ def moving_from_list
+ @moving_from_list ||= board.lists.find_by(id: params[:from_list_id])
+ end
+
+ def moving_to_list
+ @moving_to_list ||= board.lists.find_by(id: params[:to_list_id])
+ end
+
+ def update_service
+ ::Issues::UpdateService.new(project, current_user, issue_params)
+ end
+
+ def issue_params
+ {
+ add_label_ids: add_label_ids,
+ remove_label_ids: remove_label_ids,
+ state_event: issue_state
+ }
+ end
+
+ def issue_state
+ return 'reopen' if moving_from_list.done?
+ return 'close' if moving_to_list.done?
+ end
+
+ def add_label_ids
+ [moving_to_list.label_id].compact
+ end
+
+ def remove_label_ids
+ label_ids =
+ if moving_to_list.movable?
+ moving_from_list.label_id
+ else
+ board.lists.movable.pluck(:label_id)
+ end
+
+ Array(label_ids).compact
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb
new file mode 100644
index 00000000000..5cb408b9d20
--- /dev/null
+++ b/app/services/boards/lists/create_service.rb
@@ -0,0 +1,22 @@
+module Boards
+ module Lists
+ class CreateService < Boards::BaseService
+ def execute
+ List.transaction do
+ create_list_at(next_position)
+ end
+ end
+
+ private
+
+ def next_position
+ max_position = board.lists.movable.maximum(:position)
+ max_position.nil? ? 0 : max_position.succ
+ end
+
+ def create_list_at(position)
+ board.lists.create(params.merge(list_type: :label, position: position))
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb
new file mode 100644
index 00000000000..25da3bfb56d
--- /dev/null
+++ b/app/services/boards/lists/destroy_service.rb
@@ -0,0 +1,25 @@
+module Boards
+ module Lists
+ class DestroyService < Boards::BaseService
+ def execute(list)
+ return false unless list.destroyable?
+
+ list.with_lock do
+ decrement_higher_lists(list)
+ remove_list(list)
+ end
+ end
+
+ private
+
+ def decrement_higher_lists(list)
+ board.lists.movable.where('position > ?', list.position)
+ .update_all('position = position - 1')
+ end
+
+ def remove_list(list)
+ list.destroy
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb
new file mode 100644
index 00000000000..1c48b9786e4
--- /dev/null
+++ b/app/services/boards/lists/generate_service.rb
@@ -0,0 +1,36 @@
+module Boards
+ module Lists
+ class GenerateService < Boards::BaseService
+ def execute
+ return false unless board.lists.movable.empty?
+
+ List.transaction do
+ label_params.each { |params| create_list(params) }
+ end
+
+ true
+ end
+
+ private
+
+ def create_list(params)
+ label = find_or_create_label(params)
+ Lists::CreateService.new(project, current_user, label_id: label.id).execute
+ end
+
+ def find_or_create_label(params)
+ project.labels.create_with(color: params[:color])
+ .find_or_create_by(name: params[:name])
+ end
+
+ def label_params
+ [
+ { name: 'Development', color: '#5CB85C' },
+ { name: 'Testing', color: '#F0AD4E' },
+ { name: 'Production', color: '#FF5F00' },
+ { name: 'Ready', color: '#FF0000' }
+ ]
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb
new file mode 100644
index 00000000000..020ff69f4a7
--- /dev/null
+++ b/app/services/boards/lists/move_service.rb
@@ -0,0 +1,51 @@
+module Boards
+ module Lists
+ class MoveService < Boards::BaseService
+ def execute(list)
+ @old_position = list.position
+ @new_position = params[:position]
+
+ return false unless list.movable?
+ return false unless valid_move?
+
+ list.with_lock do
+ reorder_intermediate_lists
+ update_list_position(list)
+ end
+ end
+
+ private
+
+ attr_reader :old_position, :new_position
+
+ def valid_move?
+ new_position.present? && new_position != old_position &&
+ new_position >= 0 && new_position < board.lists.movable.size
+ end
+
+ def reorder_intermediate_lists
+ if old_position < new_position
+ decrement_intermediate_lists
+ else
+ increment_intermediate_lists
+ end
+ end
+
+ def decrement_intermediate_lists
+ board.lists.movable.where('position > ?', old_position)
+ .where('position <= ?', new_position)
+ .update_all('position = position - 1')
+ end
+
+ def increment_intermediate_lists
+ board.lists.movable.where('position >= ?', new_position)
+ .where('position < ?', old_position)
+ .update_all('position = position + 1')
+ end
+
+ def update_list_position(list)
+ list.update_attribute(:position, new_position)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb
deleted file mode 100644
index 4946f7076fd..00000000000
--- a/app/services/ci/create_builds_service.rb
+++ /dev/null
@@ -1,62 +0,0 @@
-module Ci
- class CreateBuildsService
- def initialize(pipeline)
- @pipeline = pipeline
- @config = pipeline.config_processor
- end
-
- def execute(stage, user, status, trigger_request = nil)
- builds_attrs = @config.builds_for_stage_and_ref(stage, @pipeline.ref, @pipeline.tag, trigger_request)
-
- # check when to create next build
- builds_attrs = builds_attrs.select do |build_attrs|
- case build_attrs[:when]
- when 'on_success'
- status == 'success'
- when 'on_failure'
- status == 'failed'
- when 'always', 'manual'
- %w(success failed).include?(status)
- end
- end
-
- # don't create the same build twice
- builds_attrs.reject! do |build_attrs|
- @pipeline.builds.find_by(ref: @pipeline.ref,
- tag: @pipeline.tag,
- trigger_request: trigger_request,
- name: build_attrs[:name])
- end
-
- builds_attrs.map do |build_attrs|
- build_attrs.slice!(:name,
- :commands,
- :tag_list,
- :options,
- :allow_failure,
- :stage,
- :stage_idx,
- :environment,
- :when,
- :yaml_variables)
-
- build_attrs.merge!(pipeline: @pipeline,
- ref: @pipeline.ref,
- tag: @pipeline.tag,
- trigger_request: trigger_request,
- user: user,
- project: @pipeline.project)
-
- # TODO: The proper implementation for this is in
- # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5295
- build_attrs[:status] = 'skipped' if build_attrs[:when] == 'manual'
-
- ##
- # We do not persist new builds here.
- # Those will be persisted when @pipeline is saved.
- #
- @pipeline.builds.new(build_attrs)
- end
- end
- end
-end
diff --git a/app/services/ci/create_pipeline_builds_service.rb b/app/services/ci/create_pipeline_builds_service.rb
new file mode 100644
index 00000000000..005014fa1de
--- /dev/null
+++ b/app/services/ci/create_pipeline_builds_service.rb
@@ -0,0 +1,42 @@
+module Ci
+ class CreatePipelineBuildsService < BaseService
+ attr_reader :pipeline
+
+ def execute(pipeline)
+ @pipeline = pipeline
+
+ new_builds.map do |build_attributes|
+ create_build(build_attributes)
+ end
+ end
+
+ private
+
+ def create_build(build_attributes)
+ build_attributes = build_attributes.merge(
+ pipeline: pipeline,
+ project: pipeline.project,
+ ref: pipeline.ref,
+ tag: pipeline.tag,
+ user: current_user,
+ trigger_request: trigger_request
+ )
+ pipeline.builds.create(build_attributes)
+ end
+
+ def new_builds
+ @new_builds ||= pipeline.config_builds_attributes.
+ reject { |build| existing_build_names.include?(build[:name]) }
+ end
+
+ def existing_build_names
+ @existing_build_names ||= pipeline.builds.pluck(:name)
+ end
+
+ def trigger_request
+ return @trigger_request if defined?(@trigger_request)
+
+ @trigger_request ||= pipeline.trigger_requests.first
+ end
+ end
+end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 7a8b0683acb..cde856b0186 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -1,50 +1,101 @@
module Ci
class CreatePipelineService < BaseService
- def execute
- pipeline = project.pipelines.new(params)
- pipeline.user = current_user
+ attr_reader :pipeline
- unless ref_names.include?(params[:ref])
- pipeline.errors.add(:base, 'Reference not found')
- return pipeline
+ def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil)
+ @pipeline = Ci::Pipeline.new(
+ project: project,
+ ref: ref,
+ sha: sha,
+ before_sha: before_sha,
+ tag: tag?,
+ trigger_requests: Array(trigger_request),
+ user: current_user
+ )
+
+ unless project.builds_enabled?
+ return error('Pipeline is disabled')
end
- if commit
- pipeline.sha = commit.id
- else
- pipeline.errors.add(:base, 'Commit not found')
- return pipeline
+ unless trigger_request || can?(current_user, :create_pipeline, project)
+ return error('Insufficient permissions to create a new pipeline')
end
- unless can?(current_user, :create_pipeline, project)
- pipeline.errors.add(:base, 'Insufficient permissions to create a new pipeline')
- return pipeline
+ unless branch? || tag?
+ return error('Reference not found')
+ end
+
+ unless commit
+ return error('Commit not found')
end
unless pipeline.config_processor
- pipeline.errors.add(:base, pipeline.yaml_errors || 'Missing .gitlab-ci.yml file')
- return pipeline
+ unless pipeline.ci_yaml_file
+ return error('Missing .gitlab-ci.yml file')
+ end
+ return error(pipeline.yaml_errors, save: save_on_errors)
end
- pipeline.save!
- pipeline.touch
+ if !ignore_skip_ci && skip_ci?
+ pipeline.skip if save_on_errors
+ return pipeline
+ end
- unless pipeline.create_builds(current_user)
- pipeline.errors.add(:base, 'No builds for this pipeline.')
+ unless pipeline.config_builds_attributes.present?
+ return error('No builds for this pipeline.')
end
pipeline.save
+ pipeline.process!
pipeline
end
private
- def ref_names
- @ref_names ||= project.repository.ref_names
+ def skip_ci?
+ pipeline.git_commit_message =~ /\[(ci skip|skip ci)\]/i if pipeline.git_commit_message
end
def commit
- @commit ||= project.commit(params[:ref])
+ @commit ||= project.commit(origin_sha || origin_ref)
+ end
+
+ def sha
+ commit.try(:id)
+ end
+
+ def before_sha
+ params[:checkout_sha] || params[:before] || Gitlab::Git::BLANK_SHA
+ end
+
+ def origin_sha
+ params[:checkout_sha] || params[:after]
+ end
+
+ def origin_ref
+ params[:ref]
+ end
+
+ def branch?
+ project.repository.ref_exists?(Gitlab::Git::BRANCH_REF_PREFIX + ref)
+ end
+
+ def tag?
+ project.repository.ref_exists?(Gitlab::Git::TAG_REF_PREFIX + ref)
+ end
+
+ def ref
+ Gitlab::Git.ref_name(origin_ref)
+ end
+
+ def valid_sha?
+ origin_sha && origin_sha != Gitlab::Git::BLANK_SHA
+ end
+
+ def error(message, save: false)
+ pipeline.errors.add(:base, message)
+ pipeline.drop if save
+ pipeline
end
end
end
diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb
index 1e629cf119a..6af3c1ca5b1 100644
--- a/app/services/ci/create_trigger_request_service.rb
+++ b/app/services/ci/create_trigger_request_service.rb
@@ -1,20 +1,11 @@
module Ci
class CreateTriggerRequestService
def execute(project, trigger, ref, variables = nil)
- commit = project.commit(ref)
- return unless commit
+ trigger_request = trigger.trigger_requests.create(variables: variables)
- # check if ref is tag
- tag = project.repository.find_tag(ref).present?
-
- pipeline = project.pipelines.create(sha: commit.sha, ref: ref, tag: tag)
-
- trigger_request = trigger.trigger_requests.create!(
- variables: variables,
- pipeline: pipeline,
- )
-
- if pipeline.create_builds(nil, trigger_request)
+ pipeline = Ci::CreatePipelineService.new(project, nil, ref: ref).
+ execute(ignore_skip_ci: true, trigger_request: trigger_request)
+ if pipeline.persisted?
trigger_request
end
end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
new file mode 100644
index 00000000000..6f7610d42ba
--- /dev/null
+++ b/app/services/ci/process_pipeline_service.rb
@@ -0,0 +1,77 @@
+module Ci
+ class ProcessPipelineService < BaseService
+ attr_reader :pipeline
+
+ def execute(pipeline)
+ @pipeline = pipeline
+
+ # This method will ensure that our pipeline does have all builds for all stages created
+ if created_builds.empty?
+ create_builds!
+ end
+
+ new_builds =
+ stage_indexes_of_created_builds.map do |index|
+ process_stage(index)
+ end
+
+ # Return a flag if a when builds got enqueued
+ new_builds.flatten.any?
+ end
+
+ private
+
+ def create_builds!
+ Ci::CreatePipelineBuildsService.new(project, current_user).execute(pipeline)
+ end
+
+ def process_stage(index)
+ current_status = status_for_prior_stages(index)
+
+ created_builds_in_stage(index).select do |build|
+ process_build(build, current_status)
+ end
+ end
+
+ def process_build(build, current_status)
+ return false unless Statuseable::COMPLETED_STATUSES.include?(current_status)
+
+ if valid_statuses_for_when(build.when).include?(current_status)
+ build.enqueue
+ true
+ else
+ build.skip
+ false
+ end
+ end
+
+ def valid_statuses_for_when(value)
+ case value
+ when 'on_success'
+ %w[success]
+ when 'on_failure'
+ %w[failed]
+ when 'always'
+ %w[success failed]
+ else
+ []
+ end
+ end
+
+ def status_for_prior_stages(index)
+ pipeline.builds.where('stage_idx < ?', index).latest.status || 'success'
+ end
+
+ def stage_indexes_of_created_builds
+ created_builds.order(:stage_idx).pluck('distinct stage_idx')
+ end
+
+ def created_builds_in_stage(index)
+ created_builds.where(stage_idx: index)
+ end
+
+ def created_builds
+ pipeline.builds.created
+ end
+ end
+end
diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb
index 149822aa647..6d6075628af 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -20,10 +20,12 @@ class CompareService
)
end
- Gitlab::Git::Compare.new(
+ raw_compare = Gitlab::Git::Compare.new(
target_project.repository.raw_repository,
target_branch,
- source_sha,
+ source_sha
)
+
+ Compare.new(raw_compare, target_project)
end
end
diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb
deleted file mode 100644
index 0b66b854dea..00000000000
--- a/app/services/create_commit_builds_service.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-class CreateCommitBuildsService
- def execute(project, user, params)
- return unless project.builds_enabled?
-
- before_sha = params[:checkout_sha] || params[:before]
- sha = params[:checkout_sha] || params[:after]
- origin_ref = params[:ref]
-
- ref = Gitlab::Git.ref_name(origin_ref)
- tag = Gitlab::Git.tag_ref?(origin_ref)
-
- # Skip branch removal
- if sha == Gitlab::Git::BLANK_SHA
- return false
- end
-
- @pipeline = Ci::Pipeline.new(
- project: project,
- sha: sha,
- ref: ref,
- before_sha: before_sha,
- tag: tag,
- user: user)
-
- ##
- # Skip creating pipeline if no gitlab-ci.yml is found
- #
- unless @pipeline.ci_yaml_file
- return false
- end
-
- ##
- # Skip creating builds for commits that have [ci skip]
- # but save pipeline object
- #
- if @pipeline.skip_ci?
- return save_pipeline!
- end
-
- ##
- # Skip creating builds when CI config is invalid
- # but save pipeline object
- #
- unless @pipeline.config_processor
- return save_pipeline!
- end
-
- ##
- # Skip creating pipeline object if there are no builds for it.
- #
- unless @pipeline.create_builds(user)
- @pipeline.errors.add(:base, 'No builds created')
- return false
- end
-
- save_pipeline!
- end
-
- private
-
- ##
- # Create a new pipeline and touch object to calculate status
- #
- def save_pipeline!
- @pipeline.save!
- @pipeline.touch
- @pipeline
- end
-end
diff --git a/app/services/create_spam_log_service.rb b/app/services/create_spam_log_service.rb
deleted file mode 100644
index 59a66fde47a..00000000000
--- a/app/services/create_spam_log_service.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-class CreateSpamLogService < BaseService
- def initialize(project, user, params)
- super(project, user, params)
- end
-
- def execute
- spam_params = params.merge({ user_id: @current_user.id,
- project_id: @project.id } )
- spam_log = SpamLog.new(spam_params)
- spam_log.save
- spam_log
- end
-end
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 87f066edb6f..918eddaa53a 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -39,7 +39,12 @@ class DeleteBranchService < BaseService
end
def build_push_data(branch)
- Gitlab::PushDataBuilder
- .build(project, current_user, branch.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}", [])
+ Gitlab::DataBuilder::Push.build(
+ project,
+ current_user,
+ branch.target.sha,
+ Gitlab::Git::BLANK_SHA,
+ "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}",
+ [])
end
end
diff --git a/app/services/delete_tag_service.rb b/app/services/delete_tag_service.rb
index 32e0eed6b63..d0cb151a010 100644
--- a/app/services/delete_tag_service.rb
+++ b/app/services/delete_tag_service.rb
@@ -33,7 +33,12 @@ class DeleteTagService < BaseService
end
def build_push_data(tag)
- Gitlab::PushDataBuilder
- .build(project, current_user, tag.target.sha, Gitlab::Git::BLANK_SHA, "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}", [])
+ Gitlab::DataBuilder::Push.build(
+ project,
+ current_user,
+ tag.target.sha,
+ Gitlab::Git::BLANK_SHA,
+ "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}",
+ [])
end
end
diff --git a/app/services/delete_user_service.rb b/app/services/delete_user_service.rb
index ce79287e35a..eaff88d6463 100644
--- a/app/services/delete_user_service.rb
+++ b/app/services/delete_user_service.rb
@@ -18,9 +18,14 @@ class DeleteUserService
user.personal_projects.each do |project|
# Skip repository removal because we remove directory with namespace
# that contain all this repositories
- ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete!
+ ::Projects::DestroyService.new(project, current_user, skip_repo: true).async_execute
end
- user.destroy
+ # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
+ namespace = user.namespace
+ user_data = user.destroy
+ namespace.really_destroy!
+
+ user_data
end
end
diff --git a/app/services/destroy_group_service.rb b/app/services/destroy_group_service.rb
index 3c42ac61be4..0081364b8aa 100644
--- a/app/services/destroy_group_service.rb
+++ b/app/services/destroy_group_service.rb
@@ -5,13 +5,23 @@ class DestroyGroupService
@group, @current_user = group, user
end
+ def async_execute
+ group.transaction do
+ # Soft delete via paranoia gem
+ group.destroy
+ job_id = GroupDestroyWorker.perform_async(group.id, current_user.id)
+ Rails.logger.info("User #{current_user.id} scheduled a deletion of group ID #{group.id} with job ID #{job_id}")
+ end
+ end
+
def execute
group.projects.each do |project|
+ # Execute the destruction of the models immediately to ensure atomic cleanup.
# Skip repository removal because we remove directory with namespace
- # that contain all this repositories
- ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete!
+ # that contain all these repositories
+ ::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
end
- group.destroy
+ group.really_destroy!
end
end
diff --git a/app/services/files/base_service.rb b/app/services/files/base_service.rb
index c4a206f785e..ea94818713b 100644
--- a/app/services/files/base_service.rb
+++ b/app/services/files/base_service.rb
@@ -15,6 +15,7 @@ module Files
else
params[:file_content]
end
+ @last_commit_sha = params[:last_commit_sha]
# Validate parameters
validate
diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb
index 8d2b5083179..4fc3b640799 100644
--- a/app/services/files/update_service.rb
+++ b/app/services/files/update_service.rb
@@ -2,11 +2,34 @@ require_relative "base_service"
module Files
class UpdateService < Files::BaseService
+ class FileChangedError < StandardError; end
+
def commit
repository.update_file(current_user, @file_path, @file_content,
branch: @target_branch,
previous_path: @previous_path,
message: @commit_message)
end
+
+ private
+
+ def validate
+ super
+
+ if file_has_changed?
+ raise FileChangedError.new("You are attempting to update a file that has changed since you started editing it.")
+ end
+ end
+
+ def file_has_changed?
+ return false unless @last_commit_sha && last_commit
+
+ @last_commit_sha != last_commit.sha
+ end
+
+ def last_commit
+ @last_commit ||= Gitlab::Git::Commit.
+ last_for_path(@source_project.repository, @source_branch, @file_path)
+ end
end
end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index 3f6a177bf3a..78feb37aa2a 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -69,7 +69,7 @@ class GitPushService < BaseService
SystemHooksService.new.execute_hooks(build_push_data_system_hook.dup, :push_hooks)
@project.execute_hooks(build_push_data.dup, :push_hooks)
@project.execute_services(build_push_data.dup, :push_hooks)
- CreateCommitBuildsService.new.execute(@project, current_user, build_push_data)
+ Ci::CreatePipelineService.new(project, current_user, build_push_data).execute
ProjectCacheWorker.perform_async(@project.id)
end
@@ -91,12 +91,12 @@ class GitPushService < BaseService
params = {
name: @project.default_branch,
- push_access_level_attributes: {
+ push_access_levels_attributes: [{
access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- },
- merge_access_level_attributes: {
+ }],
+ merge_access_levels_attributes: [{
access_level: current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MASTER
- }
+ }]
}
ProtectedBranches::CreateService.new(@project, current_user, params).execute
@@ -138,13 +138,23 @@ class GitPushService < BaseService
end
def build_push_data
- @push_data ||= Gitlab::PushDataBuilder.
- build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], push_commits)
+ @push_data ||= Gitlab::DataBuilder::Push.build(
+ @project,
+ current_user,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref],
+ push_commits)
end
def build_push_data_system_hook
- @push_data_system ||= Gitlab::PushDataBuilder.
- build(@project, current_user, params[:oldrev], params[:newrev], params[:ref], [])
+ @push_data_system ||= Gitlab::DataBuilder::Push.build(
+ @project,
+ current_user,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref],
+ [])
end
def push_to_existing_branch?
diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb
index 969530c4fdc..e6002b03b93 100644
--- a/app/services/git_tag_push_service.rb
+++ b/app/services/git_tag_push_service.rb
@@ -11,7 +11,7 @@ class GitTagPushService < BaseService
SystemHooksService.new.execute_hooks(build_system_push_data.dup, :tag_push_hooks)
project.execute_hooks(@push_data.dup, :tag_push_hooks)
project.execute_services(@push_data.dup, :tag_push_hooks)
- CreateCommitBuildsService.new.execute(project, current_user, @push_data)
+ Ci::CreatePipelineService.new(project, current_user, @push_data).execute
ProjectCacheWorker.perform_async(project.id)
true
@@ -34,12 +34,24 @@ class GitTagPushService < BaseService
end
end
- Gitlab::PushDataBuilder.
- build(project, current_user, params[:oldrev], params[:newrev], params[:ref], commits, message)
+ Gitlab::DataBuilder::Push.build(
+ project,
+ current_user,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref],
+ commits,
+ message)
end
def build_system_push_data
- Gitlab::PushDataBuilder.
- build(project, current_user, params[:oldrev], params[:newrev], params[:ref], [], '')
+ Gitlab::DataBuilder::Push.build(
+ project,
+ current_user,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref],
+ [],
+ '')
end
end
diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb
new file mode 100644
index 00000000000..b0e1799b489
--- /dev/null
+++ b/app/services/ham_service.rb
@@ -0,0 +1,26 @@
+class HamService
+ attr_accessor :spam_log
+
+ def initialize(spam_log)
+ @spam_log = spam_log
+ end
+
+ def mark_as_ham!
+ if akismet.submit_ham
+ spam_log.update_attribute(:submitted_as_ham, true)
+ else
+ false
+ end
+ end
+
+ private
+
+ def akismet
+ @akismet ||= AkismetService.new(
+ spam_log.user,
+ spam_log.text,
+ ip_address: spam_log.source_ip,
+ user_agent: spam_log.user_agent
+ )
+ end
+end
diff --git a/app/services/import_export_clean_up_service.rb b/app/services/import_export_clean_up_service.rb
new file mode 100644
index 00000000000..6442406d77e
--- /dev/null
+++ b/app/services/import_export_clean_up_service.rb
@@ -0,0 +1,24 @@
+class ImportExportCleanUpService
+ LAST_MODIFIED_TIME_IN_MINUTES = 1440
+
+ attr_reader :mmin, :path
+
+ def initialize(mmin = LAST_MODIFIED_TIME_IN_MINUTES)
+ @mmin = mmin
+ @path = Gitlab::ImportExport.storage_path
+ end
+
+ def execute
+ Gitlab::Metrics.measure(:import_export_clean_up) do
+ return unless File.directory?(path)
+
+ clean_up_export_files
+ end
+ end
+
+ private
+
+ def clean_up_export_files
+ Gitlab::Popen.popen(%W(find #{path} -not -path #{path} -mmin +#{mmin} -delete))
+ end
+end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 2d96efe1042..e06c37c323e 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -69,14 +69,9 @@ class IssuableBaseService < BaseService
end
def filter_labels
- if params[:add_label_ids].present? || params[:remove_label_ids].present?
- params.delete(:label_ids)
-
- filter_labels_in_param(:add_label_ids)
- filter_labels_in_param(:remove_label_ids)
- else
- filter_labels_in_param(:label_ids)
- end
+ filter_labels_in_param(:add_label_ids)
+ filter_labels_in_param(:remove_label_ids)
+ filter_labels_in_param(:label_ids)
end
def filter_labels_in_param(key)
@@ -85,30 +80,90 @@ class IssuableBaseService < BaseService
params[key] = project.labels.where(id: params[key]).pluck(:id)
end
- def update_issuable(issuable, attributes)
+ def process_label_ids(attributes, existing_label_ids: nil)
+ label_ids = attributes.delete(:label_ids)
+ add_label_ids = attributes.delete(:add_label_ids)
+ remove_label_ids = attributes.delete(:remove_label_ids)
+
+ new_label_ids = existing_label_ids || label_ids || []
+
+ if add_label_ids.blank? && remove_label_ids.blank?
+ new_label_ids = label_ids if label_ids
+ else
+ new_label_ids |= add_label_ids if add_label_ids
+ new_label_ids -= remove_label_ids if remove_label_ids
+ end
+
+ new_label_ids
+ end
+
+ def merge_slash_commands_into_params!(issuable)
+ description, command_params =
+ SlashCommands::InterpretService.new(project, current_user).
+ execute(params[:description], issuable)
+
+ params[:description] = description
+
+ params.merge!(command_params)
+ end
+
+ def create_issuable(issuable, attributes, label_ids:)
issuable.with_transaction_returning_status do
- add_label_ids = attributes.delete(:add_label_ids)
- remove_label_ids = attributes.delete(:remove_label_ids)
+ if issuable.save
+ issuable.update_attributes(label_ids: label_ids)
+ end
+ end
+ end
- issuable.label_ids |= add_label_ids if add_label_ids
- issuable.label_ids -= remove_label_ids if remove_label_ids
+ def create(issuable)
+ merge_slash_commands_into_params!(issuable)
+ filter_params
+
+ params.delete(:state_event)
+ params[:author] ||= current_user
+ label_ids = process_label_ids(params)
+
+ issuable.assign_attributes(params)
+
+ before_create(issuable)
+
+ if params.present? && create_issuable(issuable, params, label_ids: label_ids)
+ after_create(issuable)
+ issuable.create_cross_references!(current_user)
+ execute_hooks(issuable)
+ end
+
+ issuable
+ end
- issuable.assign_attributes(attributes.merge(updated_by: current_user))
+ def before_create(issuable)
+ # To be overridden by subclasses
+ end
+
+ def after_create(issuable)
+ # To be overridden by subclasses
+ end
- issuable.save
+ def update_issuable(issuable, attributes)
+ issuable.with_transaction_returning_status do
+ issuable.update(attributes.merge(updated_by: current_user))
end
end
def update(issuable)
change_state(issuable)
change_subscription(issuable)
+ change_todo(issuable)
filter_params
old_labels = issuable.labels.to_a
+ old_mentioned_users = issuable.mentioned_users.to_a
+
+ params[:label_ids] = process_label_ids(params, existing_label_ids: issuable.label_ids)
if params.present? && update_issuable(issuable, params)
issuable.reset_events_cache
handle_common_system_notes(issuable, old_labels: old_labels)
- handle_changes(issuable, old_labels: old_labels)
+ handle_changes(issuable, old_labels: old_labels, old_mentioned_users: old_mentioned_users)
issuable.create_new_cross_references!(current_user)
execute_hooks(issuable, 'update')
end
@@ -134,6 +189,16 @@ class IssuableBaseService < BaseService
end
end
+ def change_todo(issuable)
+ case params.delete(:todo_event)
+ when 'add'
+ todo_service.mark_todo(issuable, current_user)
+ when 'done'
+ todo = TodosFinder.new(current_user).execute.find_by(target: issuable)
+ todo_service.mark_todos_as_done([todo], current_user) if todo
+ end
+ end
+
def has_changes?(issuable, old_labels: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index 859c934ea3b..45cca216ccc 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -1,6 +1,8 @@
module Issues
class CloseService < Issues::BaseService
def execute(issue, commit: nil, notifications: true, system_note: true)
+ return issue unless can?(current_user, :update_issue, issue)
+
if project.jira_tracker? && project.jira_service.active
project.jira_service.execute(commit, issue)
todo_service.close_issue(issue, current_user)
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 5e2de2ccf64..ea1690f3e38 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -1,31 +1,33 @@
module Issues
class CreateService < Issues::BaseService
def execute
- filter_params
- label_params = params.delete(:label_ids)
- request = params.delete(:request)
- api = params.delete(:api)
- issue = project.issues.new(params)
- issue.author = params[:author] || current_user
+ @request = params.delete(:request)
+ @api = params.delete(:api)
- issue.spam = spam_check_service.execute(request, api)
+ @issue = project.issues.new
- if issue.save
- issue.update_attributes(label_ids: label_params)
- notification_service.new_issue(issue, current_user)
- todo_service.new_issue(issue, current_user)
- event_service.open_issue(issue, current_user)
- issue.create_cross_references!(current_user)
- execute_hooks(issue, 'open')
- end
+ create(@issue)
+ end
+
+ def before_create(issuable)
+ issuable.spam = spam_service.check(@api)
+ end
- issue
+ def after_create(issuable)
+ event_service.open_issue(issuable, current_user)
+ notification_service.new_issue(issuable, current_user)
+ todo_service.new_issue(issuable, current_user)
+ user_agent_detail_service.create
end
private
- def spam_check_service
- SpamCheckService.new(project, current_user, params)
+ def spam_service
+ SpamService.new(@issue, @request)
+ end
+
+ def user_agent_detail_service
+ UserAgentDetailService.new(@issue, @request)
end
end
end
diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb
index e48ca359f4f..40fbe354492 100644
--- a/app/services/issues/reopen_service.rb
+++ b/app/services/issues/reopen_service.rb
@@ -1,6 +1,8 @@
module Issues
class ReopenService < Issues::BaseService
def execute(issue)
+ return issue unless can?(current_user, :update_issue, issue)
+
if issue.reopen
event_service.reopen_issue(issue, current_user)
create_note(issue)
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index c7d406cc331..a2111b3806b 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -4,7 +4,7 @@ module Issues
update(issue)
end
- def handle_changes(issue, old_labels: [])
+ def handle_changes(issue, old_labels: [], old_mentioned_users: [])
if has_changes?(issue, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(issue, current_user)
end
@@ -32,6 +32,11 @@ module Issues
if added_labels.present?
notification_service.relabeled_issue(issue, added_labels, current_user)
end
+
+ added_mentions = issue.mentioned_users - old_mentioned_users
+ if added_mentions.present?
+ notification_service.new_mentions_in_issue(issue, added_mentions, current_user)
+ end
end
def reopen_service
diff --git a/app/services/members/authorized_destroy_service.rb b/app/services/members/authorized_destroy_service.rb
new file mode 100644
index 00000000000..ca9db59cac7
--- /dev/null
+++ b/app/services/members/authorized_destroy_service.rb
@@ -0,0 +1,19 @@
+module Members
+ class AuthorizedDestroyService < BaseService
+ attr_accessor :member, :user
+
+ def initialize(member, user = nil)
+ @member, @user = member, user
+ end
+
+ def execute
+ return false if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
+
+ member.destroy
+
+ if member.request? && member.user != user
+ notification_service.decline_access_request(member)
+ end
+ end
+ end
+end
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index 15358f80208..9a2bf82ef51 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -2,20 +2,16 @@ module Members
class DestroyService < BaseService
attr_accessor :member, :current_user
- def initialize(member, user)
- @member, @current_user = member, user
+ def initialize(member, current_user)
+ @member = member
+ @current_user = current_user
end
def execute
unless member && can?(current_user, "destroy_#{member.type.underscore}".to_sym, member)
raise Gitlab::Access::AccessDeniedError
end
-
- member.destroy
-
- if member.request? && member.user != current_user
- notification_service.decline_access_request(member)
- end
+ AuthorizedDestroyService.new(member, current_user).execute
end
end
end
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index 7fe57747265..290742f1506 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -34,7 +34,7 @@ module MergeRequests
# At this point we decide if merge request can be created
# If we have at least one commit to merge -> creation allowed
if commits.present?
- merge_request.compare_commits = Commit.decorate(commits, merge_request.source_project)
+ merge_request.compare_commits = commits
merge_request.can_be_created = true
merge_request.compare = compare
else
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index 27ee81fe3e7..f2053bda83a 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -1,6 +1,8 @@
module MergeRequests
class CloseService < MergeRequests::BaseService
def execute(merge_request, commit = nil)
+ return merge_request unless can?(current_user, :update_merge_request, merge_request)
+
# If we close MergeRequest we want to ignore validation
# so we can close broken one (Ex. fork project removed)
merge_request.allow_broken = true
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 96a25330af1..73247e62421 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -7,26 +7,19 @@ module MergeRequests
source_project = @project
@project = Project.find(params[:target_project_id]) if params[:target_project_id]
- filter_params
- label_params = params.delete(:label_ids)
- force_remove_source_branch = params.delete(:force_remove_source_branch)
+ params[:target_project_id] ||= source_project.id
- merge_request = MergeRequest.new(params)
+ merge_request = MergeRequest.new
merge_request.source_project = source_project
- merge_request.target_project ||= source_project
- merge_request.author = current_user
- merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch
+ merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
- if merge_request.save
- merge_request.update_attributes(label_ids: label_params)
- event_service.open_mr(merge_request, current_user)
- notification_service.new_merge_request(merge_request, current_user)
- todo_service.new_merge_request(merge_request, current_user)
- merge_request.create_cross_references!(current_user)
- execute_hooks(merge_request)
- end
+ create(merge_request)
+ end
- merge_request
+ def after_create(issuable)
+ event_service.open_mr(issuable, current_user)
+ notification_service.new_merge_request(issuable, current_user)
+ todo_service.new_merge_request(issuable, current_user)
end
end
end
diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb
new file mode 100644
index 00000000000..08c1f72d65a
--- /dev/null
+++ b/app/services/merge_requests/get_urls_service.rb
@@ -0,0 +1,63 @@
+module MergeRequests
+ class GetUrlsService < BaseService
+ attr_reader :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ def execute(changes)
+ branches = get_branches(changes)
+ merge_requests_map = opened_merge_requests_from_source_branches(branches)
+ branches.map do |branch|
+ existing_merge_request = merge_requests_map[branch]
+ if existing_merge_request
+ url_for_existing_merge_request(existing_merge_request)
+ else
+ url_for_new_merge_request(branch)
+ end
+ end
+ end
+
+ private
+
+ def opened_merge_requests_from_source_branches(branches)
+ merge_requests = MergeRequest.from_project(project).opened.from_source_branches(branches)
+ merge_requests.inject({}) do |hash, mr|
+ hash[mr.source_branch] = mr
+ hash
+ end
+ end
+
+ def get_branches(changes)
+ return [] if project.empty_repo?
+ return [] unless project.merge_requests_enabled
+
+ changes_list = Gitlab::ChangesList.new(changes)
+ changes_list.map do |change|
+ next unless Gitlab::Git.branch_ref?(change[:ref])
+
+ # Deleted branch
+ next if Gitlab::Git.blank_ref?(change[:newrev])
+
+ # Default branch
+ branch_name = Gitlab::Git.branch_name(change[:ref])
+ next if branch_name == project.default_branch
+
+ branch_name
+ end.compact
+ end
+
+ def url_for_new_merge_request(branch_name)
+ merge_request_params = { source_branch: branch_name }
+ url = Gitlab::Routing.url_helpers.new_namespace_project_merge_request_url(project.namespace, project, merge_request: merge_request_params)
+ { branch_name: branch_name, url: url, new_merge_request: true }
+ end
+
+ def url_for_existing_merge_request(merge_request)
+ target_project = merge_request.target_project
+ url = Gitlab::Routing.url_helpers.namespace_project_merge_request_url(target_project.namespace, target_project, merge_request)
+ { branch_name: merge_request.source_branch, url: url, new_merge_request: false }
+ end
+ end
+end
diff --git a/app/services/merge_requests/merge_request_diff_cache_service.rb b/app/services/merge_requests/merge_request_diff_cache_service.rb
new file mode 100644
index 00000000000..2945a7fd4e4
--- /dev/null
+++ b/app/services/merge_requests/merge_request_diff_cache_service.rb
@@ -0,0 +1,8 @@
+module MergeRequests
+ class MergeRequestDiffCacheService
+ def execute(merge_request)
+ # Executing the iteration we cache all the highlighted diff information
+ merge_request.diffs.diff_files.to_a
+ end
+ end
+end
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index eb88ae9d11c..fadcce5d9b6 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -1,6 +1,8 @@
module MergeRequests
class ReopenService < MergeRequests::BaseService
def execute(merge_request)
+ return merge_request unless can?(current_user, :update_merge_request, merge_request)
+
if merge_request.reopen
event_service.reopen_mr(merge_request, current_user)
create_note(merge_request)
diff --git a/app/services/merge_requests/resolve_service.rb b/app/services/merge_requests/resolve_service.rb
new file mode 100644
index 00000000000..adc71b0c2bc
--- /dev/null
+++ b/app/services/merge_requests/resolve_service.rb
@@ -0,0 +1,31 @@
+module MergeRequests
+ class ResolveService < MergeRequests::BaseService
+ attr_accessor :conflicts, :rugged, :merge_index
+
+ def execute(merge_request)
+ @conflicts = merge_request.conflicts
+ @rugged = project.repository.rugged
+ @merge_index = conflicts.merge_index
+
+ conflicts.files.each do |file|
+ write_resolved_file_to_index(file, params[:sections])
+ end
+
+ commit_params = {
+ message: params[:commit_message] || conflicts.default_commit_message,
+ parents: [conflicts.our_commit, conflicts.their_commit].map(&:oid),
+ tree: merge_index.write_tree(rugged)
+ }
+
+ project.repository.resolve_conflicts(current_user, merge_request.source_branch, commit_params)
+ end
+
+ def write_resolved_file_to_index(file, resolutions)
+ new_file = file.resolve_lines(resolutions).map(&:text).join("\n")
+ our_path = file.our_path
+
+ merge_index.add(path: our_path, oid: rugged.write(new_file, :blob), mode: file.our_mode)
+ merge_index.conflict_remove(our_path)
+ end
+ end
+end
diff --git a/app/services/merge_requests/resolved_discussion_notification_service.rb b/app/services/merge_requests/resolved_discussion_notification_service.rb
new file mode 100644
index 00000000000..3a09350c847
--- /dev/null
+++ b/app/services/merge_requests/resolved_discussion_notification_service.rb
@@ -0,0 +1,10 @@
+module MergeRequests
+ class ResolvedDiscussionNotificationService < MergeRequests::BaseService
+ def execute(merge_request)
+ return unless merge_request.discussions_resolved?
+
+ SystemNoteService.resolve_all_discussions(merge_request, project, current_user)
+ notification_service.resolve_all_discussions(merge_request, current_user)
+ end
+ end
+end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 026a37997d4..30c5f24988c 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -16,7 +16,7 @@ module MergeRequests
update(merge_request)
end
- def handle_changes(merge_request, old_labels: [])
+ def handle_changes(merge_request, old_labels: [], old_mentioned_users: [])
if has_changes?(merge_request, old_labels: old_labels)
todo_service.mark_pending_todos_as_done(merge_request, current_user)
end
@@ -55,6 +55,15 @@ module MergeRequests
current_user
)
end
+
+ added_mentions = merge_request.mentioned_users - old_mentioned_users
+ if added_mentions.present?
+ notification_service.new_mentions_in_merge_request(
+ merge_request,
+ added_mentions,
+ current_user
+ )
+ end
end
def reopen_service
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 18971bd0be3..a36008c3ef5 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -11,10 +11,33 @@ module Notes
return noteable.create_award_emoji(note.award_emoji_name, current_user)
end
- if note.save
+ # We execute commands (extracted from `params[:note]`) on the noteable
+ # **before** we save the note because if the note consists of commands
+ # only, there is no need be create a note!
+ slash_commands_service = SlashCommandsService.new(project, current_user)
+
+ if slash_commands_service.supported?(note)
+ content, command_params = slash_commands_service.extract_commands(note)
+
+ only_commands = content.empty?
+
+ note.note = content
+ end
+
+ if !only_commands && note.save
# Finish the harder work in the background
NewNoteWorker.perform_in(2.seconds, note.id, params)
- TodoService.new.new_note(note, current_user)
+ todo_service.new_note(note, current_user)
+ end
+
+ if command_params && command_params.any?
+ slash_commands_service.execute(command_params, note)
+
+ # We must add the error after we call #save because errors are reset
+ # when #save is called
+ if only_commands
+ note.errors.add(:commands_only, 'Your commands have been executed!')
+ end
end
note
diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb
index 534c48aefff..e4cd3fc7833 100644
--- a/app/services/notes/post_process_service.rb
+++ b/app/services/notes/post_process_service.rb
@@ -16,7 +16,7 @@ module Notes
end
def hook_data
- Gitlab::NoteDataBuilder.build(@note, @note.author)
+ Gitlab::DataBuilder::Note.build(@note, @note.author)
end
def execute_note_hooks
diff --git a/app/services/notes/slash_commands_service.rb b/app/services/notes/slash_commands_service.rb
new file mode 100644
index 00000000000..4a9a8a64653
--- /dev/null
+++ b/app/services/notes/slash_commands_service.rb
@@ -0,0 +1,33 @@
+module Notes
+ class SlashCommandsService < BaseService
+ UPDATE_SERVICES = {
+ 'Issue' => Issues::UpdateService,
+ 'MergeRequest' => MergeRequests::UpdateService
+ }
+
+ def supported?(note)
+ noteable_update_service(note) &&
+ can?(current_user, :"update_#{note.noteable_type.underscore}", note.noteable)
+ end
+
+ def extract_commands(note)
+ return [note.note, {}] unless supported?(note)
+
+ SlashCommands::InterpretService.new(project, current_user).
+ execute(note.note, note.noteable)
+ end
+
+ def execute(command_params, note)
+ return if command_params.empty?
+ return unless supported?(note)
+
+ noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable)
+ end
+
+ private
+
+ def noteable_update_service(note)
+ UPDATE_SERVICES[note.noteable_type]
+ end
+ end
+end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index ab6e51209ee..6139ed56e25 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -35,6 +35,20 @@ class NotificationService
new_resource_email(issue, issue.project, :new_issue_email)
end
+ # When issue text is updated, we should send an email to:
+ #
+ # * newly mentioned project team members with notification level higher than Participating
+ #
+ def new_mentions_in_issue(issue, new_mentioned_users, current_user)
+ new_mentions_in_resource_email(
+ issue,
+ issue.project,
+ new_mentioned_users,
+ current_user,
+ :new_mention_in_issue_email
+ )
+ end
+
# When we close an issue we should send an email to:
#
# * issue author if their notification level is not Disabled
@@ -75,6 +89,20 @@ class NotificationService
new_resource_email(merge_request, merge_request.target_project, :new_merge_request_email)
end
+ # When merge request text is updated, we should send an email to:
+ #
+ # * newly mentioned project team members with notification level higher than Participating
+ #
+ def new_mentions_in_merge_request(merge_request, new_mentioned_users, current_user)
+ new_mentions_in_resource_email(
+ merge_request,
+ merge_request.target_project,
+ new_mentioned_users,
+ current_user,
+ :new_mention_in_merge_request_email
+ )
+ end
+
# When we reassign a merge_request we should send an email to:
#
# * merge_request old assignee if their notification level is not Disabled
@@ -120,6 +148,14 @@ class NotificationService
)
end
+ def resolve_all_discussions(merge_request, current_user)
+ recipients = build_recipients(merge_request, merge_request.target_project, current_user, action: "resolve_all_discussions")
+
+ recipients.each do |recipient|
+ mailer.resolved_all_discussions_email(recipient.id, merge_request.id, current_user.id).deliver_later
+ end
+ end
+
# Notify new user with email after creation
def new_user(user, token = nil)
# Don't email omniauth created users
@@ -177,7 +213,7 @@ class NotificationService
# build notify method like 'note_commit_email'
notify_method = "note_#{note.noteable_type.underscore}_email".to_sym
-
+
recipients.each do |recipient|
mailer.send(notify_method, recipient.id, note.id).deliver_later
end
@@ -206,7 +242,6 @@ class NotificationService
project_member.real_source_type,
project_member.project.id,
project_member.invite_email,
- project_member.access_level,
project_member.created_by_id
).deliver_later
end
@@ -233,7 +268,6 @@ class NotificationService
group_member.real_source_type,
group_member.group.id,
group_member.invite_email,
- group_member.access_level,
group_member.created_by_id
).deliver_later
end
@@ -471,6 +505,15 @@ class NotificationService
end
end
+ def new_mentions_in_resource_email(target, project, new_mentioned_users, current_user, method)
+ recipients = build_recipients(target, project, current_user, action: "new")
+ recipients = recipients & new_mentioned_users
+
+ recipients.each do |recipient|
+ mailer.send(method, recipient.id, target.id, current_user.id).deliver_later
+ end
+ end
+
def close_resource_email(target, project, current_user, method)
action = method == :merged_merge_request_email ? "merge" : "close"
recipients = build_recipients(target, project, current_user, action: action)
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index 23b6668e0d1..f578f8dbea2 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -1,7 +1,7 @@
module Projects
class AutocompleteService < BaseService
def issues
- @project.issues.visible_to_user(current_user).opened.select([:iid, :title])
+ IssuesFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end
def milestones
@@ -9,11 +9,34 @@ module Projects
end
def merge_requests
- @project.merge_requests.opened.select([:iid, :title])
+ MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end
def labels
@project.labels.select([:title, :color])
end
+
+ def commands(noteable, type)
+ noteable ||=
+ case type
+ when 'Issue'
+ @project.issues.build
+ when 'MergeRequest'
+ @project.merge_requests.build
+ end
+
+ return [] unless noteable && noteable.is_a?(Issuable)
+
+ opts = {
+ project: project,
+ issuable: noteable,
+ current_user: current_user
+ }
+ SlashCommands::InterpretService.command_definitions.map do |definition|
+ next unless definition.available?(opts)
+
+ definition.to_h(opts)
+ end.compact
+ end
end
end
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 882606e38d0..8a53f65aec1 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -6,8 +6,12 @@ module Projects
DELETED_FLAG = '+deleted'
- def pending_delete!
- project.schedule_delete!(current_user.id, params)
+ def async_execute
+ project.transaction do
+ project.update_attribute(:pending_delete, true)
+ job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
+ Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}")
+ end
end
def execute
diff --git a/app/services/projects/enable_deploy_key_service.rb b/app/services/projects/enable_deploy_key_service.rb
new file mode 100644
index 00000000000..3cf4264ce9b
--- /dev/null
+++ b/app/services/projects/enable_deploy_key_service.rb
@@ -0,0 +1,17 @@
+module Projects
+ class EnableDeployKeyService < BaseService
+ def execute
+ key = accessible_keys.find_by(id: params[:key_id] || params[:id])
+ return unless key
+
+ project.deploy_keys << key
+ key
+ end
+
+ private
+
+ def accessible_keys
+ current_user.accessible_deploy_keys
+ end
+ end
+end
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index 02c4eee3d02..d38328403c1 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -1,40 +1,28 @@
module Projects
class ParticipantsService < BaseService
- def execute(noteable_type, noteable_id)
- @noteable_type = noteable_type
- @noteable_id = noteable_id
+ attr_reader :noteable
+
+ def execute(noteable)
+ @noteable = noteable
+
project_members = sorted(project.team.members)
- participants = target_owner + participants_in_target + all_members + groups + project_members
+ participants = noteable_owner + participants_in_noteable + all_members + groups + project_members
participants.uniq
end
- def target
- @target ||=
- case @noteable_type
- when "Issue"
- project.issues.find_by_iid(@noteable_id)
- when "MergeRequest"
- project.merge_requests.find_by_iid(@noteable_id)
- when "Commit"
- project.commit(@noteable_id)
- else
- nil
- end
- end
-
- def target_owner
- return [] unless target && target.author.present?
+ def noteable_owner
+ return [] unless noteable && noteable.author.present?
[{
- name: target.author.name,
- username: target.author.username
+ name: noteable.author.name,
+ username: noteable.author.username
}]
end
- def participants_in_target
- return [] unless target
+ def participants_in_noteable
+ return [] unless noteable
- users = target.participants(current_user)
+ users = noteable.participants(current_user)
sorted(users)
end
diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb
index 6150a2a83c9..a84e335340d 100644
--- a/app/services/protected_branches/create_service.rb
+++ b/app/services/protected_branches/create_service.rb
@@ -5,23 +5,7 @@ module ProtectedBranches
def execute
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
- protected_branch = project.protected_branches.new(params)
-
- ProtectedBranch.transaction do
- protected_branch.save!
-
- if protected_branch.push_access_level.blank?
- protected_branch.create_push_access_level!(access_level: Gitlab::Access::MASTER)
- end
-
- if protected_branch.merge_access_level.blank?
- protected_branch.create_merge_access_level!(access_level: Gitlab::Access::MASTER)
- end
- end
-
- protected_branch
- rescue ActiveRecord::RecordInvalid
- protected_branch
+ project.protected_branches.create(params)
end
end
end
diff --git a/app/services/repository_archive_clean_up_service.rb b/app/services/repository_archive_clean_up_service.rb
index 0b56b09738d..aa84d36a206 100644
--- a/app/services/repository_archive_clean_up_service.rb
+++ b/app/services/repository_archive_clean_up_service.rb
@@ -1,6 +1,8 @@
class RepositoryArchiveCleanUpService
LAST_MODIFIED_TIME_IN_MINUTES = 120
+ attr_reader :mmin, :path
+
def initialize(mmin = LAST_MODIFIED_TIME_IN_MINUTES)
@mmin = mmin
@path = Gitlab.config.gitlab.repository_downloads_path
@@ -17,8 +19,6 @@ class RepositoryArchiveCleanUpService
private
- attr_reader :mmin, :path
-
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))
end
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
new file mode 100644
index 00000000000..9ac1124abc1
--- /dev/null
+++ b/app/services/slash_commands/interpret_service.rb
@@ -0,0 +1,236 @@
+module SlashCommands
+ class InterpretService < BaseService
+ include Gitlab::SlashCommands::Dsl
+
+ attr_reader :issuable
+
+ # Takes a text and interprets the commands that are extracted from it.
+ # Returns the content without commands, and hash of changes to be applied to a record.
+ def execute(content, issuable)
+ @issuable = issuable
+ @updates = {}
+
+ opts = {
+ issuable: issuable,
+ current_user: current_user,
+ project: project
+ }
+
+ content, commands = extractor.extract_commands(content, opts)
+
+ commands.each do |name, arg|
+ definition = self.class.command_definitions_by_name[name.to_sym]
+ next unless definition
+
+ definition.execute(self, opts, arg)
+ end
+
+ [content, @updates]
+ end
+
+ private
+
+ def extractor
+ Gitlab::SlashCommands::Extractor.new(self.class.command_definitions)
+ end
+
+ desc do
+ "Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
+ end
+ condition do
+ issuable.persisted? &&
+ issuable.open? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :close do
+ @updates[:state_event] = 'close'
+ end
+
+ desc do
+ "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
+ end
+ condition do
+ issuable.persisted? &&
+ issuable.closed? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :reopen do
+ @updates[:state_event] = 'reopen'
+ end
+
+ desc 'Change title'
+ params '<New title>'
+ condition do
+ issuable.persisted? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :title do |title_param|
+ @updates[:title] = title_param
+ end
+
+ desc 'Assign'
+ params '@user'
+ condition do
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :assign do |assignee_param|
+ user = extract_references(assignee_param, :user).first
+ user ||= User.find_by(username: assignee_param)
+
+ @updates[:assignee_id] = user.id if user
+ end
+
+ desc 'Remove assignee'
+ condition do
+ issuable.persisted? &&
+ issuable.assignee_id? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :unassign do
+ @updates[:assignee_id] = nil
+ end
+
+ desc 'Set milestone'
+ params '%"milestone"'
+ condition do
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
+ project.milestones.active.any?
+ end
+ command :milestone do |milestone_param|
+ milestone = extract_references(milestone_param, :milestone).first
+ milestone ||= project.milestones.find_by(title: milestone_param.strip)
+
+ @updates[:milestone_id] = milestone.id if milestone
+ end
+
+ desc 'Remove milestone'
+ condition do
+ issuable.persisted? &&
+ issuable.milestone_id? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :remove_milestone do
+ @updates[:milestone_id] = nil
+ end
+
+ desc 'Add label(s)'
+ params '~label1 ~"label 2"'
+ condition do
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
+ project.labels.any?
+ end
+ command :label do |labels_param|
+ label_ids = find_label_ids(labels_param)
+
+ @updates[:add_label_ids] = label_ids unless label_ids.empty?
+ end
+
+ desc 'Remove all or specific label(s)'
+ params '~label1 ~"label 2"'
+ condition do
+ issuable.persisted? &&
+ issuable.labels.any? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :unlabel do |labels_param = nil|
+ if labels_param.present?
+ label_ids = find_label_ids(labels_param)
+
+ @updates[:remove_label_ids] = label_ids unless label_ids.empty?
+ else
+ @updates[:label_ids] = []
+ end
+ end
+
+ desc 'Replace all label(s)'
+ params '~label1 ~"label 2"'
+ condition do
+ issuable.persisted? &&
+ issuable.labels.any? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :relabel do |labels_param|
+ label_ids = find_label_ids(labels_param)
+
+ @updates[:label_ids] = label_ids unless label_ids.empty?
+ end
+
+ desc 'Add a todo'
+ condition do
+ issuable.persisted? &&
+ !TodoService.new.todo_exist?(issuable, current_user)
+ end
+ command :todo do
+ @updates[:todo_event] = 'add'
+ end
+
+ desc 'Mark todo as done'
+ condition do
+ issuable.persisted? &&
+ TodoService.new.todo_exist?(issuable, current_user)
+ end
+ command :done do
+ @updates[:todo_event] = 'done'
+ end
+
+ desc 'Subscribe'
+ condition do
+ issuable.persisted? &&
+ !issuable.subscribed?(current_user)
+ end
+ command :subscribe do
+ @updates[:subscription_event] = 'subscribe'
+ end
+
+ desc 'Unsubscribe'
+ condition do
+ issuable.persisted? &&
+ issuable.subscribed?(current_user)
+ end
+ command :unsubscribe do
+ @updates[:subscription_event] = 'unsubscribe'
+ end
+
+ desc 'Set due date'
+ params '<in 2 days | this Friday | December 31st>'
+ condition do
+ issuable.respond_to?(:due_date) &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :due do |due_date_param|
+ due_date = Chronic.parse(due_date_param).try(:to_date)
+
+ @updates[:due_date] = due_date if due_date
+ end
+
+ desc 'Remove due date'
+ condition do
+ issuable.persisted? &&
+ issuable.respond_to?(:due_date) &&
+ issuable.due_date? &&
+ current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
+ end
+ command :remove_due_date do
+ @updates[:due_date] = nil
+ end
+
+ # This is a dummy command, so that it appears in the autocomplete commands
+ desc 'CC'
+ params '@user'
+ command :cc
+
+ def find_label_ids(labels_param)
+ label_ids_by_reference = extract_references(labels_param, :label).map(&:id)
+ labels_ids_by_name = @project.labels.where(name: labels_param.split).select(:id)
+
+ label_ids_by_reference | labels_ids_by_name
+ end
+
+ def extract_references(arg, type)
+ ext = Gitlab::ReferenceExtractor.new(project, current_user)
+ ext.analyze(arg, author: current_user)
+
+ ext.references(type)
+ end
+ end
+end
diff --git a/app/services/spam_check_service.rb b/app/services/spam_check_service.rb
deleted file mode 100644
index 7c3e692bde9..00000000000
--- a/app/services/spam_check_service.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-class SpamCheckService < BaseService
- include Gitlab::AkismetHelper
-
- attr_accessor :request, :api
-
- def execute(request, api)
- @request, @api = request, api
- return false unless request || check_for_spam?(project)
- return false unless is_spam?(request.env, current_user, text)
-
- create_spam_log
-
- true
- end
-
- private
-
- def text
- [params[:title], params[:description]].reject(&:blank?).join("\n")
- end
-
- def spam_log_attrs
- {
- user_id: current_user.id,
- project_id: project.id,
- title: params[:title],
- description: params[:description],
- source_ip: client_ip(request.env),
- user_agent: user_agent(request.env),
- noteable_type: 'Issue',
- via_api: api
- }
- end
-
- def create_spam_log
- CreateSpamLogService.new(project, current_user, spam_log_attrs).execute
- end
-end
diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb
new file mode 100644
index 00000000000..48903291799
--- /dev/null
+++ b/app/services/spam_service.rb
@@ -0,0 +1,78 @@
+class SpamService
+ attr_accessor :spammable, :request, :options
+
+ def initialize(spammable, request = nil)
+ @spammable = spammable
+ @request = request
+ @options = {}
+
+ if @request
+ @options[:ip_address] = @request.env['action_dispatch.remote_ip'].to_s
+ @options[:user_agent] = @request.env['HTTP_USER_AGENT']
+ @options[:referrer] = @request.env['HTTP_REFERRER']
+ else
+ @options[:ip_address] = @spammable.ip_address
+ @options[:user_agent] = @spammable.user_agent
+ end
+ end
+
+ def check(api = false)
+ return false unless request && check_for_spam?
+
+ return false unless akismet.is_spam?
+
+ create_spam_log(api)
+ true
+ end
+
+ def mark_as_spam!
+ return false unless spammable.submittable_as_spam?
+
+ if akismet.submit_spam
+ spammable.user_agent_detail.update_attribute(:submitted, true)
+ else
+ false
+ end
+ end
+
+ private
+
+ def akismet
+ @akismet ||= AkismetService.new(
+ spammable_owner,
+ spammable.spammable_text,
+ options
+ )
+ end
+
+ def spammable_owner
+ @user ||= User.find(spammable_owner_id)
+ end
+
+ def spammable_owner_id
+ @owner_id ||=
+ if spammable.respond_to?(:author_id)
+ spammable.author_id
+ elsif spammable.respond_to?(:creator_id)
+ spammable.creator_id
+ end
+ end
+
+ def check_for_spam?
+ spammable.check_for_spam?
+ end
+
+ def create_spam_log(api)
+ SpamLog.create(
+ {
+ user_id: spammable_owner_id,
+ title: spammable.spam_title,
+ description: spammable.spam_description,
+ source_ip: options[:ip_address],
+ user_agent: options[:user_agent],
+ noteable_type: spammable.class.to_s,
+ via_api: api
+ }
+ )
+ end
+end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index e13dc9265b8..546a8f11330 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -158,6 +158,12 @@ module SystemNoteService
create_note(noteable: noteable, project: project, author: author, note: body)
end
+ def self.resolve_all_discussions(merge_request, project, author)
+ body = "Resolved all discussions"
+
+ create_note(noteable: merge_request, project: project, author: author, note: body)
+ end
+
# Called when the title of a Noteable is changed
#
# noteable - Noteable object that responds to `title`
diff --git a/app/services/test_hook_service.rb b/app/services/test_hook_service.rb
index e85e58751e7..280c81f7d2d 100644
--- a/app/services/test_hook_service.rb
+++ b/app/services/test_hook_service.rb
@@ -1,6 +1,6 @@
class TestHookService
def execute(hook, current_user)
- data = Gitlab::PushDataBuilder.build_sample(hook.project, current_user)
+ data = Gitlab::DataBuilder::Push.build_sample(hook.project, current_user)
hook.execute(data, 'push_hooks')
end
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index 6b48d68cccb..e0ccb654590 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -142,10 +142,15 @@ class TodoService
# When user marks some todos as done
def mark_todos_as_done(todos, current_user)
- todos = current_user.todos.where(id: todos.map(&:id)) unless todos.respond_to?(:update_all)
+ mark_todos_as_done_by_ids(todos.select(&:id), current_user)
+ end
+
+ def mark_todos_as_done_by_ids(ids, current_user)
+ todos = current_user.todos.where(id: ids)
- todos.update_all(state: :done)
+ marked_todos = todos.update_all(state: :done)
current_user.update_todos_count_cache
+ marked_todos
end
# When user marks an issue as todo
@@ -154,6 +159,10 @@ class TodoService
create_todos(current_user, attributes)
end
+ def todo_exist?(issuable, current_user)
+ TodosFinder.new(current_user).execute.exists?(target: issuable)
+ end
+
private
def create_todos(users, attributes)
diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb
new file mode 100644
index 00000000000..a1ee3df5fe1
--- /dev/null
+++ b/app/services/user_agent_detail_service.rb
@@ -0,0 +1,13 @@
+class UserAgentDetailService
+ attr_accessor :spammable, :request
+
+ def initialize(spammable, request)
+ @spammable, @request = spammable, request
+ end
+
+ def create
+ return unless request
+
+ spammable.create_user_agent_detail(user_agent: request.env['HTTP_USER_AGENT'], ip_address: request.env['action_dispatch.remote_ip'].to_s)
+ end
+end
diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml
index dd2e7ebd030..56bf6194914 100644
--- a/app/views/admin/abuse_reports/_abuse_report.html.haml
+++ b/app/views/admin/abuse_reports/_abuse_report.html.haml
@@ -1,6 +1,8 @@
- reporter = abuse_report.reporter
- user = abuse_report.user
%tr
+ %th.visible-xs-block.visible-sm-block
+ %strong User
%td
- if user
= link_to user.name, user
@@ -9,6 +11,7 @@
- else
(removed)
%td
+ %strong.subheading.visible-xs-block.visible-sm-block Reported by
- if reporter
= link_to reporter.name, reporter
- else
@@ -16,16 +19,16 @@
.light.small
= time_ago_with_tooltip(abuse_report.created_at)
%td
- = markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter)
+ %strong.subheading.visible-xs-block.visible-sm-block Message
+ .message
+ = markdown(abuse_report.message.squish!, pipeline: :single_line, author: reporter)
%td
- if user
= link_to 'Remove user & report', admin_abuse_report_path(abuse_report, remove_user: true),
- data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-xs btn-remove js-remove-tr"
-
- %td
+ data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, remote: true, method: :delete, class: "btn btn-sm btn-block btn-remove js-remove-tr"
- if user && !user.blocked?
- = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs"
+ = link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm btn-block"
- else
- .btn.btn-xs.disabled
+ .btn.btn-sm.disabled.btn-block
Already Blocked
- = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-xs btn-close js-remove-tr"
+ = link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr"
diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml
index bc4a9cedb2c..7bbc75db9ff 100644
--- a/app/views/admin/abuse_reports/index.html.haml
+++ b/app/views/admin/abuse_reports/index.html.haml
@@ -1,17 +1,20 @@
-- page_title "Abuse Reports"
+- page_title 'Abuse Reports'
%h3.page-title Abuse Reports
%hr
-- if @abuse_reports.present?
- .table-holder
- %table.table
- %thead
- %tr
- %th User
- %th Reported by
- %th Message
- %th Primary action
- %th
- = render @abuse_reports
- = paginate @abuse_reports
-- else
- %h4 There are no abuse reports
+.abuse-reports
+ - if @abuse_reports.present?
+ .table-holder
+ %table.table
+ %thead.hidden-sm.hidden-xs
+ %tr
+ %th User
+ %th Reported by
+ %th.wide Message
+ %th Action
+ = render @abuse_reports
+ - else
+ .no-reports
+ %span.pull-left
+ There are no abuse reports!
+ .pull-left
+ = emoji_icon 'tada'
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index 23b52d08df7..d929364fc96 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -228,6 +228,9 @@
= f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :max_artifacts_size, class: 'form-control'
+ .help-block
+ Set the maximum file size each build's artifacts can have
+ = link_to "(?)", help_page_path("user/admin_area/settings/continuous_integration", anchor: "maximum-artifacts-size")
- if Gitlab.config.registry.enabled
%fieldset
@@ -363,7 +366,9 @@
.col-sm-10
= f.select :repository_storage, repository_storage_options_for_select, {}, class: 'form-control'
.help-block
- You can manage the repository storage paths in your gitlab.yml configuration file
+ Manage repository storage paths. Learn more in the
+ = succeed "." do
+ = link_to "repository storages documentation", help_page_path("administration/repository_storages")
%fieldset
%legend Repository Checks
@@ -383,6 +388,25 @@
.help-block
If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.
+ %fieldset
+ %legend Koding
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.label :koding_enabled do
+ = f.check_box :koding_enabled
+ Enable Koding
+ .form-group
+ = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2'
+ .col-sm-10
+ = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090'
+ .help-block
+ Koding has integration enabled out of the box for the
+ %strong gitlab
+ team, and you need to provide that team's URL here. Learn more in the
+ = succeed "." do
+ = link_to "Koding administration documentation", help_page_path("administration/integration/koding")
+
.form-actions
- = f.submit 'Save', class: 'btn btn-save' \ No newline at end of file
+ = f.submit 'Save', class: 'btn btn-save'
diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml
index 352adbedee4..f29d9c94441 100644
--- a/app/views/admin/builds/_build.html.haml
+++ b/app/views/admin/builds/_build.html.haml
@@ -51,7 +51,7 @@
- if build.duration
%p.duration
= custom_icon("icon_timer")
- = duration_in_numbers(build.finished_at, build.started_at)
+ = duration_in_numbers(build.duration)
- if build.finished_at
%p.finished-at
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 452fc25ab07..e6687f43816 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -112,7 +112,7 @@
%h4 Projects
.data
= link_to admin_namespaces_projects_path do
- %h1= number_with_delimiter(Project.count)
+ %h1= number_with_delimiter(Project.cached_count)
%hr
= link_to('New Project', new_project_path, class: "btn btn-new")
.col-sm-4
diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml
index 448aa953548..602cfa9b6fc 100644
--- a/app/views/admin/labels/_form.html.haml
+++ b/app/views/admin/labels/_form.html.haml
@@ -28,6 +28,3 @@
.form-actions
= f.submit 'Save', class: 'btn btn-save js-save-button'
= link_to "Cancel", admin_labels_path, class: 'btn btn-cancel'
-
-:javascript
- new Labels();
diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml
index 8aea67f4497..4ce4eab8753 100644
--- a/app/views/admin/spam_logs/_spam_log.html.haml
+++ b/app/views/admin/spam_logs/_spam_log.html.haml
@@ -24,6 +24,11 @@
= link_to 'Remove user', admin_spam_log_path(spam_log, remove_user: true),
data: { confirm: "USER #{user.name} WILL BE REMOVED! Are you sure?" }, method: :delete, class: "btn btn-xs btn-remove"
%td
+ - if spam_log.submitted_as_ham?
+ .btn.btn-xs.disabled
+ Submitted as ham
+ - else
+ = link_to 'Submit as ham', mark_as_ham_admin_spam_log_path(spam_log), method: :post, class: 'btn btn-xs btn-warning'
- if user && !user.blocked?
= link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs"
- else
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index d37489bebea..76c9ed0ee8b 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -140,12 +140,10 @@
.panel-heading
This user is blocked
.panel-body
- %p Blocking user has the following effects:
+ %p A blocked user cannot:
%ul
- %li User will not be able to login
- %li User will not be able to access git repositories
- %li Personal projects will be left
- %li Owned groups will be left
+ %li Log in
+ %li Access Git repositories
%br
= link_to 'Unblock user', unblock_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' }
- else
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 98f302d2f93..b40395c74de 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -1,6 +1,7 @@
%li{class: "todo todo-#{todo.done? ? 'done' : 'pending'}", id: dom_id(todo), data:{url: todo_target_path(todo)} }
+ = author_avatar(todo, size: 40)
+
.todo-item.todo-block
- = image_tag avatar_icon(todo.author_email, 40), class: 'avatar s40', alt:''
.todo-title.title
- unless todo.build_failed?
= todo_target_state_pill(todo)
@@ -19,13 +20,13 @@
&middot; #{time_ago_with_tooltip(todo.created_at)}
- - if todo.pending?
- .todo-actions.pull-right
- = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do
- Done
- = icon('spinner spin')
-
.todo-body
.todo-note
.md
= event_note(todo.body, project: todo.project)
+
+ - if todo.pending?
+ .todo-actions
+ = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do
+ Done
+ = icon('spinner spin')
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 4e340b6ec16..d320d3bcc1e 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -43,6 +43,25 @@
class: 'select2 trigger-submit', include_blank: true,
data: {placeholder: 'Action'})
+ .pull-right
+ .dropdown.inline.prepend-left-10
+ %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'}
+ %span.light
+ - if @sort.present?
+ = sort_options_hash[@sort]
+ - else
+ = sort_title_recently_created
+ %b.caret
+ %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-sort
+ %li
+ = link_to todos_filter_path(sort: sort_value_priority) do
+ = sort_title_priority
+ = link_to todos_filter_path(sort: sort_value_recently_created) do
+ = sort_title_recently_created
+ = link_to todos_filter_path(sort: sort_value_oldest_created) do
+ = sort_title_oldest_created
+
+
.prepend-top-default
- if @todos.any?
.js-todos-options{ data: {per_page: @todos.limit_value, current_page: @todos.current_page, total_pages: @todos.total_pages} }
diff --git a/app/views/devise/sessions/_new_crowd.html.haml b/app/views/devise/sessions/_new_crowd.html.haml
index 8e81671b7e7..b7d3acac2b1 100644
--- a/app/views/devise/sessions/_new_crowd.html.haml
+++ b/app/views/devise/sessions/_new_crowd.html.haml
@@ -1,4 +1,4 @@
-= form_tag(user_omniauth_authorize_path("crowd"), id: 'new_crowd_user' ) do
+= form_tag(omniauth_authorize_path(:user, :crowd), id: 'new_crowd_user' ) do
= text_field_tag :username, nil, {class: "form-control top", placeholder: "Username", autofocus: "autofocus"}
= password_field_tag :password, nil, {class: "form-control bottom", placeholder: "Password"}
- if devise_mapping.rememberable?
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index de18bc2d844..2e7da2747d0 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -5,4 +5,4 @@
- providers.each do |provider|
%span.light
- has_icon = provider_has_icon?(provider)
- = link_to provider_image_tag(provider), user_omniauth_authorize_path(provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn'), "data-no-turbolink" => "true"
+ = link_to provider_image_tag(provider), omniauth_authorize_path(:user, provider), method: :post, class: (has_icon ? 'oauth-image-link' : 'btn'), "data-no-turbolink" => "true"
diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml
index fa1ad9efa73..1411daeb4a6 100644
--- a/app/views/discussions/_diff_discussion.html.haml
+++ b/app/views/discussions/_diff_discussion.html.haml
@@ -1,6 +1,6 @@
-%tr.notes_holder
+- expanded = local_assigns.fetch(:expanded, true)
+%tr.notes_holder{class: ('hide' unless expanded)}
%td.notes_line{ colspan: 2 }
%td.notes_content
- %ul.notes{ data: { discussion_id: discussion.id } }
- = render partial: "projects/notes/note", collection: discussion.notes, as: :note
- = link_to_reply_discussion(discussion)
+ .content
+ = render "discussions/notes", discussion: discussion
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 02b159ffd45..3a95a652810 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -7,8 +7,11 @@
.diff-content.code.js-syntax-highlight
%table
- - discussion.truncated_diff_lines.each do |line|
- = render "projects/diffs/line", line: line, diff_file: diff_file, plain: true
-
- - if discussion.for_line?(line)
- = render "discussions/diff_discussion", discussion: discussion
+ - discussions = { discussion.original_line_code => discussion }
+ = render partial: "projects/diffs/line",
+ collection: discussion.truncated_diff_lines,
+ as: :line,
+ locals: { diff_file: diff_file,
+ discussions: discussions,
+ discussion_expanded: true,
+ plain: true }
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 49702e048aa..077e8e64e5f 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -5,8 +5,17 @@
= link_to user_path(discussion.author) do
= image_tag avatar_icon(discussion.author), class: "avatar s40"
.timeline-content
- .discussion.js-toggle-container{ class: discussion.id }
+ .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } }
.discussion-header
+ .discussion-actions
+ = link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do
+ - if expanded
+ = icon("chevron-up")
+ - else
+ = icon("chevron-down")
+
+ Toggle discussion
+
= link_to_member(@project, discussion.author, avatar: false)
.inline.discussion-headline-light
@@ -29,17 +38,11 @@
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
- .discussion-actions
- = link_to "#", class: "note-action-button discussion-toggle-button js-toggle-button" do
- - if expanded
- = icon("chevron-up")
- - else
- = icon("chevron-down")
-
- Toggle discussion
+ = render "discussions/headline", discussion: discussion
.discussion-body.js-toggle-content{ class: ("hide" unless expanded) }
- if discussion.diff_discussion? && discussion.diff_file
= render "discussions/diff_with_notes", discussion: discussion
- else
- = render "discussions/notes", discussion: discussion
+ .panel.panel-default
+ = render "discussions/notes", discussion: discussion
diff --git a/app/views/discussions/_headline.html.haml b/app/views/discussions/_headline.html.haml
new file mode 100644
index 00000000000..c1dabeed387
--- /dev/null
+++ b/app/views/discussions/_headline.html.haml
@@ -0,0 +1,14 @@
+- if discussion.resolved?
+ .discussion-headline-light.js-discussion-headline
+ Resolved
+ - if discussion.resolved_by
+ by
+ = link_to_member(@project, discussion.resolved_by, avatar: false)
+ = time_ago_with_tooltip(discussion.resolved_at, placement: "bottom")
+- elsif discussion.last_updated_at != discussion.created_at
+ .discussion-headline-light.js-discussion-headline
+ Last updated
+ - if discussion.last_updated_by
+ by
+ = link_to_member(@project, discussion.last_updated_by, avatar: false)
+ = time_ago_with_tooltip(discussion.last_updated_at, placement: "bottom")
diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml
new file mode 100644
index 00000000000..69bd416c4de
--- /dev/null
+++ b/app/views/discussions/_jump_to_next.html.haml
@@ -0,0 +1,9 @@
+- discussion = local_assigns.fetch(:discussion, nil)
+- if current_user
+ %jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" }
+ .btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" }
+ %button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion",
+ title: "Jump to next unresolved discussion",
+ "aria-label" => "Jump to next unresolved discussion",
+ data: { container: "body" } }
+ = custom_icon("next_discussion")
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index a2642b839f6..fbe470bed2c 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -1,5 +1,15 @@
-.panel.panel-default
- .notes{ data: { discussion_id: discussion.id } }
- %ul.notes.timeline
- = render partial: "projects/notes/note", collection: discussion.notes, as: :note
- = link_to_reply_discussion(discussion)
+%ul.notes{ data: { discussion_id: discussion.id } }
+ = render partial: "projects/notes/note", collection: discussion.notes, as: :note
+
+- if current_user
+ .discussion-reply-holder
+ - if discussion.diff_discussion?
+ - line_type = local_assigns.fetch(:line_type, nil)
+
+ .btn-group-justified.discussion-with-resolve-btn{ role: "group" }
+ .btn-group{ role: "group" }
+ = link_to_reply_discussion(discussion, line_type)
+ = render "discussions/resolve_all", discussion: discussion
+ = render "discussions/jump_to_next", discussion: discussion
+ - else
+ = link_to_reply_discussion(discussion)
diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml
index a798c438ea0..f1072ce0feb 100644
--- a/app/views/discussions/_parallel_diff_discussion.html.haml
+++ b/app/views/discussions/_parallel_diff_discussion.html.haml
@@ -1,22 +1,21 @@
-%tr.notes_holder
+- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?)
+%tr.notes_holder{class: ('hide' unless expanded)}
- if discussion_left
%td.notes_line.old
%td.notes_content.parallel.old
- %ul.notes{ data: { discussion_id: discussion_left.id } }
- = render partial: "projects/notes/note", collection: discussion_left.notes, as: :note
-
- = link_to_reply_discussion(discussion_left, 'old')
+ .content{class: ('hide' unless discussion_left.expanded?)}
+ = render "discussions/notes", discussion: discussion_left, line_type: 'old'
- else
%td.notes_line.old= ""
- %td.notes_content.parallel.old= ""
+ %td.notes_content.parallel.old
+ .content
- if discussion_right
%td.notes_line.new
%td.notes_content.parallel.new
- %ul.notes{ data: { discussion_id: discussion_right.id } }
- = render partial: "projects/notes/note", collection: discussion_right.notes, as: :note
-
- = link_to_reply_discussion(discussion_right, 'new')
+ .content{class: ('hide' unless discussion_right.expanded?)}
+ = render "discussions/notes", discussion: discussion_right, line_type: 'new'
- else
%td.notes_line.new= ""
- %td.notes_content.parallel.new= ""
+ %td.notes_content.parallel.new
+ .content
diff --git a/app/views/discussions/_resolve_all.html.haml b/app/views/discussions/_resolve_all.html.haml
new file mode 100644
index 00000000000..7a8767ddba0
--- /dev/null
+++ b/app/views/discussions/_resolve_all.html.haml
@@ -0,0 +1,11 @@
+- if discussion.for_merge_request?
+ %resolve-discussion-btn{ ":namespace-path" => "'#{discussion.project.namespace.path}'",
+ ":project-path" => "'#{discussion.project.path}'",
+ ":discussion-id" => "'#{discussion.id}'",
+ ":merge-request-id" => discussion.noteable.iid,
+ ":can-resolve" => discussion.can_resolve?(current_user),
+ "inline-template" => true }
+ .btn-group{ role: "group", "v-if" => "showButton" }
+ %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading" }
+ = icon("spinner spin", "v-show" => "loading")
+ {{ buttonText }}
diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml
index 9bb9f962177..2fb3190ab11 100644
--- a/app/views/groups/group_members/_new_group_member.html.haml
+++ b/app/views/groups/group_members/_new_group_member.html.haml
@@ -14,5 +14,14 @@
Read more about role permissions
%strong= link_to "here", help_page_path("user/permissions"), class: "vlink"
+ .form-group
+ = f.label :expires_at, 'Access expiration date', class: 'control-label'
+ .col-sm-10
+ .clearable-input
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date'
+ %i.clear-icon.js-clear-input
+ .help-block
+ On this date, the user(s) will automatically lose access to this group and all of its projects.
+
.form-actions
= f.submit 'Add users to group', class: "btn btn-create"
diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml
index da71de4cd1e..742f9d7a433 100644
--- a/app/views/groups/group_members/update.js.haml
+++ b/app/views/groups/group_members/update.js.haml
@@ -1,2 +1,3 @@
:plain
$("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @group_member))}');
+ new MemberExpirationDate();
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index deaaf9af875..54ff1d27c67 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -4,10 +4,6 @@
%i.fa.fa-github
Import projects from GitHub
-%p
- %i.fa.fa-warning
- To import GitHub pull requests, any pull request source branches that had been deleted are temporarily restored on GitHub. To prevent any connected CI services from being overloaded with dozens of irrelevant branches being created and deleted again, GitHub webhooks are temporarily disabled during the import process, but only if you have admin access to the GitHub repository.
-
%p.light
Select projects you want to import.
%hr
diff --git a/app/views/koding/index.html.haml b/app/views/koding/index.html.haml
new file mode 100644
index 00000000000..65887aacbaf
--- /dev/null
+++ b/app/views/koding/index.html.haml
@@ -0,0 +1,6 @@
+.row-content-block.second-block.center
+ %p
+ = icon('circle', class: 'cgreen')
+ Integration is active for
+ = link_to koding_project_url, target: '_blank' do
+ #{current_application_settings.koding_url}
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index 351100f3523..67ff4b272b9 100644
--- a/app/views/layouts/_init_auto_complete.html.haml
+++ b/app/views/layouts/_init_auto_complete.html.haml
@@ -1,7 +1,7 @@
- project = @target_project || @project
-- noteable_class = @noteable.class if @noteable.present?
+- noteable_type = @noteable.class if @noteable.present?
:javascript
- GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_class, type_id: params[:id])}"
+ GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_type, type_id: params[:id])}"
GitLab.GfmAutoComplete.cachedData = undefined;
GitLab.GfmAutoComplete.setup();
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index a1a71c2fb33..bf50633af24 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -23,7 +23,6 @@
= render "layouts/broadcast"
= render "layouts/flash"
= yield :flash_message
- %div{ class: (container_class unless @no_container) }
+ %div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content
- .clearfix
- = yield
+ = yield
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 33cedaaf2ee..15a94ac23c5 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,5 +1,5 @@
!!! 5
-%html{ lang: "en"}
+%html{ lang: "en", class: "#{page_class}" }
= render "layouts/head"
%body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}}
= Gon::Base.render_data
diff --git a/app/views/layouts/koding.html.haml b/app/views/layouts/koding.html.haml
new file mode 100644
index 00000000000..22319bba745
--- /dev/null
+++ b/app/views/layouts/koding.html.haml
@@ -0,0 +1,5 @@
+- page_title "Koding"
+- page_description "Koding Dashboard"
+- header_title "Koding", koding_path
+
+= render template: "layouts/application"
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 3a14751ea8e..67f558c854b 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -12,6 +12,11 @@
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
%span
Activity
+ - if koding_enabled?
+ = nav_link(controller: :koding) do
+ = link_to koding_path, title: 'Koding' do
+ %span
+ Koding
= nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
= link_to dashboard_groups_path, title: 'Groups' do
%span
diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml
index 9e65d94186b..f7012595a5a 100644
--- a/app/views/layouts/nav/_project.html.haml
+++ b/app/views/layouts/nav/_project.html.haml
@@ -65,8 +65,8 @@
Graphs
- if project_nav_tab? :issues
- = nav_link(controller: [:issues, :labels, :milestones]) do
- = link_to url_for_project_issues(@project, only_path: true), title: 'Issues', class: 'shortcuts-issues' do
+ = nav_link(controller: [:issues, :labels, :milestones, :boards]) do
+ = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do
%span
Issues
- if @project.default_issues_tracker?
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index ee9c0366f2b..9fe94291db7 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -6,13 +6,13 @@
- content_for :scripts_body_top do
- project = @target_project || @project
- if @project_wiki && @page
- - markdown_preview_path = namespace_project_wiki_markdown_preview_path(project.namespace, project, @page.slug)
+ - preview_markdown_path = namespace_project_wiki_preview_markdown_path(project.namespace, project, @page.slug)
- else
- - markdown_preview_path = markdown_preview_namespace_project_path(project.namespace, project)
+ - preview_markdown_path = preview_markdown_namespace_project_path(project.namespace, project)
- if current_user
:javascript
window.project_uploads_path = "#{namespace_project_uploads_path project.namespace,project}";
- window.markdown_preview_path = "#{markdown_preview_path}";
+ window.preview_markdown_path = "#{preview_markdown_path}";
- content_for :scripts_body do
= render "layouts/init_auto_complete" if current_user
diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb
index fc64c98038b..ca5c2f2688c 100644
--- a/app/views/notify/new_issue_email.text.erb
+++ b/app/views/notify/new_issue_email.text.erb
@@ -3,3 +3,5 @@ New Issue was created.
Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
Author: <%= @issue.author_name %>
Assignee: <%= @issue.assignee_name %>
+
+<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml
new file mode 100644
index 00000000000..4f3d36bd9ca
--- /dev/null
+++ b/app/views/notify/new_mention_in_issue_email.html.haml
@@ -0,0 +1,12 @@
+%p
+ You have been mentioned in an issue.
+
+- if current_application_settings.email_author_in_body
+ %div
+ #{link_to @issue.author_name, user_url(@issue.author)} wrote:
+-if @issue.description
+ = markdown(@issue.description, pipeline: :email, author: @issue.author)
+
+- if @issue.assignee_id.present?
+ %p
+ Assignee: #{@issue.assignee_name}
diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb
new file mode 100644
index 00000000000..457e94b4800
--- /dev/null
+++ b/app/views/notify/new_mention_in_issue_email.text.erb
@@ -0,0 +1,7 @@
+You have been mentioned in an issue.
+
+Issue <%= @issue.iid %>: <%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue)) %>
+Author: <%= @issue.author_name %>
+Assignee: <%= @issue.assignee_name %>
+
+<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml
new file mode 100644
index 00000000000..32aedb9e6b9
--- /dev/null
+++ b/app/views/notify/new_mention_in_merge_request_email.html.haml
@@ -0,0 +1,15 @@
+%p
+ You have been mentioned in Merge Request #{@merge_request.to_reference}
+
+- if current_application_settings.email_author_in_body
+ %div
+ #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote:
+%p.details
+ != merge_path_description(@merge_request, '&rarr;')
+
+- if @merge_request.assignee_id.present?
+ %p
+ Assignee: #{@merge_request.author_name} &rarr; #{@merge_request.assignee_name}
+
+-if @merge_request.description
+ = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author)
diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb
new file mode 100644
index 00000000000..5bf0282e097
--- /dev/null
+++ b/app/views/notify/new_mention_in_merge_request_email.text.erb
@@ -0,0 +1,9 @@
+You have been mentioned in Merge Request <%= @merge_request.to_reference %>
+
+<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
+
+<%= merge_path_description(@merge_request, 'to') %>
+Author: <%= @merge_request.author_name %>
+Assignee: <%= @merge_request.assignee_name %>
+
+<%= @merge_request.description %>
diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb
index d4aad8d1862..3c8f178ac77 100644
--- a/app/views/notify/new_merge_request_email.text.erb
+++ b/app/views/notify/new_merge_request_email.text.erb
@@ -6,3 +6,5 @@ New Merge Request <%= @merge_request.to_reference %>
Author: <%= @merge_request.author_name %>
Assignee: <%= @merge_request.assignee_name %>
+<%= @merge_request.description %>
+
diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml
index c161ecc3463..c0c07d65daa 100644
--- a/app/views/notify/repository_push_email.html.haml
+++ b/app/views/notify/repository_push_email.html.haml
@@ -75,8 +75,7 @@
- blob = diff_file.blob
- if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob)
%table.code.white
- - diff_file.highlighted_diff_lines.each do |line|
- = render "projects/diffs/line", line: line, diff_file: diff_file, plain: true
+ = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true }
- else
No preview for this file type
%br
diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml
new file mode 100644
index 00000000000..522421b7cc3
--- /dev/null
+++ b/app/views/notify/resolved_all_discussions_email.html.haml
@@ -0,0 +1,2 @@
+%p
+ All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name}
diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb
new file mode 100644
index 00000000000..b0d380af8fc
--- /dev/null
+++ b/app/views/notify/resolved_all_discussions_email.text.erb
@@ -0,0 +1,3 @@
+All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %>
+
+<%= url_for(namespace_project_merge_request_url(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request)) %>
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 57d16d29158..c80f22457b4 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -70,7 +70,7 @@
= link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do
Disconnect
- else
- = link_to user_omniauth_authorize_path(provider), method: :post, class: 'provider-btn not-active', "data-no-turbolink" => "true" do
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active', "data-no-turbolink" => "true" do
Connect
%hr
- if current_user.can_change_username?
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 71ac367830d..05a2ea67aa2 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -7,6 +7,10 @@
= page_title
%p
You can generate a personal access token for each application you use that needs access to the GitLab API.
+ %p
+ You can also use personal access tokens to authenticate against Git over HTTP.
+ They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.
+
.col-lg-9
- if flash[:personal_access_token]
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 366f1fed35b..03ac739ade5 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -60,13 +60,38 @@
two-factor authentication app before a U2F device. That way you'll always be able to
log in - even when you're using an unsupported browser.
.col-lg-9
- %p
- - if @registration_key_handles.present?
- = icon "check inverse", base: "circle", class: "text-success", text: "You have #{pluralize(@registration_key_handles.size, 'U2F device')} registered with GitLab."
- if @u2f_registration.errors.present?
= form_errors(@u2f_registration)
= render "u2f/register"
+ %hr
+
+ %h5 U2F Devices (#{@u2f_registrations.length})
+
+ - if @u2f_registrations.present?
+ .table-responsive
+ %table.table.table-bordered.u2f-registrations
+ %colgroup
+ %col{ width: "50%" }
+ %col{ width: "30%" }
+ %col{ width: "20%" }
+ %thead
+ %tr
+ %th Name
+ %th Registered On
+ %th
+ %tbody
+ - @u2f_registrations.each do |registration|
+ %tr
+ %td= registration.name.presence || "<no name set>"
+ %td= registration.created_at.to_date.to_s(:medium)
+ %td= link_to "Delete", profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to delete this device? This action cannot be undone." }
+
+ - else
+ .settings-message.text-center
+ You don't have any U2F devices registered yet.
+
+
- if two_factor_skippable?
:javascript
var button = "<a class='btn btn-xs btn-warning pull-right' data-method='patch' href='#{skip_profile_two_factor_auth_path}'>Configure it later</a>";
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 51f74f3b7ce..8ef31ca3bda 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -24,6 +24,3 @@
.project-clone-holder
= render "shared/clone_panel"
-
-:javascript
- new Star();
diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml
index 413477a2d3a..3978fa60d66 100644
--- a/app/views/projects/_zen.html.haml
+++ b/app/views/projects/_zen.html.haml
@@ -1,7 +1,8 @@
+- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.zen-backdrop
- classes << ' js-gfm-input js-autosize markdown-area'
- if defined?(f) && f
- = f.text_area attr, class: classes, placeholder: placeholder
+ = f.text_area attr, class: classes, placeholder: placeholder, data: { supports_slash_commands: supports_slash_commands }
- else
= text_area_tag attr, nil, class: classes, placeholder: placeholder
%a.zen-control.zen-control-leave.js-zen-leave{ href: "#" }
diff --git a/app/views/projects/badges/badge.svg.erb b/app/views/projects/badges/badge.svg.erb
new file mode 100644
index 00000000000..a5fef4fc56f
--- /dev/null
+++ b/app/views/projects/badges/badge.svg.erb
@@ -0,0 +1,36 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="<%= badge.width %>" height="20">
+ <linearGradient id="b" x2="0" y2="100%">
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
+ <stop offset="1" stop-opacity=".1"/>
+ </linearGradient>
+
+ <mask id="a">
+ <rect width="<%= badge.width %>" height="20" rx="3" fill="#fff"/>
+ </mask>
+
+ <g mask="url(#a)">
+ <path fill="<%= badge.key_color %>"
+ d="M0 0 h<%= badge.key_width %> v20 H0 z"/>
+ <path fill="<%= badge.value_color %>"
+ d="M<%= badge.key_width %> 0 h<%= badge.value_width %> v20 H<%= badge.key_width %> z"/>
+ <path fill="url(#b)"
+ d="M0 0 h<%= badge.width %> v20 H0 z"/>
+ </g>
+
+ <g fill="#fff" text-anchor="middle">
+ <g font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
+ <text x="<%= badge.key_text_anchor %>" y="15" fill="#010101" fill-opacity=".3">
+ <%= badge.key_text %>
+ </text>
+ <text x="<%= badge.key_text_anchor %>" y="14">
+ <%= badge.key_text %>
+ </text>
+ <text x="<%= badge.value_text_anchor %>" y="15" fill="#010101" fill-opacity=".3">
+ <%= badge.value_text %>
+ </text>
+ <text x="<%= badge.value_text_anchor %>" y="14">
+ <%= badge.value_text %>
+ </text>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index ff379bafb26..0237e152b54 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -24,7 +24,7 @@
.encoding-selector
= select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2'
- .file-content.code
+ .file-editor.code
%pre.js-edit-mode-pane#editor #{params[:content] || local_assigns[:blob_data]}
- if local_assigns[:path]
.js-edit-mode-pane#preview.hide
diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml
index 18caddabd39..4c356d1f07f 100644
--- a/app/views/projects/blob/_image.html.haml
+++ b/app/views/projects/blob/_image.html.haml
@@ -1,9 +1,15 @@
.file-content.image_file
- if blob.svg?
- - # We need to scrub SVG but we cannot do so in the RawController: it would
- - # be wrong/strange if RawController modified the data.
- - blob.load_all_data!(@repository)
- - blob = sanitize_svg(blob)
- %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"}
+ - if blob.size_within_svg_limits?
+ - # We need to scrub SVG but we cannot do so in the RawController: it would
+ - # be wrong/strange if RawController modified the data.
+ - blob.load_all_data!(@repository)
+ - blob = sanitize_svg(blob)
+ %img{src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}"}
+ - else
+ .nothing-here-block
+ The SVG could not be displayed as it is too large, you can
+ #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank')}
+ instead.
- else
%img{src: namespace_project_raw_path(@project.namespace, @project, tree_join(@commit.id, blob.path))}
diff --git a/app/views/projects/blob/diff.html.haml b/app/views/projects/blob/diff.html.haml
index 5926d181ba3..a79ae53c780 100644
--- a/app/views/projects/blob/diff.html.haml
+++ b/app/views/projects/blob/diff.html.haml
@@ -1,20 +1,30 @@
- if @lines.present?
+ - line_class = diff_view == :inline ? '' : diff_view
- if @form.unfold? && @form.since != 1 && !@form.bottom?
- %tr.line_holder
- = render "projects/diffs/match_line", { line: @match_line,
- line_old: @form.since, line_new: @form.since, bottom: false, new_file: false }
+ %tr.line_holder{ class: line_class }
+ = diff_match_line @form.since, @form.since, text: @match_line, view: diff_view
- @lines.each_with_index do |line, index|
- line_new = index + @form.since
- line_old = line_new - @form.offset
- %tr.line_holder{ id: line_old }
- %td.old_line.diff-line-num{ data: { linenumber: line_old } }
- = link_to raw(line_old), "##{line_old}"
- %td.new_line.diff-line-num{ data: { linenumber: line_old } }
- = link_to raw(line_new) , "##{line_old}"
- %td.line_content.noteable_line==#{' ' * @form.indent}#{line}
+ - line_content = capture do
+ %td.line_content.noteable_line{ class: line_class }==#{' ' * @form.indent}#{line}
+ %tr.line_holder{ id: line_old, class: line_class }
+ - case diff_view
+ - when :inline
+ %td.old_line.diff-line-num{ data: { linenumber: line_old } }
+ %a{href: "##{line_old}", data: { linenumber: line_old }}
+ %td.new_line.diff-line-num{ data: { linenumber: line_new } }
+ %a{href: "##{line_new}", data: { linenumber: line_new }}
+ = line_content
+ - when :parallel
+ %td.old_line.diff-line-num{data: { linenumber: line_old }}
+ = link_to raw(line_old), "##{line_old}"
+ = line_content
+ %td.new_line.diff-line-num{data: { linenumber: line_new }}
+ = link_to raw(line_new), "##{line_new}"
+ = line_content
- if @form.unfold? && @form.bottom? && @form.to < @blob.loc
- %tr.line_holder{ id: @form.to }
- = render "projects/diffs/match_line", { line: @match_line,
- line_old: @form.to, line_new: @form.to, bottom: true, new_file: false }
+ %tr.line_holder{ id: @form.to, class: line_class }
+ = diff_match_line @form.to, @form.to, text: @match_line, view: diff_view, bottom: true
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index b1c9895f43e..680e95ac6b5 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -1,4 +1,13 @@
- page_title "Edit", @blob.path, @ref
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+ = page_specific_javascript_tag('blob_edit/blob_edit_bundle.js')
+
+- if @conflict
+ .alert.alert-danger
+ Someone edited the file the same time you did. Please check out
+ = link_to "the file", namespace_project_blob_path(@project.namespace, @project, tree_join(@target_branch, @file_path)), target: "_blank"
+ and make sure your changes will not unintentionally remove theirs.
.file-editor
%ul.nav-links.no-bottom.js-edit-mode
@@ -10,15 +19,10 @@
= link_to '#preview', 'data-preview-url' => namespace_project_preview_blob_path(@project.namespace, @project, @id) do
= editing_preview_title(@blob.name)
- = form_tag(namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form') do
+ = form_tag(namespace_project_update_blob_path(@project.namespace, @project, @id), method: :put, class: 'form-horizontal js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths) do
= render 'projects/blob/editor', ref: @ref, path: @path, blob_data: @blob.data
= render 'shared/new_commit_form', placeholder: "Update #{@blob.name}"
-
- = hidden_field_tag 'last_commit', @last_commit
+ = hidden_field_tag 'last_commit_sha', @last_commit_sha
= hidden_field_tag 'content', '', id: "file-content"
= hidden_field_tag 'from_merge_request_id', params[:from_merge_request_id]
= render 'projects/commit_button', ref: @ref, cancel_path: namespace_project_blob_path(@project.namespace, @project, @id)
-
-:javascript
- blob = new EditBlob(gon.relative_url_root + "#{Gitlab::Application.config.assets.prefix}", "#{@blob.language.try(:ace_mode)}")
- new NewCommitForm($('.js-edit-blob-form'))
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index c952bc7e5db..b6ed9518c48 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -1,17 +1,16 @@
- page_title "New File", @path.presence, @ref
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+ = page_specific_javascript_tag('blob_edit/blob_edit_bundle.js')
%h3.page-title
New File
.file-editor
- = form_tag(namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post, class: 'form-horizontal js-new-blob-form js-quick-submit js-requires-input') do
+ = form_tag(namespace_project_create_blob_path(@project.namespace, @project, @id), method: :post, class: 'form-horizontal js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths) do
= render 'projects/blob/editor', ref: @ref
= render 'shared/new_commit_form', placeholder: "Add new file"
= hidden_field_tag 'content', '', id: 'file-content'
= render 'projects/commit_button', ref: @ref,
cancel_path: namespace_project_tree_path(@project.namespace, @project, @id)
-
-:javascript
- blob = new EditBlob(gon.relative_url_root + "#{Gitlab::Application.config.assets.prefix}")
- new NewCommitForm($('.js-new-blob-form'))
diff --git a/app/views/projects/boards/components/_blank_state.html.haml b/app/views/projects/boards/components/_blank_state.html.haml
new file mode 100644
index 00000000000..97eb952eff1
--- /dev/null
+++ b/app/views/projects/boards/components/_blank_state.html.haml
@@ -0,0 +1,15 @@
+%board-blank-state{ "inline-template" => true,
+ "v-if" => "list.id == 'blank'" }
+ .board-blank-state
+ %p
+ Add the following default lists to your Issue Board with one click:
+ %ul.board-blank-state-list
+ %li{ "v-for" => "label in predefinedLabels" }
+ %span.label-color{ ":style" => "{ backgroundColor: label.color } " }
+ {{ label.title }}
+ %p
+ Starting out with the default set of lists will get you right on the way to making the most of your board.
+ %button.btn.btn-create.btn-inverted.btn-block{ type: "button", "@click.stop" => "addDefaultLists" }
+ Add default lists
+ %button.btn.btn-default.btn-block{ type: "button", "@click.stop" => "clearBlankState" }
+ Nevermind, I'll use my own
diff --git a/app/views/projects/boards/components/_board.html.haml b/app/views/projects/boards/components/_board.html.haml
new file mode 100644
index 00000000000..de53a298f84
--- /dev/null
+++ b/app/views/projects/boards/components/_board.html.haml
@@ -0,0 +1,43 @@
+%board{ "inline-template" => true,
+ "v-cloak" => true,
+ "v-for" => "list in state.lists | orderBy 'position'",
+ "v-ref:board" => true,
+ ":list" => "list",
+ ":disabled" => "disabled",
+ ":issue-link-base" => "issueLinkBase",
+ "track-by" => "_uid" }
+ .board{ ":class" => "{ 'is-draggable': !list.preset }",
+ ":data-id" => "list.id" }
+ .board-inner
+ %header.board-header{ ":class" => "{ 'has-border': list.label }", ":style" => "{ borderTopColor: (list.label ? list.label.color : null) }" }
+ %h3.board-title.js-board-handle{ ":class" => "{ 'user-can-drag': (!disabled && !list.preset) }" }
+ {{ list.title }}
+ %span.pull-right{ "v-if" => "list.type !== 'blank'" }
+ {{ list.issues.length }}
+ - if can?(current_user, :admin_list, @project)
+ %board-delete{ "inline-template" => true,
+ ":list" => "list",
+ "v-if" => "!list.preset && list.id" }
+ %button.board-delete.has-tooltip.pull-right{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
+ = icon("trash")
+ = icon("spinner spin", class: "board-header-loading-spinner pull-right", "v-show" => "list.loadingMore")
+ .board-inner-container.board-search-container{ "v-if" => "list.canSearch()" }
+ %input.form-control{ type: "text", placeholder: "Search issues", "v-model" => "query", "debounce" => "250" }
+ = icon("search", class: "board-search-icon", "v-show" => "!query")
+ %button.board-search-clear-btn{ type: "button", role: "button", "aria-label" => "Clear search", "@click" => "query = ''", "v-show" => "query" }
+ = icon("times", class: "board-search-clear")
+ %board-list{ "inline-template" => true,
+ "v-if" => "list.type !== 'blank'",
+ ":list" => "list",
+ ":issues" => "list.issues",
+ ":loading" => "list.loading",
+ ":disabled" => "disabled",
+ ":issue-link-base" => "issueLinkBase" }
+ .board-list-loading.text-center{ "v-if" => "loading" }
+ = icon("spinner spin")
+ %ul.board-list{ "v-el:list" => true,
+ "v-show" => "!loading",
+ ":data-board" => "list.id" }
+ = render "projects/boards/components/card"
+ - if can?(current_user, :admin_list, @project)
+ = render "projects/boards/components/blank_state"
diff --git a/app/views/projects/boards/components/_card.html.haml b/app/views/projects/boards/components/_card.html.haml
new file mode 100644
index 00000000000..e8b60b54d80
--- /dev/null
+++ b/app/views/projects/boards/components/_card.html.haml
@@ -0,0 +1,33 @@
+%board-card{ "inline-template" => true,
+ "v-for" => "issue in issues | orderBy 'priority'",
+ "v-ref:issue" => true,
+ ":index" => "$index",
+ ":list" => "list",
+ ":issue" => "issue",
+ ":issue-link-base" => "issueLinkBase",
+ ":disabled" => "disabled",
+ "track-by" => "id" }
+ %li.card{ ":class" => "{ 'user-can-drag': !disabled }",
+ ":index" => "index" }
+ %h4.card-title
+ = icon("eye-slash", class: "confidential-icon", "v-if" => "issue.confidential")
+ %a{ ":href" => "issueLinkBase + '/' + issue.id",
+ ":title" => "issue.title" }
+ {{ issue.title }}
+ .card-footer
+ %span.card-number
+ = precede '#' do
+ {{ issue.id }}
+ %button.label.color-label.has-tooltip{ "v-for" => "label in issue.labels",
+ type: "button",
+ "v-if" => "(!list.label || label.id !== list.label.id)",
+ "@click" => "filterByLabel(label, $event)",
+ ":style" => "{ backgroundColor: label.color, color: label.textColor }",
+ ":title" => "label.description",
+ data: { container: 'body' } }
+ {{ label.title }}
+ %a.has-tooltip{ ":href" => "'/u/' + issue.assignee.username",
+ ":title" => "'Assigned to ' + issue.assignee.name",
+ "v-if" => "issue.assignee",
+ data: { container: 'body' } }
+ %img.avatar.avatar-inline.s20{ ":src" => "issue.assignee.avatar", width: 20, height: 20 }
diff --git a/app/views/projects/boards/show.html.haml b/app/views/projects/boards/show.html.haml
new file mode 100644
index 00000000000..edbbd3f3d2a
--- /dev/null
+++ b/app/views/projects/boards/show.html.haml
@@ -0,0 +1,19 @@
+- @no_container = true
+- @content_class = "issue-boards-content"
+- page_title "Boards"
+
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('boards/boards_bundle.js')
+ = page_specific_javascript_tag('boards/test_utils/simulate_drag.js') if Rails.env.test?
+
+= render "projects/issues/head"
+
+= render 'shared/issuable/filter', type: :boards
+
+.boards-list#board-app{ "v-cloak" => true,
+ "data-endpoint" => "#{namespace_project_board_path(@project.namespace, @project)}",
+ "data-disabled" => "#{!can?(current_user, :admin_list, @project)}",
+ "data-issue-link-base" => "#{namespace_project_issues_path(@project.namespace, @project)}" }
+ .boards-app-loading.text-center{ "v-if" => "loading" }
+ = icon("spinner spin")
+ = render "projects/boards/components/board"
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 4bd85061240..6192ccb710b 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -5,8 +5,8 @@
- number_commits_ahead = diverging_commit_counts[:ahead]
%li(class="js-branch-#{branch.name}")
%div
- = link_to namespace_project_tree_path(@project.namespace, @project, branch.name) do
- %span.item-title.str-truncated= branch.name
+ = link_to namespace_project_tree_path(@project.namespace, @project, branch.name), class: 'item-title str-truncated' do
+ = branch.name
&nbsp;
- if branch.name == @repository.root_ref
%span.label.label-primary default
diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml
index a8bc53c2849..5b0b58e087b 100644
--- a/app/views/projects/builds/_sidebar.html.haml
+++ b/app/views/projects/builds/_sidebar.html.haml
@@ -11,98 +11,133 @@
%p.build-detail-row
#{@build.coverage}%
- - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
- .block{ class: ("block-first" if !@build.coverage) }
- .title
- Build artifacts
- - if @build.artifacts_expired?
- %p.build-detail-row
- The artifacts were removed
- #{time_ago_with_tooltip(@build.artifacts_expire_at)}
- - elsif @build.artifacts_expire_at
- %p.build-detail-row
- The artifacts will be removed in
- %span.js-artifacts-remove= @build.artifacts_expire_at
+ - builds = @build.pipeline.builds.latest.to_a
+ - statuses = ["failed", "pending", "running", "canceled", "success", "skipped"]
+ - if builds.size > 1
+ .dropdown.build-dropdown
+ .build-light-text Stage
+ %button.dropdown-menu-toggle{type: 'button', 'data-toggle' => 'dropdown'}
+ %span.stage-selection More
+ = icon('caret-down')
+ %ul.dropdown-menu
+ - builds.map(&:stage).uniq.each do |stage|
+ %li
+ %a.stage-item= stage
- - if @build.artifacts?
- .btn-group.btn-group-justified{ role: :group }
- - if @build.artifacts_expire_at
- = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
- Keep
+ .builds-container
+ - statuses.each do |build_status|
+ - builds.select{|build| build.status == build_status}.each do |build|
+ .build-job{class: ('active' if build == @build), data: {stage: build.stage}}
+ = link_to namespace_project_build_path(@project.namespace, @project, build) do
+ = icon('check')
+ = ci_icon_for_status(build.status)
+ %span
+ - if build.name
+ = build.name
+ - else
+ = build.id
- = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
- Download
+ - if @build.retried?
+ %li.active
+ %a
+ Build ##{@build.id}
+ &middot;
+ %i.fa.fa-warning
+ This build was retried.
- - if @build.artifacts_metadata?
- = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
- Browse
+ .blocks-container
+ - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?)
+ .block{ class: ("block-first" if !@build.coverage) }
+ .title
+ Build artifacts
+ - if @build.artifacts_expired?
+ %p.build-detail-row
+ The artifacts were removed
+ #{time_ago_with_tooltip(@build.artifacts_expire_at)}
+ - elsif @build.artifacts_expire_at
+ %p.build-detail-row
+ The artifacts will be removed in
+ %span.js-artifacts-remove= @build.artifacts_expire_at
- .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) }
- .title
- Build details
- - if can?(current_user, :update_build, @build) && @build.retryable?
- = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right', method: :post
- - if @build.merge_request
- %p.build-detail-row
- %span.build-light-text Merge Request:
- = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request)
- - if @build.duration
- %p.build-detail-row
- %span.build-light-text Duration:
- = time_interval_in_words(@build.duration)
- - if @build.finished_at
- %p.build-detail-row
- %span.build-light-text Finished:
- #{time_ago_with_tooltip(@build.finished_at)}
- - if @build.erased_at
- %p.build-detail-row
- %span.build-light-text Erased:
- #{time_ago_with_tooltip(@build.erased_at)}
- %p.build-detail-row
- %span.build-light-text Runner:
- - if @build.runner && current_user && current_user.admin
- = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id)
- - elsif @build.runner
- \##{@build.runner.id}
- .btn-group.btn-group-justified{ role: :group }
- - if @build.has_trace?
- = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
- - if @build.active?
- = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
- - if can?(current_user, :update_build, @project) && @build.erasable?
- = link_to erase_namespace_project_build_path(@project.namespace, @project, @build),
- class: "btn btn-sm btn-default", method: :post,
- data: { confirm: "Are you sure you want to erase this build?" } do
- Erase
+ - if @build.artifacts?
+ .btn-group.btn-group-justified{ role: :group }
+ - if @build.artifacts_expire_at
+ = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
+ Keep
+
+ = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
+ Download
- - if @build.trigger_request
- .build-widget
- %h4.title
- Trigger
+ - if @build.artifacts_metadata?
+ = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do
+ Browse
- %p
- %span.build-light-text Token:
- #{@build.trigger_request.trigger.short_token}
+ .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) }
+ .title
+ Build details
+ - if can?(current_user, :update_build, @build) && @build.retryable?
+ = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right', method: :post
+ - if @build.merge_request
+ %p.build-detail-row
+ %span.build-light-text Merge Request:
+ = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request)
+ - if @build.duration
+ %p.build-detail-row
+ %span.build-light-text Duration:
+ = time_interval_in_words(@build.duration)
+ - if @build.finished_at
+ %p.build-detail-row
+ %span.build-light-text Finished:
+ #{time_ago_with_tooltip(@build.finished_at)}
+ - if @build.erased_at
+ %p.build-detail-row
+ %span.build-light-text Erased:
+ #{time_ago_with_tooltip(@build.erased_at)}
+ %p.build-detail-row
+ %span.build-light-text Runner:
+ - if @build.runner && current_user && current_user.admin
+ = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id)
+ - elsif @build.runner
+ \##{@build.runner.id}
+ .btn-group.btn-group-justified{ role: :group }
+ - if @build.has_trace?
+ = link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
+ - if @build.active?
+ = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
+ - if can?(current_user, :update_build, @project) && @build.erasable?
+ = link_to erase_namespace_project_build_path(@project.namespace, @project, @build),
+ class: "btn btn-sm btn-default", method: :post,
+ data: { confirm: "Are you sure you want to erase this build?" } do
+ Erase
+
+ - if @build.trigger_request
+ .build-widget
+ %h4.title
+ Trigger
- - if @build.trigger_request.variables
%p
- %span.build-light-text Variables:
+ %span.build-light-text Token:
+ #{@build.trigger_request.trigger.short_token}
+ - if @build.trigger_request.variables
+ %p
+ %span.build-light-text Variables:
- - @build.trigger_request.variables.each do |key, value|
- %code
- #{key}=#{value}
- .block
- .title
- Commit title
- %p.build-light-text.append-bottom-0
- #{@build.pipeline.git_commit_title}
+ - @build.trigger_request.variables.each do |key, value|
+ %code
+ #{key}=#{value}
- - if @build.tags.any?
.block
.title
- Tags
- - @build.tag_list.each do |tag|
- %span.label.label-primary
- = tag
+ Commit title
+ %p.build-light-text.append-bottom-0
+ #{@build.pipeline.git_commit_title}
+
+ - if @build.tags.any?
+ .block
+ .title
+ Tags
+ - @build.tag_list.each do |tag|
+ %span.label.label-primary
+ = tag
diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml
index 4421f3b9562..e4d41288aa6 100644
--- a/app/views/projects/builds/show.html.haml
+++ b/app/views/projects/builds/show.html.haml
@@ -5,26 +5,6 @@
.build-page
= render "header"
- - builds = @build.pipeline.builds.latest.to_a
- - if builds.size > 1
- %ul.nav-links.no-top.no-bottom
- - builds.each do |build|
- %li{class: ('active' if build == @build) }
- = link_to namespace_project_build_path(@project.namespace, @project, build) do
- = ci_icon_for_status(build.status)
- %span
- - if build.name
- = build.name
- - else
- = build.id
-
- - if @build.retried?
- %li.active
- %a
- Build ##{@build.id}
- &middot;
- %i.fa.fa-warning
- This build was retried.
- if @build.stuck?
- unless @build.any_runners_online?
.bs-callout.bs-callout-warning
@@ -67,4 +47,10 @@
= render "sidebar"
:javascript
- new Build("#{namespace_project_build_url(@project.namespace, @project, @build)}", "#{namespace_project_build_url(@project.namespace, @project, @build, :json)}", "#{@build.status}", "#{trace_with_state[:state]}")
+ new Build({
+ page_url: "#{namespace_project_build_url(@project.namespace, @project, @build)}",
+ build_url: "#{namespace_project_build_url(@project.namespace, @project, @build, :json)}",
+ build_status: "#{@build.status}",
+ build_stage: "#{@build.stage}",
+ state1: "#{trace_with_state[:state]}"
+ })
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
index 16b8e1cca91..ca907077c2b 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -9,7 +9,7 @@
- if can_create_issue
%li
- = link_to url_for_new_issue(@project, only_path: true) do
+ = link_to new_namespace_project_issue_path(@project.namespace, @project) do
= icon('exclamation-circle fw')
New issue
diff --git a/app/views/projects/buttons/_koding.html.haml b/app/views/projects/buttons/_koding.html.haml
new file mode 100644
index 00000000000..fdc80d44253
--- /dev/null
+++ b/app/views/projects/buttons/_koding.html.haml
@@ -0,0 +1,7 @@
+- if koding_enabled? && current_user && can_push_branch?(@project, @project.default_branch)
+ - if @repository.koding_yml
+ = link_to koding_project_url(@project), class: 'btn', target: '_blank' do
+ Run in IDE (Koding)
+ - else
+ = link_to add_koding_stack_path(@project), class: 'btn' do
+ Set Up Koding
diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml
index 71cf5582a4c..311583037e5 100644
--- a/app/views/projects/buttons/_star.html.haml
+++ b/app/views/projects/buttons/_star.html.haml
@@ -1,10 +1,10 @@
- if current_user
= link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: current_user.starred?(@project) ? 'Unstar project' : 'Star project' } do
- if current_user.starred?(@project)
- = icon('star fw')
+ = icon('star')
%span.starred Unstar
- else
- = icon('star-o fw')
+ = icon('star-o')
%span Star
%div.count-with-arrow
%span.arrow
@@ -13,7 +13,7 @@
- else
= link_to new_user_session_path, class: 'btn has-tooltip star-btn', title: 'You must sign in to star a project' do
- = icon('star fw')
+ = icon('star')
Star
%div.count-with-arrow
%span.arrow
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 91081435220..1fdf32466f2 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -63,7 +63,7 @@
- if build.duration
%p.duration
= custom_icon("icon_timer")
- = duration_in_numbers(build.finished_at, build.started_at)
+ = duration_in_numbers(build.duration)
- if build.finished_at
%p.finished-at
= icon("calendar")
diff --git a/app/views/projects/ci/builds/_build_pipeline.html.haml b/app/views/projects/ci/builds/_build_pipeline.html.haml
new file mode 100644
index 00000000000..04cbd0c3591
--- /dev/null
+++ b/app/views/projects/ci/builds/_build_pipeline.html.haml
@@ -0,0 +1,14 @@
+- is_playable = subject.playable? && can?(current_user, :update_build, @project)
+%li.build{class: ("playable" if is_playable)}
+ .build-content
+ - if is_playable
+ = link_to play_namespace_project_build_path(subject.project.namespace, subject.project, subject, return_to: request.original_url), method: :post, title: 'Play' do
+ = render_status_with_link('build', 'play')
+ = subject.name
+ - elsif can?(current_user, :read_build, @project)
+ = link_to namespace_project_build_path(subject.project.namespace, subject.project, subject) do
+ = render_status_with_link('build', subject.status)
+ = subject.name
+ - else
+ = render_status_with_link('build', subject.status)
+ = ci_icon_for_status(subject.status)
diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml
index 558c35553da..b119f6edf14 100644
--- a/app/views/projects/ci/pipelines/_pipeline.html.haml
+++ b/app/views/projects/ci/pipelines/_pipeline.html.haml
@@ -1,21 +1,23 @@
- status = pipeline.status
%tr.commit
%td.commit-link
- = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do
- = ci_status_with_icon(status)
-
-
+ = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) do
+ - if defined?(status_icon_only) && status_icon_only
+ = ci_icon_for_status(status)
+ - else
+ = ci_status_with_icon(status)
%td
.branch-commit
- = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id) do
+ = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id) do
%span ##{pipeline.id}
- if pipeline.ref
- .icon-container
- = pipeline.tag? ? icon('tag') : icon('code-fork')
- = link_to pipeline.ref, namespace_project_commits_path(@project.namespace, @project, pipeline.ref), class: "monospace branch-name"
- .icon-container
- = custom_icon("icon_commit")
- = link_to pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, pipeline.sha), class: "commit-id monospace"
+ - unless defined?(hide_branch) && hide_branch
+ .icon-container
+ = pipeline.tag? ? icon('tag') : icon('code-fork')
+ = link_to pipeline.ref, namespace_project_commits_path(pipeline.project.namespace, pipeline.project, pipeline.ref), class: "monospace branch-name"
+ .icon-container
+ = custom_icon("icon_commit")
+ = link_to pipeline.short_sha, namespace_project_commit_path(pipeline.project.namespace, pipeline.project, pipeline.sha), class: "commit-id monospace"
- if pipeline.latest?
%span.label.label-success.has-tooltip{ title: 'Latest build for this branch' } latest
- if pipeline.triggered?
@@ -28,32 +30,32 @@
%p.commit-title
- if commit = pipeline.commit
= author_avatar(commit, size: 20)
- = link_to_gfm truncate(commit.title, length: 60), namespace_project_commit_path(@project.namespace, @project, commit.id), class: "commit-row-message"
+ = link_to_gfm truncate(commit.title, length: 60), namespace_project_commit_path(pipeline.project.namespace, pipeline.project, commit.id), class: "commit-row-message"
- else
Cant find HEAD commit for this branch
- - stages_status = pipeline.statuses.latest.stages_status
+ - stages_status = pipeline.statuses.relevant.latest.stages_status
- stages.each do |stage|
%td.stage-cell
- status = stages_status[stage]
- tooltip = "#{stage.titleize}: #{status || 'not found'}"
- if status
- = link_to namespace_project_pipeline_path(@project.namespace, @project, pipeline.id, anchor: stage), class: "has-tooltip ci-status-icon-#{status}", title: tooltip do
+ = link_to namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id, anchor: stage), class: "has-tooltip ci-status-icon-#{status}", title: tooltip do
= ci_icon_for_status(status)
- else
.light.has-tooltip{ title: tooltip }
\-
%td
- - if pipeline.started_at && pipeline.finished_at
+ - if pipeline.duration
%p.duration
= custom_icon("icon_timer")
- = duration_in_numbers(pipeline.finished_at, pipeline.started_at)
+ = duration_in_numbers(pipeline.duration)
- if pipeline.finished_at
%p.finished-at
= icon("calendar")
- #{time_ago_with_tooltip(pipeline.finished_at)}
+ #{time_ago_with_tooltip(pipeline.finished_at, short_format: false, skip_js: true)}
%td.pipeline-actions
.controls.hidden-xs.pull-right
@@ -69,7 +71,7 @@
%ul.dropdown-menu.dropdown-menu-align-right
- actions.each do |build|
%li
- = link_to play_namespace_project_build_path(@project.namespace, @project, build), method: :post, rel: 'nofollow' do
+ = link_to play_namespace_project_build_path(pipeline.project.namespace, pipeline.project, build), method: :post, rel: 'nofollow' do
= icon("play")
%span= build.name.humanize
- if artifacts.present?
@@ -80,15 +82,15 @@
%ul.dropdown-menu.dropdown-menu-align-right
- artifacts.each do |build|
%li
- = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, build), rel: 'nofollow' do
+ = link_to download_namespace_project_build_artifacts_path(pipeline.project.namespace, pipeline.project, build), rel: 'nofollow' do
= icon("download")
%span Download '#{build.name}' artifacts
- - if can?(current_user, :update_pipeline, @project)
+ - if can?(current_user, :update_pipeline, pipeline.project)
.cancel-retry-btns.inline
- if pipeline.retryable?
- = link_to retry_namespace_project_pipeline_path(@project.namespace, @project, pipeline.id), class: 'btn has-tooltip', title: "Retry", method: :post do
+ = link_to retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn has-tooltip', title: "Retry", method: :post do
= icon("repeat")
- if pipeline.cancelable?
- = link_to cancel_namespace_project_pipeline_path(@project.namespace, @project, pipeline.id), class: 'btn btn-remove has-tooltip', title: "Cancel", method: :post do
+ = link_to cancel_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-remove has-tooltip', title: "Cancel", method: :post do
= icon("remove")
diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml
index d9b800a4ded..e4cd55b9f7a 100644
--- a/app/views/projects/commit/_change.html.haml
+++ b/app/views/projects/commit/_change.html.haml
@@ -17,7 +17,9 @@
.form-group.branch
= label_tag 'target_branch', target_label, class: 'control-label'
.col-sm-10
- = select_tag "target_branch", project_branches, class: "select2 select2-sm js-target-branch"
+ = hidden_field_tag :target_branch, @project.default_branch, id: 'target_branch'
+ = dropdown_tag(@project.default_branch, options: { title: "Switch branch", filter: true, placeholder: "Search branches", toggle_class: 'js-project-refs-dropdown js-target-branch dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "target_branch", selected: @project.default_branch, target_branch: @project.default_branch, refs_url: namespace_project_branches_path(@project.namespace, @project), submit_form_on_click: false }})
+
- if can?(current_user, :push_code, @project)
.js-create-merge-request-container
.checkbox
diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml
index ea33aa472a6..935433306ea 100644
--- a/app/views/projects/commit/_ci_menu.html.haml
+++ b/app/views/projects/commit/_ci_menu.html.haml
@@ -2,7 +2,7 @@
= nav_link(path: 'commit#show') do
= link_to namespace_project_commit_path(@project.namespace, @project, @commit.id) do
Changes
- %span.badge= @diffs.count
+ %span.badge= @diffs.size
= nav_link(path: 'commit#builds') do
= link_to builds_namespace_project_commit_path(@project.namespace, @project, @commit.id) do
Builds
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 3ad866bb2f1..29d767e7769 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -56,10 +56,10 @@
= pluralize(@commit.pipelines.count, 'pipeline')
= link_to builds_namespace_project_commit_path(@project.namespace, @project, @commit.id), class: "ci-status-link ci-status-icon-#{@commit.status}" do
= ci_icon_for_status(@commit.status)
- = ci_label_for_status(@commit.status)
- - if @commit.pipelines.duration
- in
- = time_interval_in_words @commit.pipelines.duration
+ %span.ci-status-label
+ = ci_label_for_status(@commit.status)
+ in
+ = time_interval_in_words @commit.pipelines.total_duration
.commit-box.content-block
%h3.commit-title
diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml
index 540689f4a61..20a85148ab5 100644
--- a/app/views/projects/commit/_pipeline.html.haml
+++ b/app/views/projects/commit/_pipeline.html.haml
@@ -1,5 +1,9 @@
-.row-content-block.build-content.middle-block
+.row-content-block.build-content.middle-block.pipeline-actions
.pull-right
+ .btn.btn-grouped.btn-white.toggle-pipeline-btn
+ %span.toggle-btn-text Hide
+ %span pipeline graph
+ %span.caret
- if can?(current_user, :update_pipeline, pipeline.project)
- if pipeline.builds.latest.failed.any?(&:retryable?)
= link_to "Retry failed", retry_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline.id), class: 'btn btn-grouped btn-primary', method: :post
@@ -23,6 +27,22 @@
in
= time_interval_in_words pipeline.duration
+.row-content-block.build-content.middle-block.pipeline-graph
+ .pipeline-visualization
+ %ul.stage-column-list
+ - stages = pipeline.stages_with_latest_statuses
+ - stages.each do |stage, statuses|
+ %li.stage-column
+ .stage-name
+ %a{name: stage}
+ - if stage
+ = stage.titleize
+ .builds-container
+ %ul
+ - statuses.each do |status|
+ = render "projects/#{status.to_partial_path}_pipeline", subject: status
+
+
- if pipeline.yaml_errors.present?
.bs-callout.bs-callout-danger
%h4 Found errors in your .gitlab-ci.yml:
@@ -46,5 +66,5 @@
- if pipeline.project.build_coverage_enabled?
%th Coverage
%th
- - pipeline.statuses.stages.each do |stage|
- = render 'projects/commit/ci_stage', stage: stage, statuses: pipeline.statuses.where(stage: stage)
+ - pipeline.statuses.relevant.stages.each do |stage|
+ = render 'projects/commit/ci_stage', stage: stage, statuses: pipeline.statuses.relevant.where(stage: stage)
diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml
new file mode 100644
index 00000000000..29f4ef8f49e
--- /dev/null
+++ b/app/views/projects/commit/_pipelines_list.haml
@@ -0,0 +1,17 @@
+%ul.content-list.pipelines
+ - if pipelines.blank?
+ %li
+ .nothing-here-block No pipelines to show
+ - else
+ .table-holder
+ %table.table.builds
+ %tbody
+ %th Status
+ %th Commit
+ - pipelines.stages.each do |stage|
+ %th.stage
+ %span.has-tooltip{ title: "#{stage.titleize}" }
+ = stage.titleize
+ %th
+ %th
+ = render pipelines, commit_sha: true, stage: true, allow_retry: true, stages: pipelines.stages, status_icon_only: true, hide_branch: true
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index d0da2606587..ed44d86a687 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -7,7 +7,7 @@
= render "ci_menu"
- else
%div.block-connector
-= render "projects/diffs/diffs", diffs: @diffs, project: @project, diff_refs: @commit.diff_refs
+= render "projects/diffs/diffs", diffs: @diffs
= render "projects/notes/notes_with_form"
- if can_collaborate_with_project?
- %w(revert cherry-pick).each do |type|
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index af09b3418ea..d79336f5a60 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -1,7 +1,7 @@
= form_tag namespace_project_compare_index_path(@project.namespace, @project), method: :post, class: 'form-inline js-requires-input' do
.clearfix
- if params[:to] && params[:from]
- = link_to 'switch', {from: params[:to], to: params[:from]}, {class: 'commits-compare-switch has-tooltip', title: 'Switch base of comparison'}
+ = link_to icon('exchange'), {from: params[:to], to: params[:from]}, {class: 'commits-compare-switch has-tooltip', title: 'Switch base of comparison'}
.form-group.dropdown.compare-form-group.js-compare-from-dropdown
.input-group.inline-input-group
%span.input-group-addon from
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index 28a50e7031a..819e9bc15ae 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -8,7 +8,7 @@
- if @commits.present?
= render "projects/commits/commit_list"
- = render "projects/diffs/diffs", diffs: @diffs, project: @project, diff_refs: @diff_refs
+ = render "projects/diffs/diffs", diffs: @diffs
- else
.light-well
.center
diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml
index f70dba224fa..f7bf3b834ef 100644
--- a/app/views/projects/deployments/_actions.haml
+++ b/app/views/projects/deployments/_actions.haml
@@ -2,9 +2,9 @@
.pull-right
- actions = deployment.manual_actions
- if actions.present?
- .btn-group.inline
- .btn-group
- %a.dropdown-toggle.btn.btn-default{type: 'button', 'data-toggle' => 'dropdown'}
+ .inline
+ .dropdown
+ %a.dropdown-new.btn.btn-default{type: 'button', 'data-toggle' => 'dropdown'}
= icon("play")
%b.caret
%ul.dropdown-menu.dropdown-menu-align-right
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 0f9d9512d88..28813babd7b 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -1,12 +1,16 @@
%div.branch-commit
- if deployment.ref
- = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace"
- &middot;
+ .icon-container
+ = deployment.tag? ? icon('tag') : icon('code-fork')
+ = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace branch-name"
+ .icon-container
+ = custom_icon("icon_commit")
= link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace"
%p.commit-title
%span
- if commit_title = deployment.commit_title
+ = author_avatar(deployment.commit, size: 20)
= link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message"
- else
Cant find HEAD commit for this branch
diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml
index baf02f1e6a0..cd95841ca5a 100644
--- a/app/views/projects/deployments/_deployment.html.haml
+++ b/app/views/projects/deployments/_deployment.html.haml
@@ -8,6 +8,7 @@
%td
- if deployment.deployable
= link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable] do
+ = user_avatar(user: deployment.user, size: 20)
= "#{deployment.deployable.name} (##{deployment.deployable.id})"
%td
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index a1b071f130c..d37961c4e40 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -13,7 +13,7 @@
.nothing-here-block.diff-collapsed{data: { diff_for_path: url } }
This diff is collapsed. Click to expand it.
- elsif diff_file.diff_lines.length > 0
- - if diff_view == 'parallel'
+ - if diff_view == :parallel
= render "projects/diffs/parallel_view", diff_file: diff_file, project: project, blob: blob
- else
= render "projects/diffs/text_file", diff_file: diff_file
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index 4bf3ccace20..62aff36aadd 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -1,20 +1,19 @@
- show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true)
-- if diff_view == 'parallel'
+- diff_files = diffs.diff_files
+- if diff_view == :parallel
- fluid_layout true
-- diff_files = safe_diff_files(diffs, diff_refs: diff_refs, repository: project.repository)
-
.content-block.oneline-block.files-changed
.inline-parallel-buttons
- if !expand_all_diffs? && diff_files.any? { |diff_file| diff_file.collapsed? }
= link_to 'Expand all', url_for(params.merge(expand_all_diffs: 1, format: nil)), class: 'btn btn-default'
- if show_whitespace_toggle
- if current_controller?(:commit)
- = commit_diff_whitespace_link(@project, @commit, class: 'hidden-xs')
+ = commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs')
- elsif current_controller?(:merge_requests)
- = diff_merge_request_whitespace_link(@project, @merge_request, class: 'hidden-xs')
+ = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'hidden-xs')
- elsif current_controller?(:compare)
- = diff_compare_whitespace_link(@project, params[:from], params[:to], class: 'hidden-xs')
+ = diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'hidden-xs')
.btn-group
= inline_diff_btn
= parallel_diff_btn
@@ -23,12 +22,12 @@
- if diff_files.overflow?
= render 'projects/diffs/warning', diff_files: diff_files
-.files{data: {can_create_note: (!@diff_notes_disabled && can?(current_user, :create_note, @project))}}
+.files{data: {can_create_note: (!@diff_notes_disabled && can?(current_user, :create_note, diffs.project))}}
- diff_files.each_with_index do |diff_file, index|
- diff_commit = commit_for_diff(diff_file)
- blob = diff_file.blob(diff_commit)
- next unless blob
- - blob.load_all_data!(project.repository) unless blob.only_display_raw?
+ - blob.load_all_data!(diffs.project.repository) unless blob.only_display_raw?
- = render 'projects/diffs/file', i: index, project: project,
- diff_file: diff_file, diff_commit: diff_commit, blob: blob, diff_refs: diff_refs
+ = render 'projects/diffs/file', index: index, project: diffs.project,
+ diff_file: diff_file, diff_commit: diff_commit, blob: blob
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 1854c64cbd7..ad2eb3e504f 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -1,6 +1,6 @@
-.diff-file.file-holder{id: "diff-#{i}", data: diff_file_html_data(project, diff_file)}
+.diff-file.file-holder{id: "diff-#{index}", data: diff_file_html_data(project, diff_file.file_path, diff_commit.id)}
.file-title{id: "file-path-#{hexdigest(diff_file.file_path)}"}
- = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "#diff-#{i}"
+ = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "#diff-#{index}"
- unless diff_file.submodule?
.file-actions.hidden-xs
@@ -10,11 +10,10 @@
\
- if editable_diff?(diff_file)
- = edit_blob_link(@merge_request.source_project,
- @merge_request.source_branch, diff_file.new_path,
- from_merge_request_id: @merge_request.id,
- skip_visible_check: true)
+ - link_opts = @merge_request.id ? { from_merge_request_id: @merge_request.id } : {}
+ = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path,
+ blob: blob, link_opts: link_opts)
- = view_file_btn(diff_commit.id, diff_file, project)
+ = view_file_btn(diff_commit.id, diff_file.new_path, project)
- = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, diff_refs: diff_refs, blob: blob, project: project
+ = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index 4d3af905b58..7042e9f1fc9 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -1,11 +1,11 @@
+- email = local_assigns.fetch(:email, false)
- plain = local_assigns.fetch(:plain, false)
- type = line.type
-- line_code = diff_file.line_code(line) unless plain
+- line_code = diff_file.line_code(line)
%tr.line_holder{ plain ? { class: type} : { class: type, id: line_code } }
- case type
- when 'match'
- = render "projects/diffs/match_line", { line: line.text,
- line_old: line.old_pos, line_new: line.new_pos, bottom: false, new_file: diff_file.new_file }
+ = diff_match_line line.old_pos, line.new_pos, text: line.text
- when 'nonewline'
%td.old_line.diff-line-num
%td.new_line.diff-line-num
@@ -23,4 +23,15 @@
= link_text
- else
%a{href: "##{line_code}", data: { linenumber: link_text }}
- %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }= diff_line_content(line.text, type)
+ %td.line_content.noteable_line{ class: type, data: (diff_view_line_data(line_code, diff_file.position(line), type) unless plain) }<
+ - if email
+ %pre= diff_line_content(line.text, type)
+ - else
+ = diff_line_content(line.text, type)
+
+- discussions = local_assigns.fetch(:discussions, nil)
+- if discussions && !line.meta?
+ - discussion = discussions[line_code]
+ - if discussion
+ - discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?)
+ = render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded
diff --git a/app/views/projects/diffs/_match_line.html.haml b/app/views/projects/diffs/_match_line.html.haml
deleted file mode 100644
index d6dddd97879..00000000000
--- a/app/views/projects/diffs/_match_line.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-%td.old_line.diff-line-num{data: {linenumber: line_old},
- class: [unfold_bottom_class(bottom), unfold_class(!new_file)]}
- \...
-%td.new_line.diff-line-num{data: {linenumber: line_new},
- class: [unfold_bottom_class(bottom), unfold_class(!new_file)]}
- \...
-%td.line_content.match= line
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index 7f30faa20d8..28aad3f4725 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -1,14 +1,15 @@
/ Side-by-side diff view
%div.text-file.diff-wrap-lines.code.file-content.js-syntax-highlight{ data: diff_view_data }
%table
+ - last_line = 0
- diff_file.parallel_diff_lines.each do |line|
- left = line[:left]
- right = line[:right]
+ - last_line = right.new_pos if right
%tr.line_holder.parallel
- if left
- if left.meta?
- %td.old_line.diff-line-num.empty-cell
- %td.line_content.parallel.match= left.text
+ = diff_match_line left.old_pos, nil, text: left.text, view: :parallel
- else
- left_line_code = diff_file.line_code(left)
- left_position = diff_file.position(left)
@@ -21,8 +22,7 @@
- if right
- if right.meta?
- %td.old_line.diff-line-num.empty-cell
- %td.line_content.parallel.match= left.text
+ = diff_match_line nil, right.new_pos, text: left.text, view: :parallel
- else
- right_line_code = diff_file.line_code(right)
- right_position = diff_file.position(right)
@@ -37,3 +37,5 @@
- discussion_left, discussion_right = parallel_diff_discussions(left, right, diff_file)
- if discussion_left || discussion_right
= render "discussions/parallel_diff_discussion", discussion_left: discussion_left, discussion_right: discussion_right
+ - if !diff_file.new_file && last_line > 0
+ = diff_match_line last_line, last_line, bottom: true, view: :parallel
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 5970b9abf2b..f1d2d4bf268 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -5,16 +5,12 @@
%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
- last_line = 0
- - diff_file.highlighted_diff_lines.each do |line|
- - last_line = line.new_pos
- = render "projects/diffs/line", line: line, diff_file: diff_file
+ - discussions = @grouped_diff_discussions unless @diff_notes_disabled
+ = render partial: "projects/diffs/line",
+ collection: diff_file.highlighted_diff_lines,
+ as: :line,
+ locals: { diff_file: diff_file, discussions: discussions }
- - unless @diff_notes_disabled
- - line_code = diff_file.line_code(line)
- - discussion = @grouped_diff_discussions[line_code] if line_code
- - if discussion
- = render "discussions/diff_discussion", discussion: discussion
-
- - if last_line > 0
- = render "projects/diffs/match_line", { line: "",
- line_old: last_line, line_new: last_line, bottom: true, new_file: diff_file.new_file }
+ - last_line = diff_file.highlighted_diff_lines.last.new_pos
+ - if !diff_file.new_file && last_line > 0
+ = diff_match_line last_line, last_line, bottom: true
diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml
index e2453395602..36a6162a5a8 100644
--- a/app/views/projects/environments/_environment.html.haml
+++ b/app/views/projects/environments/_environment.html.haml
@@ -2,8 +2,12 @@
%tr.environment
%td
- %strong
- = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment)
+ = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment)
+
+ %td
+ - if last_deployment
+ = user_avatar(user: last_deployment.user, size: 20)
+ %strong ##{last_deployment.id}
%td
- if last_deployment
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index a6dd34653ab..b3eb5b0011a 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -23,10 +23,11 @@
New environment
- else
.table-holder
- %table.table.environments
+ %table.table.builds.environments
%tbody
%th Environment
- %th Last deployment
- %th Date
+ %th Last Deployment
+ %th Commit
+ %th
%th
= render @environments
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index a07436ad7c9..8f8c1c4ce22 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -23,13 +23,13 @@
= link_to "Read more", help_page_path("ci/environments"), class: "btn btn-success"
- else
.table-holder
- %table.table.environments
+ %table.table.builds.environments
%thead
%tr
%th ID
%th Commit
%th Build
- %th Date
+ %th
%th
= render @deployments
diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml
new file mode 100644
index 00000000000..584c0fa18ae
--- /dev/null
+++ b/app/views/projects/generic_commit_statuses/_generic_commit_status_pipeline.html.haml
@@ -0,0 +1,9 @@
+%li.build
+ .build-content
+ - if subject.target_url
+ - link_to subject.target_url do
+ = render_status_with_link('commit status', subject.status)
+ = subject.name
+ - else
+ = render_status_with_link('commit status', subject.status)
+ = subject.name
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index a985b442b2d..ac5f792d140 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -32,7 +32,7 @@
:javascript
$.ajax({
type: "GET",
- url: location.href,
+ url: "#{namespace_project_graph_path(@project.namespace, @project, current_ref, format: :json)}",
dataType: "json",
success: function (data) {
var graph = new ContributorsStatGraph();
diff --git a/app/views/projects/group_links/index.html.haml b/app/views/projects/group_links/index.html.haml
index 2b904544f28..ca700cb3a3b 100644
--- a/app/views/projects/group_links/index.html.haml
+++ b/app/views/projects/group_links/index.html.haml
@@ -17,6 +17,13 @@
.select-wrapper
= select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
%span.caret
+ .form-group
+ = label_tag :expires_at, 'Access expiration date', class: 'label-light'
+ .clearable-input
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date'
+ %i.clear-icon.js-clear-input
+ .help-block
+ On this date, all users in the group will automatically lose access to this project.
= submit_tag "Share", class: "btn btn-create"
.col-lg-9.col-lg-offset-3
%hr
@@ -35,6 +42,10 @@
= group.name
%br
up to #{group_link.human_access}
+ - if group_link.expires?
+ ยท
+ %span{ class: ('text-warning' if group_link.expires_soon?) }
+ expires in #{distance_of_time_in_words_to_now(group_link.expires_at)}
.pull-right
= link_to namespace_project_group_link_path(@project.namespace, @project, group_link), method: :delete, class: "btn btn-transparent" do
%span.sr-only disable sharing
diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml
index 403adb7426b..b6cb559afcb 100644
--- a/app/views/projects/issues/_head.html.haml
+++ b/app/views/projects/issues/_head.html.haml
@@ -2,10 +2,15 @@
%ul{ class: (container_class) }
- if project_nav_tab?(:issues) && !current_controller?(:merge_requests)
= nav_link(controller: :issues) do
- = link_to url_for_project_issues(@project, only_path: true), title: 'Issues' do
+ = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues' do
%span
Issues
+ = nav_link(controller: :boards) do
+ = link_to namespace_project_board_path(@project.namespace, @project), title: 'Board' do
+ %span
+ Board
+
- if project_nav_tab?(:merge_requests) && current_controller?(:merge_requests)
= nav_link(controller: :merge_requests) do
= link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index e5cce16a171..3fb4191c60e 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -22,7 +22,7 @@
- if can?(current_user, :create_issue, @project) || can?(current_user, :update_issue, @issue)
.issuable-actions
.clearfix.issue-btn-group.dropdown
- %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ data: { toggle: "dropdown" } }
+ %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
%span.caret
Options
.dropdown-menu.dropdown-menu-align-right.hidden-lg
@@ -37,14 +37,19 @@
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%li
= link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue)
+ - if @issue.submittable_as_spam? && current_user.admin?
+ %li
+ = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
+
- if can?(current_user, :create_issue, @project)
= link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do
New issue
- if can?(current_user, :update_issue, @issue)
= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, status_only: true, format: 'json'), data: {no_turbolink: true}, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
- = link_to edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' do
- Edit
+ - if @issue.submittable_as_spam? && current_user.admin?
+ = link_to 'Submit as spam', mark_as_spam_namespace_project_issue_path(@project.namespace, @project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
+ = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
.issue-details.issuable-details
diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
index 53dd300c35c..d070979bcfe 100644
--- a/app/views/projects/merge_requests/_discussion.html.haml
+++ b/app/views/projects/merge_requests/_discussion.html.haml
@@ -4,5 +4,8 @@
= link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"}
- if @merge_request.closed?
= link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
+ %comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" }
+ %button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { namespace_path: "#{@merge_request.project.namespace.path}", project_path: "#{@merge_request.project.path}" } }
+ {{ buttonText }}
#notes= render "projects/notes/notes_with_form"
diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml
index a5e67b95727..00bd4e143df 100644
--- a/app/views/projects/merge_requests/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/_new_submit.html.haml
@@ -20,7 +20,7 @@
.mr-compare.merge-request
%ul.merge-request-tabs.nav-links.no-top.no-bottom
%li.commits-tab
- = link_to url_for(params), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do
+ = link_to url_for(params), data: {target: 'div#commits', action: 'new', toggle: 'tab'} do
Commits
%span.badge= @commits.size
- if @pipeline
@@ -42,7 +42,7 @@
%h4 This comparison includes more than #{MergeRequestDiff::COMMITS_SAFE_SIZE} commits.
%p To preserve performance the line changes are not shown.
- else
- = render "projects/diffs/diffs", diffs: @diffs, project: @project, diff_refs: @merge_request.diff_refs, show_whitespace_toggle: false
+ = render "projects/diffs/diffs", diffs: @diffs, show_whitespace_toggle: false
- if @pipeline
#builds.builds.tab-pane
= render "projects/merge_requests/show/builds"
@@ -52,11 +52,8 @@
$('#merge_request_assignee_id').val("#{current_user.id}").trigger("change");
e.preventDefault();
});
-
:javascript
- var merge_request
- merge_request = new MergeRequest({
- action: 'new',
- diffs_loaded: true,
- commits_loaded: true
+ var merge_request = new MergeRequest({
+ action: "#{(@show_changes_tab ? 'diffs' : 'new')}",
+ setUrl: false
});
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
index 873ed9b59ee..9d8b4cc56be 100644
--- a/app/views/projects/merge_requests/_show.html.haml
+++ b/app/views/projects/merge_requests/_show.html.haml
@@ -1,8 +1,10 @@
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
- page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('diff_notes/diff_notes_bundle.js')
-- if diff_view == 'parallel'
+- if diff_view == :parallel
- fluid_layout true
.merge-request{'data-url' => merge_request_path(@merge_request)}
@@ -14,6 +16,9 @@
- if @merge_request.open?
.pull-right
- if @merge_request.source_branch_exists?
+ - if koding_enabled? && @repository.koding_yml
+ = link_to koding_project_url(@merge_request.source_project, @merge_request.source_branch, @merge_request.commits.first.short_id), class: "btn inline btn-grouped btn-sm", target: '_blank' do
+ Run in IDE (Koding)
= link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do
Check out branch
@@ -45,24 +50,38 @@
- if @commits_count.nonzero?
%ul.merge-request-tabs.nav-links.no-top.no-bottom
%li.notes-tab
- = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#notes', action: 'notes', toggle: 'tab'} do
+ = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'notes', toggle: 'tab' } do
Discussion
%span.badge= @merge_request.mr_and_commit_notes.user.count
%li.commits-tab
- = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#commits', action: 'commits', toggle: 'tab'} do
+ = link_to commits_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do
Commits
%span.badge= @commits_count
- if @pipeline
+ %li.pipelines-tab
+ = link_to pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do
+ Pipelines
+ %span.badge= @merge_request.all_pipelines.size
%li.builds-tab
- = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: '#builds', action: 'builds', toggle: 'tab'} do
+ = link_to builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: '#builds', action: 'builds', toggle: 'tab' } do
Builds
%span.badge= @statuses.size
%li.diffs-tab
- = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do
+ = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do
Changes
%span.badge= @merge_request.diff_size
+ %li#resolve-count-app.line-resolve-all-container.pull-right.prepend-top-10.hidden-xs{ "v-cloak" => true }
+ %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" }
+ .line-resolve-all{ "v-show" => "discussionCount > 0",
+ ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" }
+ %span.line-resolve-btn.is-disabled{ type: "button",
+ ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" }
+ = render "shared/icons/icon_status_success.svg"
+ %span.line-resolve-text
+ {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ discussionCount | pluralize 'discussion' }} resolved
+ = render "discussions/jump_to_next"
- .tab-content
+ .tab-content#diff-notes-app
#notes.notes.tab-pane.voting_notes
.content-block.content-block-small.oneline-block
= render 'award_emoji/awards_block', awardable: @merge_request, inline: true
@@ -76,6 +95,8 @@
- # This tab is always loaded via AJAX
#builds.builds.tab-pane
- # This tab is always loaded via AJAX
+ #pipelines.pipelines.tab-pane
+ - # This tab is always loaded via AJAX
#diffs.diffs.tab-pane
- # This tab is always loaded via AJAX
diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
new file mode 100644
index 00000000000..a524936f73c
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts.html.haml
@@ -0,0 +1,29 @@
+- class_bindings = "{ |
+ 'head': line.isHead, |
+ 'origin': line.isOrigin, |
+ 'match': line.hasMatch, |
+ 'selected': line.isSelected, |
+ 'unselected': line.isUnselected }"
+
+- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
+= render "projects/merge_requests/show/mr_title"
+
+.merge-request-details.issuable-details
+ = render "projects/merge_requests/show/mr_box"
+
+= render 'shared/issuable/sidebar', issuable: @merge_request
+
+#conflicts{"v-cloak" => "true", data: { conflicts_path: conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request, format: :json),
+ resolve_conflicts_path: resolve_conflicts_namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request) } }
+ .loading{"v-if" => "isLoading"}
+ %i.fa.fa-spinner.fa-spin
+
+ .nothing-here-block{"v-if" => "hasError"}
+ {{conflictsData.errorMessage}}
+
+ = render partial: "projects/merge_requests/conflicts/commit_stats"
+
+ .files-wrapper{"v-if" => "!isLoading && !hasError"}
+ = render partial: "projects/merge_requests/conflicts/parallel_view", locals: { class_bindings: class_bindings }
+ = render partial: "projects/merge_requests/conflicts/inline_view", locals: { class_bindings: class_bindings }
+ = render partial: "projects/merge_requests/conflicts/submit_form"
diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
new file mode 100644
index 00000000000..457c467fba9
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
@@ -0,0 +1,20 @@
+.content-block.oneline-block.files-changed{"v-if" => "!isLoading && !hasError"}
+ .inline-parallel-buttons
+ .btn-group
+ %a.btn{ |
+ ":class" => "{'active': !isParallel}", |
+ "@click" => "handleViewTypeChange('inline')"}
+ Inline
+ %a.btn{ |
+ ":class" => "{'active': isParallel}", |
+ "@click" => "handleViewTypeChange('parallel')"}
+ Side-by-side
+
+ .js-toggle-container
+ .commit-stat-summary
+ Showing
+ %strong.cred {{conflictsCount}} {{conflictsData.conflictsText}}
+ between
+ %strong {{conflictsData.source_branch}}
+ and
+ %strong {{conflictsData.target_branch}}
diff --git a/app/views/projects/merge_requests/conflicts/_inline_view.html.haml b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml
new file mode 100644
index 00000000000..19c7da4b5e3
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_inline_view.html.haml
@@ -0,0 +1,28 @@
+.files{"v-show" => "!isParallel"}
+ .diff-file.file-holder.conflict.inline-view{"v-for" => "file in conflictsData.files"}
+ .file-title
+ %i.fa.fa-fw{":class" => "file.iconClass"}
+ %strong {{file.filePath}}
+ .file-actions
+ %a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
+ View file @{{conflictsData.shortCommitSha}}
+
+ .diff-content.diff-wrap-lines
+ .diff-wrap-lines.code.file-content.js-syntax-highlight
+ %table
+ %tr.line_holder.diff-inline{"v-for" => "line in file.inlineLines"}
+ %template{"v-if" => "!line.isHeader"}
+ %td.diff-line-num.new_line{":class" => class_bindings}
+ %a {{line.new_line}}
+ %td.diff-line-num.old_line{":class" => class_bindings}
+ %a {{line.old_line}}
+ %td.line_content{":class" => class_bindings}
+ {{{line.richText}}}
+
+ %template{"v-if" => "line.isHeader"}
+ %td.diff-line-num.header{":class" => class_bindings}
+ %td.diff-line-num.header{":class" => class_bindings}
+ %td.line_content.header{":class" => class_bindings}
+ %strong {{{line.richText}}}
+ %button.btn{"@click" => "handleSelected(line.id, line.section)"}
+ {{line.buttonTitle}}
diff --git a/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml
new file mode 100644
index 00000000000..2e6f67c2eaf
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_parallel_view.html.haml
@@ -0,0 +1,27 @@
+.files{"v-show" => "isParallel"}
+ .diff-file.file-holder.conflict.parallel-view{"v-for" => "file in conflictsData.files"}
+ .file-title
+ %i.fa.fa-fw{":class" => "file.iconClass"}
+ %strong {{file.filePath}}
+ .file-actions
+ %a.btn.view-file.btn-file-option{":href" => "file.blobPath"}
+ View file @{{conflictsData.shortCommitSha}}
+
+ .diff-content.diff-wrap-lines
+ .diff-wrap-lines.code.file-content.js-syntax-highlight
+ %table
+ %tr.line_holder.parallel{"v-for" => "section in file.parallelLines"}
+ %template{"v-for" => "line in section"}
+
+ %template{"v-if" => "line.isHeader"}
+ %td.diff-line-num.header{":class" => class_bindings}
+ %td.line_content.header{":class" => class_bindings}
+ %strong {{line.richText}}
+ %button.btn{"@click" => "handleSelected(line.id, line.section)"}
+ {{line.buttonTitle}}
+
+ %template{"v-if" => "!line.isHeader"}
+ %td.diff-line-num.old_line{":class" => class_bindings}
+ {{line.lineNumber}}
+ %td.line_content.parallel{":class" => class_bindings}
+ {{{line.richText}}}
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
new file mode 100644
index 00000000000..78bd4133ea2
--- /dev/null
+++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
@@ -0,0 +1,15 @@
+.content-block.oneline-block.files-changed
+ %strong.resolved-count {{resolvedCount}}
+ of
+ %strong.total-count {{conflictsCount}}
+ conflicts have been resolved
+
+ .commit-message-container.form-group
+ .max-width-marker
+ %textarea.form-control.js-commit-message{"v-model" => "conflictsData.commitMessage"}
+ {{{conflictsData.commitMessage}}}
+
+ %button{type: "button", class: "btn btn-success js-submit-button", ":disabled" => "!readyToCommit", "@click" => "commit()"}
+ %span {{commitButtonText}}
+
+ = link_to "Cancel", namespace_project_merge_request_path(@merge_request.project.namespace, @merge_request.project, @merge_request), class: "btn btn-cancel"
diff --git a/app/views/projects/merge_requests/show/_builds.html.haml b/app/views/projects/merge_requests/show/_builds.html.haml
index 81de60f116c..808ef7fed27 100644
--- a/app/views/projects/merge_requests/show/_builds.html.haml
+++ b/app/views/projects/merge_requests/show/_builds.html.haml
@@ -1,2 +1 @@
= render "projects/commit/pipeline", pipeline: @pipeline, link_to_commit: true
-
diff --git a/app/views/projects/merge_requests/show/_diffs.html.haml b/app/views/projects/merge_requests/show/_diffs.html.haml
index 1b0bae86ad4..013b05628fa 100644
--- a/app/views/projects/merge_requests/show/_diffs.html.haml
+++ b/app/views/projects/merge_requests/show/_diffs.html.haml
@@ -1,6 +1,5 @@
- if @merge_request_diff.collected?
- = render "projects/diffs/diffs", diffs: @merge_request.diffs(diff_options),
- project: @merge_request.project, diff_refs: @merge_request.diff_refs
+ = render "projects/diffs/diffs", diffs: @diffs
- elsif @merge_request_diff.empty?
.nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch}
- else
diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml
index b24bdf22ceb..098ce19da21 100644
--- a/app/views/projects/merge_requests/show/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/show/_mr_title.html.haml
@@ -14,7 +14,7 @@
- if can?(current_user, :update_merge_request, @merge_request)
.issuable-actions
.clearfix.issue-btn-group.dropdown
- %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ data: { toggle: "dropdown" } }
+ %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
%span.caret
Options
.dropdown-menu.dropdown-menu-align-right.hidden-lg
diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml
new file mode 100644
index 00000000000..afe3f3430c6
--- /dev/null
+++ b/app/views/projects/merge_requests/show/_pipelines.html.haml
@@ -0,0 +1 @@
+= render "projects/commit/pipelines_list", pipelines: @pipelines, link_to_commit: true
diff --git a/app/views/projects/merge_requests/widget/_heading.html.haml b/app/views/projects/merge_requests/widget/_heading.html.haml
index 6ef640bb654..494695a03a5 100644
--- a/app/views/projects/merge_requests/widget/_heading.html.haml
+++ b/app/views/projects/merge_requests/widget/_heading.html.haml
@@ -42,3 +42,16 @@
.ci_widget.ci-error{style: "display:none"}
= icon("times-circle")
Could not connect to the CI server. Please check your settings and try again.
+
+- @merge_request.environments.each do |environment|
+ .mr-widget-heading
+ .ci_widget.ci-success
+ = ci_icon_for_status("success")
+ %span.hidden-sm
+ Deployed to
+ = succeed '.' do
+ = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment), class: 'environment'
+ - external_url = environment.external_url
+ - if external_url
+ = link_to external_url, target: '_blank' do
+ = icon('external-link', text: "View on #{external_url.gsub(/\A.*?:\/\//, '')}", right: true)
diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml
index 19b5d0ff066..7794d6d7df2 100644
--- a/app/views/projects/merge_requests/widget/_merged.html.haml
+++ b/app/views/projects/merge_requests/widget/_merged.html.haml
@@ -6,7 +6,7 @@
- if @merge_request.merge_event
by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)}
#{time_ago_with_tooltip(@merge_request.merge_event.created_at)}
- - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true')
+ - if !@merge_request.source_branch_exists? || params[:deleted_source_branch]
%p
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml
index dc18f715f25..6f5ee5f16c5 100644
--- a/app/views/projects/merge_requests/widget/_open.html.haml
+++ b/app/views/projects/merge_requests/widget/_open.html.haml
@@ -1,6 +1,12 @@
.mr-state-widget
= render 'projects/merge_requests/widget/heading'
.mr-widget-body
+ -# After conflicts are resolved, the user is redirected back to the MR page.
+ -# There is a short window before background workers run and GitLab processes
+ -# the new push and commits, during which it will think the conflicts still exist.
+ -# We send this param to get the widget to treat the MR as having no more conflicts.
+ - resolved_conflicts = params[:resolved_conflicts]
+
- if @project.archived?
= render 'projects/merge_requests/widget/open/archived'
- elsif @merge_request.commits.blank?
@@ -9,7 +15,7 @@
= render 'projects/merge_requests/widget/open/missing_branch'
- elsif @merge_request.unchecked?
= render 'projects/merge_requests/widget/open/check'
- - elsif @merge_request.cannot_be_merged?
+ - elsif @merge_request.cannot_be_merged? && !resolved_conflicts
= render 'projects/merge_requests/widget/open/conflicts'
- elsif @merge_request.work_in_progress?
= render 'projects/merge_requests/widget/open/wip'
@@ -19,7 +25,7 @@
= render 'projects/merge_requests/widget/open/not_allowed'
- elsif !@merge_request.mergeable_ci_state? && @pipeline && @pipeline.failed?
= render 'projects/merge_requests/widget/open/build_failed'
- - elsif @merge_request.can_be_merged?
+ - elsif @merge_request.can_be_merged? || resolved_conflicts
= render 'projects/merge_requests/widget/open/accept'
- if mr_closes_issues.present?
diff --git a/app/views/projects/merge_requests/widget/_show.html.haml b/app/views/projects/merge_requests/widget/_show.html.haml
index d9efe81701f..ea618263a4a 100644
--- a/app/views/projects/merge_requests/widget/_show.html.haml
+++ b/app/views/projects/merge_requests/widget/_show.html.haml
@@ -23,7 +23,8 @@
preparing: "{{status}} build",
normal: "Build {{status}}"
},
- builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
+ builds_path: "#{builds_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}",
+ pipelines_path: "#{pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)}"
};
if (typeof merge_request_widget !== 'undefined') {
diff --git a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
index f000cc38a65..af3096f04d9 100644
--- a/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
+++ b/app/views/projects/merge_requests/widget/open/_conflicts.html.haml
@@ -3,7 +3,18 @@
This merge request contains merge conflicts
%p
- Please resolve these conflicts or
+ Please
+ - if @merge_request.conflicts_can_be_resolved_by?(current_user)
+ - if @merge_request.conflicts_can_be_resolved_in_ui?
+ = link_to "resolve these conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
+ - else
+ %span.has-tooltip{title: "These conflicts cannot be resolved through GitLab"}
+ resolve these conflicts locally
+ - else
+ resolve these conflicts
+
+ or
+
- if @merge_request.can_be_merged_via_command_line_by?(current_user)
#{link_to "merge this request manually", "#modal_merge_info", class: "how_to_merge_link vlink", "data-toggle" => "modal"}.
- else
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index facdfcc9447..ea4898f2107 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -46,28 +46,18 @@
%div
- if github_import_enabled?
= link_to new_import_github_path, class: 'btn import_github' do
- = icon 'github', text: 'GitHub'
+ = icon('github', text: 'GitHub')
%div
- if bitbucket_import_enabled?
- - if bitbucket_import_configured?
- = link_to status_import_bitbucket_path, class: 'btn import_bitbucket', "data-no-turbolink" => "true" do
- %i.fa.fa-bitbucket
- Bitbucket
- - else
- = link_to status_import_bitbucket_path, class: 'how_to_import_link btn import_bitbucket', "data-no-turbolink" => "true" do
- %i.fa.fa-bitbucket
- Bitbucket
+ = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}", "data-no-turbolink" => "true" do
+ = icon('bitbucket', text: 'Bitbucket')
+ - unless bitbucket_import_configured?
= render 'bitbucket_import_modal'
%div
- if gitlab_import_enabled?
- - if gitlab_import_configured?
- = link_to status_import_gitlab_path, class: 'btn import_gitlab' do
- %i.fa.fa-heart
- GitLab.com
- - else
- = link_to status_import_gitlab_path, class: 'how_to_import_link btn import_gitlab' do
- %i.fa.fa-heart
- GitLab.com
+ = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless bitbucket_import_configured?}" do
+ = icon('gitlab', text: 'GitLab.com')
+ - unless gitlab_import_configured?
= render 'gitlab_import_modal'
%div
- if gitorious_import_enabled?
@@ -77,23 +67,19 @@
%div
- if google_code_import_enabled?
= link_to new_import_google_code_path, class: 'btn import_google_code' do
- %i.fa.fa-google
- Google Code
+ = icon('google', text: 'Google Code')
%div
- if fogbugz_import_enabled?
= link_to new_import_fogbugz_path, class: 'btn import_fogbugz' do
- %i.fa.fa-bug
- Fogbugz
+ = icon('bug', text: 'Fogbugz')
%div
- if git_import_enabled?
= link_to "#", class: 'btn js-toggle-button import_git' do
- %i.fa.fa-git
- %span Repo by URL
+ = icon('git', text: 'Repo by URL')
%div{ class: 'import_gitlab_project' }
- - if gitlab_project_import_enabled?
+ - if gitlab_project_import_enabled? && current_user.is_admin?
= link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit' do
- %i.fa.fa-gitlab
- %span GitLab export
+ = icon('gitlab', text: 'GitLab export')
.js-toggle-content.hide
= render "shared/import_form", f: f
@@ -159,4 +145,4 @@
$('.import_git').click(function( event ) {
$projectImportUrl = $('#project_import_url')
$projectImportUrl.attr('disabled', !$projectImportUrl.attr('disabled'))
- }); \ No newline at end of file
+ });
diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml
index 7c61ba750fe..402f5b52f5e 100644
--- a/app/views/projects/notes/_form.html.haml
+++ b/app/views/projects/notes/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form" }, authenticity_token: true do |f|
+= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
= hidden_field_tag :view, diff_view
= hidden_field_tag :line_type
= note_target_fields(@note)
@@ -10,8 +10,12 @@
= f.hidden_field :position
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
- = render 'projects/zen', f: f, attr: :note, classes: 'note-textarea js-note-text', placeholder: "Write a comment or drag your files here..."
- = render 'projects/notes/hints'
+ = render 'projects/zen', f: f,
+ attr: :note,
+ classes: 'note-textarea js-note-text',
+ placeholder: "Write a comment or drag your files here...",
+ supports_slash_commands: true
+ = render 'projects/notes/hints', supports_slash_commands: true
.error-alert
.note-form-actions.clearfix
diff --git a/app/views/projects/notes/_hints.html.haml b/app/views/projects/notes/_hints.html.haml
index 25466e7562e..6c14f48d41b 100644
--- a/app/views/projects/notes/_hints.html.haml
+++ b/app/views/projects/notes/_hints.html.haml
@@ -1,8 +1,15 @@
+- supports_slash_commands = local_assigns.fetch(:supports_slash_commands, false)
.comment-toolbar.clearfix
.toolbar-text
Styling with
- = link_to 'Markdown', help_page_path('markdown/markdown'), target: '_blank', tabindex: -1
- is supported
+ = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1
+ - if supports_slash_commands
+ and
+ = link_to 'slash commands', help_page_path('user/project/slash_commands'), target: '_blank', tabindex: -1
+ are
+ - else
+ is
+ supported
%button.toolbar-button.markdown-selector{ type: 'button', tabindex: '-1' }
= icon('file-image-o', class: 'toolbar-button-icon')
- Attach a file \ No newline at end of file
+ Attach a file
diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml
index 71da8ac9d7c..d2ac1ce2b9a 100644
--- a/app/views/projects/notes/_note.html.haml
+++ b/app/views/projects/notes/_note.html.haml
@@ -1,5 +1,6 @@
- return unless note.author
- return if note.cross_reference_not_visible_for?(current_user)
+- can_resolve = can?(current_user, :resolve_note, note)
- note_editable = note_editable?(note)
%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable} }
@@ -16,19 +17,48 @@
commented
%a{ href: "##{dom_id(note)}" }
= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
- .note-actions
- - access = note_max_access_for_user(note)
- - if access and not note.system
- %span.note-role.hidden-xs= access
- - if current_user and not note.system
- = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
- = icon('spinner spin')
- = icon('smile-o')
- - if note_editable
- = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
- = icon('pencil')
- = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do
- = icon('trash-o')
+ - unless note.system?
+ .note-actions
+ - access = note_max_access_for_user(note)
+ - if access
+ %span.note-role.hidden-xs= access
+
+ - if note.resolvable?
+ %resolve-btn{ ":namespace-path" => "'#{note.project.namespace.path}'",
+ ":project-path" => "'#{note.project.path}'",
+ ":discussion-id" => "'#{note.discussion_id}'",
+ ":note-id" => note.id,
+ ":resolved" => note.resolved?,
+ ":can-resolve" => can_resolve,
+ ":resolved-by" => "'#{note.resolved_by.try(:name)}'",
+ "v-show" => "#{can_resolve || note.resolved?}",
+ "inline-template" => true,
+ "v-ref:note_#{note.id}" => true }
+
+ .note-action-button
+ = icon("spin spinner", "v-show" => "loading")
+ %button.line-resolve-btn{ type: "button",
+ class: ("is-disabled" unless can_resolve),
+ ":class" => "{ 'is-active': isResolved }",
+ ":aria-label" => "buttonText",
+ "@click" => "resolve",
+ ":title" => "buttonText",
+ "v-show" => "!loading",
+ "v-el:button" => true }
+
+ = render "shared/icons/icon_status_success.svg"
+
+ - if current_user
+ - if note.emoji_awardable?
+ = link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
+ = icon('spinner spin')
+ = icon('smile-o')
+
+ - if note_editable
+ = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
+ = icon('pencil')
+ = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button hidden-xs js-note-delete danger' do
+ = icon('trash-o')
.note-body{class: note_editable ? 'js-task-list-container' : ''}
.note-text.md
= preserve do
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 8289aefcde7..063e83a407a 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -9,7 +9,7 @@
= link_to @pipeline.ref, namespace_project_commits_path(@project.namespace, @project, @pipeline.ref), class: "monospace"
- if @pipeline.duration
in
- = time_interval_in_words @pipeline.duration
+ = time_interval_in_words(@pipeline.duration)
.pull-right
= link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), class: "ci-status ci-#{@pipeline.status}" do
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index 5f4ec2e40c8..55202725b9e 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -9,7 +9,7 @@
.form-group
= f.label :ref, 'Create for', class: 'control-label'
.col-sm-10
- = f.text_field :ref, required: true, tabindex: 2, class: 'form-control'
+ = f.text_field :ref, required: true, tabindex: 2, class: 'form-control js-branch-name ui-autocomplete-input', autocomplete: :false, id: :ref
.help-block Existing branch name, tag
.form-actions
= f.submit 'Create pipeline', class: 'btn btn-create', tabindex: 3
diff --git a/app/views/projects/pipelines_settings/_badge.html.haml b/app/views/projects/pipelines_settings/_badge.html.haml
new file mode 100644
index 00000000000..7b7fa56d993
--- /dev/null
+++ b/app/views/projects/pipelines_settings/_badge.html.haml
@@ -0,0 +1,27 @@
+.row{ class: badge.title.gsub(' ', '-') }
+ .col-lg-3.profile-settings-sidebar
+ %h4.prepend-top-0
+ = badge.title.capitalize
+ .col-lg-9
+ .prepend-top-10
+ .panel.panel-default
+ .panel-heading
+ %b
+ = badge.title.capitalize
+ &middot;
+ = badge.to_html
+ .pull-right
+ = render 'shared/ref_switcher', destination: 'badges', align_right: true
+ .panel-body
+ .row
+ .col-md-2.text-center
+ Markdown
+ .col-md-10.code.js-syntax-highlight
+ = highlight('.md', badge.to_markdown)
+ .row
+ %hr
+ .row
+ .col-md-2.text-center
+ HTML
+ .col-md-10.code.js-syntax-highlight
+ = highlight('.html', badge.to_html)
diff --git a/app/views/projects/pipelines_settings/show.html.haml b/app/views/projects/pipelines_settings/show.html.haml
index 228bad36ebd..8c7222bfe3d 100644
--- a/app/views/projects/pipelines_settings/show.html.haml
+++ b/app/views/projects/pipelines_settings/show.html.haml
@@ -77,27 +77,4 @@
%hr
.row.prepend-top-default
- .col-lg-3.profile-settings-sidebar
- %h4.prepend-top-0
- Builds Badge
- .col-lg-9
- .prepend-top-10
- .panel.panel-default
- .panel-heading
- %b Builds badge &middot;
- = @build_badge.to_html
- .pull-right
- = render 'shared/ref_switcher', destination: 'badges', align_right: true
- .panel-body
- .row
- .col-md-2.text-center
- Markdown
- .col-md-10.code.js-syntax-highlight
- = highlight('.md', @build_badge.to_markdown)
- .row
- %hr
- .row
- .col-md-2.text-center
- HTML
- .col-md-10.code.js-syntax-highlight
- = highlight('.html', @build_badge.to_html)
+ = render partial: 'badge', collection: @badges
diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml
index 978c4dfc5ec..fa8cbf71733 100644
--- a/app/views/projects/project_members/_new_project_member.html.haml
+++ b/app/views/projects/project_members/_new_project_member.html.haml
@@ -14,5 +14,14 @@
Read more about role permissions
%strong= link_to "here", help_page_path("user/permissions"), class: "vlink"
+ .form-group
+ = f.label :expires_at, 'Access expiration date', class: 'control-label'
+ .col-sm-10
+ .clearable-input
+ = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date'
+ %i.clear-icon.js-clear-input
+ .help-block
+ On this date, the user(s) will automatically lose access to this project.
+
.form-actions
= f.submit 'Add users to project', class: "btn btn-create"
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 9031f01b496..9d063b3081f 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -1,6 +1,6 @@
- page_title "Members"
-.project-members-page.prepend-top-default
+.project-members-page.js-project-members-page.prepend-top-default
- if can?(current_user, :admin_project_member, @project)
.panel.panel-default
.panel-heading
diff --git a/app/views/projects/project_members/update.js.haml b/app/views/projects/project_members/update.js.haml
index 45f8ef89060..833954bc039 100644
--- a/app/views/projects/project_members/update.js.haml
+++ b/app/views/projects/project_members/update.js.haml
@@ -1,2 +1,3 @@
:plain
$("##{dom_id(@project_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @project_member))}');
+ new MemberExpirationDate();
diff --git a/app/views/projects/protected_branches/_branches_list.html.haml b/app/views/projects/protected_branches/_branches_list.html.haml
index 0603a014008..04b19a8c5a7 100644
--- a/app/views/projects/protected_branches/_branches_list.html.haml
+++ b/app/views/projects/protected_branches/_branches_list.html.haml
@@ -1,26 +1,28 @@
-%h5.prepend-top-0
- Already Protected (#{@protected_branches.size})
-- if @protected_branches.empty?
- %p.settings-message.text-center
- No branches are protected, protect a branch with the form above.
-- else
- - can_admin_project = can?(current_user, :admin_project, @project)
+.panel.panel-default.protected-branches-list
+ - if @protected_branches.empty?
+ .panel-heading
+ %h3.panel-title
+ Protected branch (#{@protected_branches.size})
+ %p.settings-message.text-center
+ There are currently no protected branches, protect a branch with the form above.
+ - else
+ - can_admin_project = can?(current_user, :admin_project, @project)
- %table.table.protected-branches-list
- %colgroup
- %col{ width: "20%" }
- %col{ width: "30%" }
- %col{ width: "25%" }
- %col{ width: "25%" }
- %thead
- %tr
- %th Branch
- %th Last commit
- %th Allowed to merge
- %th Allowed to push
- - if can_admin_project
- %th
- %tbody
- = render partial: @protected_branches, locals: { can_admin_project: can_admin_project }
+ %table.table.table-bordered
+ %colgroup
+ %col{ width: "25%" }
+ %col{ width: "30%" }
+ %col{ width: "25%" }
+ %col{ width: "20%" }
+ %thead
+ %tr
+ %th Protected branch (#{@protected_branches.size})
+ %th Last commit
+ %th Allowed to merge
+ %th Allowed to push
+ - if can_admin_project
+ %th
+ %tbody
+ = render partial: @protected_branches, locals: { can_admin_project: can_admin_project }
- = paginate @protected_branches, theme: 'gitlab'
+ = paginate @protected_branches, theme: 'gitlab'
diff --git a/app/views/projects/protected_branches/_create_protected_branch.html.haml b/app/views/projects/protected_branches/_create_protected_branch.html.haml
new file mode 100644
index 00000000000..e95a3b1b4c3
--- /dev/null
+++ b/app/views/projects/protected_branches/_create_protected_branch.html.haml
@@ -0,0 +1,41 @@
+= form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f|
+ .panel.panel-default
+ .panel-heading
+ %h3.panel-title
+ Protect a branch
+ .panel-body
+ .form-horizontal
+ = form_errors(@protected_branch)
+ .form-group
+ = f.label :name, class: 'col-md-2 text-right' do
+ Branch:
+ .col-md-10
+ = render partial: "dropdown", locals: { f: f }
+ .help-block
+ = link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches')
+ such as
+ %code *-stable
+ or
+ %code production/*
+ are supported
+ .form-group
+ %label.col-md-2.text-right{ for: 'merge_access_levels_attributes' }
+ Allowed to merge:
+ .col-md-10
+ .merge_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-merge wide',
+ dropdown_class: 'dropdown-menu-selectable',
+ data: { field_name: 'protected_branch[merge_access_levels_attributes][0][access_level]', input_id: 'merge_access_levels_attributes' }})
+ .form-group
+ %label.col-md-2.text-right{ for: 'push_access_levels_attributes' }
+ Allowed to push:
+ .col-md-10
+ .push_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-push wide',
+ dropdown_class: 'dropdown-menu-selectable',
+ data: { field_name: 'protected_branch[push_access_levels_attributes][0][access_level]', input_id: 'push_access_levels_attributes' }})
+
+ .panel-footer
+ = f.submit 'Protect', class: 'btn-create btn', disabled: true
diff --git a/app/views/projects/protected_branches/_dropdown.html.haml b/app/views/projects/protected_branches/_dropdown.html.haml
index b803d932e67..a9e27df5a87 100644
--- a/app/views/projects/protected_branches/_dropdown.html.haml
+++ b/app/views/projects/protected_branches/_dropdown.html.haml
@@ -1,17 +1,15 @@
= f.hidden_field(:name)
-= dropdown_tag("Protected Branch",
- options: { title: "Pick protected branch", toggle_class: 'js-protected-branch-select js-filter-submit',
+= dropdown_tag('Select branch or create wildcard',
+ options: { toggle_class: 'js-protected-branch-select js-filter-submit wide',
filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected branches",
footer_content: true,
data: { show_no: true, show_any: true, show_upcoming: true,
selected: params[:protected_branch_name],
project_id: @project.try(:id) } }) do
- %ul.dropdown-footer-list.hidden.protected-branch-select-footer-list
+ %ul.dropdown-footer-list
%li
= link_to '#', title: "New Protected Branch", class: "create-new-protected-branch" do
- Create new
-
-:javascript
- new ProtectedBranchSelect();
+ Create wildcard
+ %code
diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml
index 498e412235e..0628134b1bb 100644
--- a/app/views/projects/protected_branches/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_protected_branch.html.haml
@@ -1,5 +1,4 @@
-- url = namespace_project_protected_branch_path(@project.namespace, @project, protected_branch)
-%tr
+%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch), branch_id: protected_branch.id } }
%td
= protected_branch.name
- if @project.root_ref?(protected_branch.name)
@@ -14,16 +13,9 @@
= time_ago_with_tooltip(commit.committed_date)
- else
(branch was removed from repository)
- %td
- = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_level.access_level
- = dropdown_tag(protected_branch.merge_access_level.humanize,
- options: { title: "Allowed to merge", toggle_class: 'allowed-to-merge', dropdown_class: 'dropdown-menu-selectable merge',
- data: { field_name: "allowed_to_merge_#{protected_branch.id}", url: url, id: protected_branch.id, type: "merge_access_level" }})
- %td
- = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_level.access_level
- = dropdown_tag(protected_branch.push_access_level.humanize,
- options: { title: "Allowed to push", toggle_class: 'allowed-to-push', dropdown_class: 'dropdown-menu-selectable push',
- data: { field_name: "allowed_to_push_#{protected_branch.id}", url: url, id: protected_branch.id, type: "push_access_level" }})
+
+ = render partial: 'update_protected_branch', locals: { protected_branch: protected_branch }
+
- if can_admin_project
%td
- = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning btn-sm pull-right"
+ = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml
new file mode 100644
index 00000000000..d6044aacaec
--- /dev/null
+++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml
@@ -0,0 +1,10 @@
+%td
+ = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level
+ = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') ,
+ options: { toggle_class: 'js-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container',
+ data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }})
+%td
+ = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level
+ = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') ,
+ options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container',
+ data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }})
diff --git a/app/views/projects/protected_branches/index.html.haml b/app/views/projects/protected_branches/index.html.haml
index 4efe44c7233..49dcc9a6ba4 100644
--- a/app/views/projects/protected_branches/index.html.haml
+++ b/app/views/projects/protected_branches/index.html.haml
@@ -14,41 +14,7 @@
%li prevent <strong>anyone</strong> from deleting the branch
%p.append-bottom-0 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}.
.col-lg-9
- %h5.prepend-top-0
- Protect a branch
- if can? current_user, :admin_project, @project
- = form_for [@project.namespace.becomes(Namespace), @project, @protected_branch] do |f|
- = form_errors(@protected_branch)
+ = render 'create_protected_branch'
- .form-group
- = f.label :name, "Branch", class: "label-light"
- = render partial: "dropdown", locals: { f: f }
- %p.help-block
- = link_to "Wildcards", help_page_path('user/project/protected_branches', anchor: "wildcard-protected-branches")
- such as
- %code *-stable
- or
- %code production/*
- are supported.
-
- .form-group
- = hidden_field_tag 'protected_branch[merge_access_level_attributes][access_level]'
- = label_tag "Allowed to merge: ", nil, class: "label-light append-bottom-0"
- = dropdown_tag("<Make a selection>",
- options: { title: "Allowed to merge", toggle_class: 'allowed-to-merge',
- dropdown_class: 'dropdown-menu-selectable',
- data: { field_name: "protected_branch[merge_access_level_attributes][access_level]" }})
-
- .form-group
- = hidden_field_tag 'protected_branch[push_access_level_attributes][access_level]'
- = label_tag "Allowed to push: ", nil, class: "label-light append-bottom-0"
- = dropdown_tag("<Make a selection>",
- options: { title: "Allowed to push", toggle_class: 'allowed-to-push',
- dropdown_class: 'dropdown-menu-selectable',
- data: { field_name: "protected_branch[push_access_level_attributes][access_level]" }})
-
-
- = f.submit "Protect", class: "btn-create btn protect-branch-btn", disabled: true
-
- %hr
= render "branches_list"
diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/releases/edit.html.haml
index 835398b6f98..33d5cbff420 100644
--- a/app/views/projects/releases/edit.html.haml
+++ b/app/views/projects/releases/edit.html.haml
@@ -1,18 +1,20 @@
+- @no_container = true
- page_title "Edit", @tag.name, "Tags"
= render "projects/commits/head"
-.row-content-block
- .oneline
- .title
- Release notes for tag
- %strong #{@tag.name}
+%div{ class: container_class }
+ .sub-header-block.no-bottom-space
+ .oneline
+ .title
+ Release notes for tag
+ %strong #{@tag.name}
+
-.prepend-top-default
= form_for(@release, method: :put, url: namespace_project_tag_release_path(@project.namespace, @project, @tag.name), html: { class: 'form-horizontal common-note-form release-form js-quick-submit' }) do |f|
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here..."
= render 'projects/notes/hints'
.error-alert
- .form-actions.prepend-top-default
+ .prepend-top-default
= f.submit 'Save changes', class: 'btn btn-save'
= link_to "Cancel", namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-default btn-cancel"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index dd1cf680cfa..340e159c874 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -43,6 +43,10 @@
%li
= link_to 'Contribution guide', contribution_guide_path(@project)
+ - if @repository.gitlab_ci_yml
+ %li
+ = link_to 'CI configuration', ci_configuration_path(@project)
+
- if current_user && can_push_branch?(@project, @project.default_branch)
- unless @repository.changelog
%li.missing
@@ -60,10 +64,12 @@
%li.missing
= link_to add_special_file_path(@project, file_name: '.gitlab-ci.yml') do
Set Up CI
+
%li.project-repo-buttons-right
.project-repo-buttons.project-right-buttons
- if current_user
= render 'shared/members/access_request_buttons', source: @project
+ = render "projects/buttons/koding"
.btn-group.project-repo-btn-group
= render "projects/buttons/download"
@@ -82,4 +88,4 @@
Archived project! Repository is read-only
%div{class: "project-show-#{default_project_view}"}
- = render default_project_view \ No newline at end of file
+ = render default_project_view
diff --git a/app/views/projects/tree/_tree_commit_column.html.haml b/app/views/projects/tree/_tree_commit_column.html.haml
index a3a4bd4f752..84da16b6bb1 100644
--- a/app/views/projects/tree/_tree_commit_column.html.haml
+++ b/app/views/projects/tree/_tree_commit_column.html.haml
@@ -1,2 +1,2 @@
%span.str-truncated
- = link_to_gfm commit.title, namespace_project_commit_path(@project.namespace, @project, commit.id), class: "tree-commit-link"
+ = link_to_gfm commit.full_title, namespace_project_commit_path(@project.namespace, @project, commit.id), class: "tree-commit-link"
diff --git a/app/views/projects/tree/_tree_row.html.haml b/app/views/projects/tree/_tree_row.html.haml
new file mode 100644
index 00000000000..0a5c6f048f7
--- /dev/null
+++ b/app/views/projects/tree/_tree_row.html.haml
@@ -0,0 +1,6 @@
+- if tree_row.type == :tree
+ = render partial: 'projects/tree/tree_item', object: tree_row, as: 'tree_item', locals: { type: 'folder' }
+- elsif tree_row.type == :blob
+ = render partial: 'projects/tree/blob_item', object: tree_row, as: 'blob_item', locals: { type: 'file' }
+- elsif tree_row.type == :commit
+ = render partial: 'projects/tree/submodule_item', object: tree_row, as: 'submodule_item'
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index 797a1a59e9f..643f7c589e6 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -18,9 +18,14 @@
.error-alert
.help-block
- To link to a (new) page, simply type
- %code [Link Title](page-slug)
- \.
+ = succeed '.' do
+ To link to a (new) page, simply type
+ %code [Link Title](page-slug)
+
+ = succeed '.' do
+ More examples are in the
+ = link_to 'documentation', help_page_path("user/project/markdown", anchor: "wiki-specific-markdown")
+
.form-group
= f.label :commit_message, class: 'control-label'
.col-sm-10= f.text_field :message, class: 'form-control', rows: 18
diff --git a/app/views/shared/_labels_row.html.haml b/app/views/shared/_labels_row.html.haml
index dce492352ac..e324d0e5203 100644
--- a/app/views/shared/_labels_row.html.haml
+++ b/app/views/shared/_labels_row.html.haml
@@ -1,9 +1,5 @@
- labels.each do |label|
%span.label-row.btn-group{ role: "group", aria: { label: label.name }, style: "color: #{text_color_for_bg(label.color)}" }
- = link_to label.name, label_filter_path(@project, label, type: controller.controller_name),
- class: "btn btn-transparent has-tooltip",
- style: "background-color: #{label.color};",
- title: escape_once(label.description),
- data: { container: "body" }
+ = link_to_label(label, css_class: 'btn btn-transparent')
%button.btn.btn-transparent.label-remove.js-label-filter-remove{ type: "button", style: "background-color: #{label.color};", data: { label: label.title } }
= icon("times")
diff --git a/app/views/shared/_ref_switcher.html.haml b/app/views/shared/_ref_switcher.html.haml
index ea7162d4d63..9a8252ab087 100644
--- a/app/views/shared/_ref_switcher.html.haml
+++ b/app/views/shared/_ref_switcher.html.haml
@@ -6,7 +6,7 @@
- @options && @options.each do |key, value|
= hidden_field_tag key, value, id: nil
.dropdown
- = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project) }, { toggle_class: "js-project-refs-dropdown" }
+ = dropdown_toggle dropdown_toggle_text, { toggle: "dropdown", selected: dropdown_toggle_text, ref: @ref, refs_url: refs_namespace_project_path(@project.namespace, @project), field_name: 'ref', submit_form_on_click: true }, { toggle_class: "js-project-refs-dropdown" }
.dropdown-menu.dropdown-menu-selectable{ class: ("dropdown-menu-align-right" if local_assigns[:align_right]) }
= dropdown_title "Switch branch/tag"
= dropdown_filter "Search branches and tags"
diff --git a/app/views/shared/icons/_icon_fork.svg b/app/views/shared/icons/_icon_fork.svg
index a21f8f3a951..fc970e4ce50 100644
--- a/app/views/shared/icons/_icon_fork.svg
+++ b/app/views/shared/icons/_icon_fork.svg
@@ -1,3 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
+<svg xmlns="http://www.w3.org/2000/svg" width="30" height="40" viewBox="5 0 30 40">
<path fill="#7E7E7E" fill-rule="evenodd" d="M22,29.5351288 L22,22.7193602 C26.1888699,21.5098039 29.3985457,16.802989 29.3985457,16.802989 C29.740988,16.3567547 30,15.5559546 30,15.0081969 L30,10.4648712 C31.1956027,9.77325238 32,8.48056471 32,7 C32,4.790861 30.209139,3 28,3 C25.790861,3 24,4.790861 24,7 C24,8.48056471 24.8043973,9.77325238 26,10.4648712 L26,14.7083871 C26,14.8784435 25.9055559,15.0987329 25.7890533,15.2104147 C25.7890533,15.2104147 24.5373893,16.4126202 23.9488702,16.9515733 C22.5015398,18.2770075 21.1191354,19 20.090554,19 C19.0477772,19 17.6172728,18.2608988 16.1128852,16.9142923 C15.5030182,16.3683886 14.3672121,15.3403307 14.3672121,15.3403307 C14.1659605,15.1583364 14.0000086,14.7846305 14.0000192,14.5088473 C14.0000192,14.5088473 14.0000932,12.7539451 14.0001308,10.4647956 C15.1956614,9.77315812 16,8.48051074 16,7 C16,4.790861 14.209139,3 12,3 C9.790861,3 8,4.790861 8,7 C8,8.48056471 8.80439726,9.77325238 10,10.4648712 L10,15.0081969 C10,15.5446944 10.2736352,16.3534183 10.6111812,16.7893819 C10.6111812,16.7893819 13.8599776,21.3779363 18,22.6668724 L18,29.5351288 C16.8043973,30.2267476 16,31.5194353 16,33 C16,35.209139 17.790861,37 20,37 C22.209139,37 24,35.209139 24,33 C24,31.5194353 23.1956027,30.2267476 22,29.5351288 Z M14,7 C14,5.8954305 13.1045695,5 12,5 C10.8954305,5 10,5.8954305 10,7 C10,8.1045695 10.8954305,9 12,9 C13.1045695,9 14,8.1045695 14,7 Z M30,7 C30,5.8954305 29.1045695,5 28,5 C26.8954305,5 26,5.8954305 26,7 C26,8.1045695 26.8954305,9 28,9 C29.1045695,9 30,8.1045695 30,7 Z M22,33 C22,31.8954305 21.1045695,31 20,31 C18.8954305,31 18,31.8954305 18,33 C18,34.1045695 18.8954305,35 20,35 C21.1045695,35 22,34.1045695 22,33 Z"/>
</svg>
diff --git a/app/views/shared/icons/_icon_play.svg b/app/views/shared/icons/_icon_play.svg
new file mode 100644
index 00000000000..80a6d41dbf6
--- /dev/null
+++ b/app/views/shared/icons/_icon_play.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 11"><path fill-rule="evenodd" d="m9.283 6.47l-7.564 4.254c-.949.534-1.719.266-1.719-.576v-9.292c0-.852.756-1.117 1.719-.576l7.564 4.254c.949.534.963 1.392 0 1.934"/></svg> \ No newline at end of file
diff --git a/app/views/shared/icons/_next_discussion.svg b/app/views/shared/icons/_next_discussion.svg
new file mode 100644
index 00000000000..43559a60cb0
--- /dev/null
+++ b/app/views/shared/icons/_next_discussion.svg
@@ -0,0 +1 @@
+<svg viewBox="0 0 20 19" ><path d="M15.21 7.783h-3.317c-.268 0-.472.218-.472.486v.953c0 .28.212.486.473.486h3.318v1.575c0 .36.233.452.52.23l3.06-2.37c.274-.213.286-.582 0-.804l-3.06-2.37c-.275-.213-.52-.12-.52.23v1.583zm.57-3.66c-1.558-1.22-3.783-1.98-6.254-1.98C4.816 2.143 1 4.91 1 8.333c0 1.964 1.256 3.715 3.216 4.846-.447 1.615-1.132 2.195-1.732 2.882-.142.174-.304.32-.256.56v.01c.047.213.218.368.41.368h.046c.37-.048.743-.116 1.085-.213 1.645-.425 3.13-1.22 4.377-2.34.447.048.913.077 1.38.077 2.092 0 4.01-.546 5.492-1.454-.416-.208-.798-.475-1.134-.792-1.227.63-2.743 1.008-4.36 1.008-.41 0-.828-.03-1.237-.078l-.543-.058-.41.368c-.78.696-1.655 1.248-2.616 1.654.248-.445.486-.977.667-1.664l.257-.928-.828-.484c-1.646-.948-2.598-2.32-2.598-3.763 0-2.69 3.35-4.952 7.308-4.952 1.893 0 3.647.518 4.962 1.353.393-.266.827-.473 1.29-.61z" /></svg>
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 0b7fa8c7d06..4f8ea7e7cef 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -27,7 +27,17 @@
= render "shared/issuable/label_dropdown"
.pull-right
- = render 'shared/sort_dropdown'
+ - if controller.controller_name == 'boards' && can?(current_user, :admin_list, @project)
+ .dropdown
+ %button.btn.btn-create.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, project_id: @project.try(:id) } }
+ Create new list
+ .dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable
+ = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Create a new list" }
+ - if can?(current_user, :admin_label, @project)
+ = render partial: "shared/issuable/label_page_create"
+ = dropdown_loading
+ - else
+ = render 'shared/sort_dropdown'
- if controller.controller_name == 'issues'
.issues_bulk_update.hide
@@ -45,7 +55,7 @@
.filter-item.inline
= dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
.filter-item.inline.labels-filter
- = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, show_footer: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
+ = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
.filter-item.inline
= dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]" } } ) do
%ul
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index c30bdb0ae91..22594b46443 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -1,8 +1,30 @@
= form_errors(issuable)
+- if @conflict
+ .alert.alert-danger
+ Someone edited the #{issuable.class.model_name.human.downcase} the same time you did.
+ Please check out
+ = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), target: "_blank"
+ and make sure your changes will not unintentionally remove theirs
+
.form-group
= f.label :title, class: 'control-label'
- .col-sm-10
+
+ - issuable_template_names = issuable_templates(issuable)
+
+ - if issuable_template_names.any?
+ .col-sm-3.col-lg-2
+ .js-issuable-selector-wrap{ data: { issuable_type: issuable.class.to_s.underscore.downcase } }
+ - title = selected_template(issuable) || "Choose a template"
+
+ = dropdown_tag(title, options: { toggle_class: 'js-issuable-selector',
+ title: title, filter: true, placeholder: 'Filter', footer_content: true,
+ data: { data: issuable_template_names, field_name: 'issuable_template', selected: selected_template(issuable), project_path: @project.path, namespace_path: @project.namespace.path } } ) do
+ %ul.dropdown-footer-list
+ %li
+ %a.reset-template
+ Reset template
+ %div{ class: issuable_template_names.any? ? 'col-sm-7 col-lg-8' : 'col-sm-10' }
= f.text_field :title, maxlength: 255, autofocus: true, autocomplete: 'off',
class: 'form-control pad', required: true
@@ -23,6 +45,13 @@
to prevent a
%strong Work In Progress
merge request from being merged before it's ready.
+
+ - if can_add_template?(issuable)
+ %p.help-block
+ Add
+ = link_to "description templates", help_page_path('user/project/description_templates'), tabindex: -1
+ to help your contributors communicate effectively!
+
.form-group.detail-page-description
= f.label :description, 'Description', class: 'control-label'
.col-sm-10
@@ -30,8 +59,9 @@
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
= render 'projects/zen', f: f, attr: :description,
classes: 'note-textarea',
- placeholder: "Write a comment or drag your files here..."
- = render 'projects/notes/hints'
+ placeholder: "Write a comment or drag your files here...",
+ supports_slash_commands: !issuable.persisted?
+ = render 'projects/notes/hints', supports_slash_commands: !issuable.persisted?
.clearfix
.error-alert
@@ -98,7 +128,7 @@
= label_tag :move_to_project_id, 'Move', class: 'control-label'
.col-sm-10
.issuable-form-select-holder
- = hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id) }
+ = hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id), page_size: MoveToProjectFinder::PAGE_SIZE }
&nbsp;
%span{ data: { toggle: 'tooltip', placement: 'auto top' }, style: 'cursor: default',
title: 'Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location.' }
@@ -149,3 +179,5 @@
= link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), data: { confirm: "#{issuable.class.name.titleize} will be removed! Are you sure?" },
method: :delete, class: 'btn btn-danger btn-grouped'
= link_to 'Cancel', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), class: 'btn btn-grouped btn-cancel'
+
+= f.hidden_field :lock_version
diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml
index 4e280c371ac..c0dc63be2bf 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -2,8 +2,16 @@
- show_create = local_assigns.fetch(:show_create, true)
- show_footer = local_assigns.fetch(:show_footer, true)
- filter_placeholder = local_assigns.fetch(:filter_placeholder, 'Search labels')
+- show_boards_content = local_assigns.fetch(:show_boards_content, false)
.dropdown-page-one
= dropdown_title(title)
+ - if show_boards_content
+ .issue-board-dropdown-content
+ %p
+ Each label that exists in your issue tracker can have its own dedicated
+ list. Select a label below to add a list to your Board and it will
+ automatically be populated with issues that have that label. To create
+ a list for a label that doesn't exist yet, simply create the label below.
= dropdown_filter(filter_placeholder)
= dropdown_content
- if @project && show_footer
@@ -12,7 +20,7 @@
- if can?(current_user, :admin_label, @project)
%li
%a.dropdown-toggle-page{href: "#"}
- Create new
+ Create new label
%li
= link_to namespace_project_labels_path(@project.namespace, @project), :"data-is-link" => true do
- if show_create && @project && can?(current_user, :admin_label, @project)
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 8e2fcbdfab8..c1b50e65af5 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -109,7 +109,7 @@
- if issuable.project.labels.any?
.block.labels
- .sidebar-collapsed-icon
+ .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body" } }
= icon('tags')
%span
= issuable.labels_array.size
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 5ae485f36ba..5f20e4bd42a 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -1,4 +1,4 @@
-- show_roles = local_assigns.fetch(:show_roles, default_show_roles(member))
+- show_roles = local_assigns.fetch(:show_roles, true)
- show_controls = local_assigns.fetch(:show_controls, true)
- user = member.user
@@ -16,7 +16,7 @@
= button_tag icon('pencil'),
type: 'button',
class: 'btn inline js-toggle-button',
- title: 'Edit access level'
+ title: 'Edit'
- if member.request?
= link_to icon('check inverse'), polymorphic_path([:approve_access_request, member]),
@@ -59,6 +59,10 @@
= time_ago_with_tooltip(member.requested_at)
- else
Joined #{time_ago_with_tooltip(member.created_at)}
+ - if member.expires?
+ ยท
+ %span{ class: ('text-warning' if member.expires_soon?) }
+ Expires in #{distance_of_time_in_words_to_now(member.expires_at)}
- else
= image_tag avatar_icon(member.invite_email, 40), class: "avatar s40", alt: ''
@@ -73,8 +77,16 @@
- if show_roles
.edit-member.hide.js-toggle-content
%br
- = form_for member, remote: true do |f|
- .prepend-top-10
- = f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control'
+ = form_for member, remote: true, html: { class: 'form-horizontal' } do |f|
+ .form-group
+ = label_tag "member_access_level_#{member.id}", 'Project access', class: 'control-label'
+ .col-sm-10
+ = f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control', id: "member_access_level_#{member.id}"
+ .form-group
+ = label_tag "member_expires_at_#{member.id}", 'Access expiration date', class: 'control-label'
+ .col-sm-10
+ .clearable-input
+ = f.text_field :expires_at, class: 'form-control js-access-expiration-date', placeholder: 'Select access expiration date', id: "member_expires_at_#{member.id}"
+ %i.clear-icon.js-clear-input
.prepend-top-10
= f.submit 'Save', class: 'btn btn-save btn-sm'
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index b8b66d08db8..66c309644a7 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -12,19 +12,21 @@
%li.project-row{ class: css_class }
= cache(cache_key) do
.controls
+ - if project.archived
+ %span.label.label-warning archived
- if project.commit.try(:status)
%span
= render_commit_status(project.commit)
- if forks
%span
= icon('code-fork')
- = project.forks_count
+ = number_with_delimiter(project.forks_count)
- if stars
%span
= icon('star')
- = project.star_count
+ = number_with_delimiter(project.star_count)
%span.visibility-icon.has-tooltip{data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project)}
- = visibility_level_icon(project.visibility_level, fw: false)
+ = visibility_level_icon(project.visibility_level, fw: true)
.title
= link_to project_path(project), class: dom_class(project) do
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 47ec09f62c6..0c788032020 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,3 +1,7 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+ = page_specific_javascript_tag('snippet/snippet_bundle.js')
+
.snippet-form-holder
= form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input" } do |f|
= form_errors(@snippet)
@@ -31,8 +35,3 @@
- else
= link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
-:javascript
- var editor = ace.edit("editor");
- $(".snippet-form-holder form").submit(function(){
- $(".snippet-file-content").val(editor.getValue());
- });
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 106161d6515..f99fbe56277 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -19,7 +19,7 @@
= f.label :token, "Secret Token", class: 'label-light'
= f.text_field :token, class: "form-control", placeholder: ''
%p.help-block
- Use this token to validate received payloads
+ Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header.
.form-group
= f.label :url, "Trigger", class: 'label-light'
%ul.list-unstyled
@@ -29,42 +29,49 @@
= f.label :push_events, class: 'list-label' do
%strong Push events
%p.light
- This url will be triggered by a push to the repository
+ This URL will be triggered by a push to the repository
%li
= f.check_box :tag_push_events, class: 'pull-left'
.prepend-left-20
= f.label :tag_push_events, class: 'list-label' do
%strong Tag push events
%p.light
- This url will be triggered when a new tag is pushed to the repository
+ This URL will be triggered when a new tag is pushed to the repository
%li
= f.check_box :note_events, class: 'pull-left'
.prepend-left-20
= f.label :note_events, class: 'list-label' do
%strong Comments
%p.light
- This url will be triggered when someone adds a comment
+ This URL will be triggered when someone adds a comment
%li
= f.check_box :issues_events, class: 'pull-left'
.prepend-left-20
= f.label :issues_events, class: 'list-label' do
%strong Issues events
%p.light
- This url will be triggered when an issue is created/updated/merged
+ This URL will be triggered when an issue is created/updated/merged
%li
= f.check_box :merge_requests_events, class: 'pull-left'
.prepend-left-20
= f.label :merge_requests_events, class: 'list-label' do
%strong Merge Request events
%p.light
- This url will be triggered when a merge request is created/updated/merged
+ This URL will be triggered when a merge request is created/updated/merged
%li
= f.check_box :build_events, class: 'pull-left'
.prepend-left-20
= f.label :build_events, class: 'list-label' do
%strong Build events
%p.light
- This url will be triggered when the build status changes
+ This URL will be triggered when the build status changes
+ %li
+ = f.check_box :pipeline_events, class: 'pull-left'
+ .prepend-left-20
+ = f.label :pipeline_events, class: 'list-label' do
+ %strong Pipeline events
+ %p.light
+ This URL will be triggered when the pipeline status changes
%li
= f.check_box :pipeline_events, class: 'pull-left'
.prepend-left-20
@@ -78,7 +85,7 @@
= f.label :wiki_page_events, class: 'list-label' do
%strong Wiki Page events
%p.light
- This url will be triggered when a wiki page is created/updated
+ This URL will be triggered when a wiki page is created/updated
.form-group
= f.label :enable_ssl_verification, "SSL verification", class: 'label-light checkbox'
.checkbox
diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml
index cbb8dfb7829..8f7b42eb351 100644
--- a/app/views/u2f/_register.html.haml
+++ b/app/views/u2f/_register.html.haml
@@ -28,10 +28,15 @@
%script#js-register-u2f-registered{ type: "text/template" }
%div.row.append-bottom-10
- %p Your device was successfully set up! Click this button to register with the GitLab server.
- = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do
- = hidden_field_tag :device_response, nil, class: 'form-control', required: true, id: "js-device-response"
- = submit_tag "Register U2F Device", class: "btn btn-success"
+ .col-md-12
+ %p Your device was successfully set up! Give it a name and register it with the GitLab server.
+ = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do
+ .row.append-bottom-10
+ .col-md-3
+ = text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: "Pick a name"
+ .col-md-3
+ = hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
+ = submit_tag "Register U2F Device", class: "btn btn-success"
:javascript
var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f);
diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index 0b6a01a3200..1dc7e0adef7 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -33,25 +33,14 @@ class EmailsOnPushWorker
reverse_compare = false
if action == :push
- merge_base_sha = project.merge_base_commit(before_sha, after_sha).try(:sha)
- compare = Gitlab::Git::Compare.new(project.repository.raw_repository, before_sha, after_sha)
-
- diff_refs = Gitlab::Diff::DiffRefs.new(
- base_sha: merge_base_sha,
- start_sha: before_sha,
- head_sha: after_sha
- )
+ compare = CompareService.new.execute(project, after_sha, project, before_sha)
+ diff_refs = compare.diff_refs
return false if compare.same
if compare.commits.empty?
- compare = Gitlab::Git::Compare.new(project.repository.raw_repository, after_sha, before_sha)
-
- diff_refs = Gitlab::Diff::DiffRefs.new(
- base_sha: merge_base_sha,
- start_sha: after_sha,
- head_sha: before_sha
- )
+ compare = CompareService.new.execute(project, before_sha, project, after_sha)
+ diff_refs = compare.diff_refs
reverse_compare = true
diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb
new file mode 100644
index 00000000000..5048746f09b
--- /dev/null
+++ b/app/workers/group_destroy_worker.rb
@@ -0,0 +1,17 @@
+class GroupDestroyWorker
+ include Sidekiq::Worker
+
+ sidekiq_options queue: :default
+
+ def perform(group_id, user_id)
+ begin
+ group = Group.with_deleted.find(group_id)
+ rescue ActiveRecord::RecordNotFound
+ return
+ end
+
+ user = User.find(user_id)
+
+ DestroyGroupService.new(group, user).execute
+ end
+end
diff --git a/app/workers/gitlab_remove_project_export_worker.rb b/app/workers/import_export_project_cleanup_worker.rb
index 1d91897d520..72e3a9ae734 100644
--- a/app/workers/gitlab_remove_project_export_worker.rb
+++ b/app/workers/import_export_project_cleanup_worker.rb
@@ -1,9 +1,9 @@
-class GitlabRemoveProjectExportWorker
+class ImportExportProjectCleanupWorker
include Sidekiq::Worker
sidekiq_options queue: :default
def perform
- Project.remove_gitlab_exports!
+ ImportExportCleanUpService.new.execute
end
end
diff --git a/app/workers/irker_worker.rb b/app/workers/irker_worker.rb
index 605ec4f04e5..19f38358eb5 100644
--- a/app/workers/irker_worker.rb
+++ b/app/workers/irker_worker.rb
@@ -141,8 +141,10 @@ class IrkerWorker
end
def files_count(commit)
- files = "#{commit.diffs.real_size} file"
- files += 's' if commit.diffs.count > 1
+ diffs = commit.raw_diffs(deltas_only: true)
+
+ files = "#{diffs.real_size} file"
+ files += 's' if diffs.size > 1
files
end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 09035a7cf2d..a9a2b716005 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -10,6 +10,10 @@ class PostReceive
log("Check gitlab.yml config for correct repositories.storages values. No repository storage path matches \"#{repo_path}\"")
end
+ changes = Base64.decode64(changes) unless changes.include?(' ')
+ # Use Sidekiq.logger so arguments can be correlated with execution
+ # time and thread ID's.
+ Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS']
post_received = Gitlab::GitPostReceive.new(repo_path, identifier, changes)
if post_received.project.nil?
diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb
index b51c6a266c9..3062301a9b1 100644
--- a/app/workers/project_destroy_worker.rb
+++ b/app/workers/project_destroy_worker.rb
@@ -12,6 +12,6 @@ class ProjectDestroyWorker
user = User.find(user_id)
- ::Projects::DestroyService.new(project, user, params).execute
+ ::Projects::DestroyService.new(project, user, params.symbolize_keys).execute
end
end
diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb
new file mode 100644
index 00000000000..246c8b6650a
--- /dev/null
+++ b/app/workers/remove_expired_group_links_worker.rb
@@ -0,0 +1,7 @@
+class RemoveExpiredGroupLinksWorker
+ include Sidekiq::Worker
+
+ def perform
+ ProjectGroupLink.expired.destroy_all
+ end
+end
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
new file mode 100644
index 00000000000..cf765af97ce
--- /dev/null
+++ b/app/workers/remove_expired_members_worker.rb
@@ -0,0 +1,13 @@
+class RemoveExpiredMembersWorker
+ include Sidekiq::Worker
+
+ def perform
+ Member.expired.find_each do |member|
+ begin
+ Members::AuthorizedDestroyService.new(member).execute
+ rescue => ex
+ logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
+ end
+ end
+ end
+end
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index d69d6037053..61ed1c38ac4 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -5,6 +5,10 @@ class RepositoryForkWorker
sidekiq_options queue: :gitlab_shell
def perform(project_id, forked_from_repository_storage_path, source_path, target_path)
+ Gitlab::Metrics.add_event(:fork_repository,
+ source_path: source_path,
+ target_path: target_path)
+
project = Project.find_by_id(project_id)
unless project.present?
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 7d819fe78f8..e6701078f71 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -10,6 +10,10 @@ class RepositoryImportWorker
@project = Project.find(project_id)
@current_user = @project.creator
+ Gitlab::Metrics.add_event(:import_repository,
+ import_url: @project.import_url,
+ path: @project.path_with_namespace)
+
result = Projects::ImportService.new(project, current_user).execute
if result[:status] == :error