diff options
author | Lin Jen-Shin <godfat@godfat.org> | 2017-06-30 13:46:51 +0800 |
---|---|---|
committer | Lin Jen-Shin <godfat@godfat.org> | 2017-06-30 13:46:51 +0800 |
commit | 62fdbbeeb01810f9215a7b8bdf880901fcb48c65 (patch) | |
tree | 388bee91b591f3110185c103882469ce42b80784 | |
parent | 0d5e6536e7c18d839b1c1da0807aa90ba5be3e06 (diff) | |
parent | 6bbbc0ba80d98a5479cf1c962835487f73e00ef7 (diff) | |
download | gitlab-ce-62fdbbeeb01810f9215a7b8bdf880901fcb48c65.tar.gz |
Merge remote-tracking branch 'upstream/master' into 32815--Add-Custom-CI-Config-Path
* upstream/master: (123 commits)
Backport changes to Projects::IssuesController and the search bar
bugfix: use `require_dependency` to bring in DeclarativePolicy
Resolve "Select branch dropdown is too close to branch name"
Clean up issuable lists
Defer project destroys within a namespace in Groups::DestroyService#async_execute
Fixed new navgiation bar logo height in Safari
Resolve "Issue dropdown persists when adding issue number to issue description"
Move verification to block level instead of paragraph
Revert "Merge branch 'dm-drop-default-scope-on-sortable-finders' into 'master'"
Added code for defining SHA attributes
Minor edits
Job details won't scroll horizontally to show long lines
Run mysql tests on stable preperation branches like 9-3-stable-patch-2
Bring back branches badge to main project page
optimize translation content based on comments
supplement traditional chinese in taiwan translation
Inserts exact matches of username, email and name to the top of the user search list
Remove Namespace model default scope override and write additional test to Project search
optimize translation content based on comments
Limit OpenGraph image size to 64x64
...
335 files changed, 13644 insertions, 4880 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 08aef3dd8ee..e52b656599c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -63,7 +63,7 @@ stages: .only-master-and-ee-or-mysql: &only-master-and-ee-or-mysql only: - /mysql/ - - /-stable$/ + - /-stable/ - master@gitlab-org/gitlab-ce - master@gitlab/gitlabhq - tags@gitlab-org/gitlab-ce @@ -476,6 +476,7 @@ codeclimate: script: - docker pull codeclimate/codeclimate - docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate analyze -f json > codeclimate.json + - sed -i.bak 's/\({"body":"\)[^"]*\("}\)/\1\2/g' codeclimate.json artifacts: paths: [codeclimate.json] @@ -550,3 +551,9 @@ cache gems: only: - master@gitlab-org/gitlab-ce - master@gitlab-org/gitlab-ee + +gitlab_git_test: + variables: + SETUP_DB: "false" + script: + - spec/support/prepare-gitlab-git-test-for-commit --check-for-changes diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index c28f6e151a0..9974e135022 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -196,6 +196,7 @@ window.Build = (function () { }) .done((log) => { gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`); + if (log.state) { this.state = log.state; } @@ -220,7 +221,11 @@ window.Build = (function () { } if (!log.complete) { - this.toggleScrollAnimation(true); + if (!this.hasBeenScrolled) { + this.toggleScrollAnimation(true); + } else { + this.toggleScrollAnimation(false); + } Build.timeout = setTimeout(() => { //eslint-disable-next-line diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 31a86090242..4247540de22 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -209,8 +209,8 @@ import initExperimentalFlags from './experimental_flags'; new MilestoneSelect(); new gl.IssuableTemplateSelectors(); break; - case 'projects:merge_requests:new': - case 'projects:merge_requests:new_diffs': + case 'projects:merge_requests:creations:new': + case 'projects:merge_requests:creations:diffs': case 'projects:merge_requests:edit': new gl.Diff(); shortcut_handler = new ShortcutsNavigation(); @@ -247,10 +247,6 @@ import initExperimentalFlags from './experimental_flags'; shortcut_handler = new ShortcutsIssuable(true); new ZenMode(); break; - case "projects:merge_requests:diffs": - new gl.Diff(); - new ZenMode(); - break; case 'dashboard:activity': new gl.Activities(); break; @@ -319,7 +315,7 @@ import initExperimentalFlags from './experimental_flags'; new gl.Members(); new UsersSelect(); break; - case 'projects:members:show': + case 'projects:settings:members:show': new gl.MemberExpirationDate('.js-access-expiration-date-groups'); new GroupsSelect(); new gl.MemberExpirationDate(); @@ -386,7 +382,7 @@ import initExperimentalFlags from './experimental_flags'; case 'search:show': new Search(); break; - case 'projects:repository:show': + case 'projects:settings:repository:show': // Initialize Protected Branch Settings new gl.ProtectedBranchCreate(); new gl.ProtectedBranchEditList(); @@ -396,7 +392,7 @@ import initExperimentalFlags from './experimental_flags'; // Initialize expandable settings panels initSettingsPanels(); break; - case 'projects:ci_cd:show': + case 'projects:settings:ci_cd:show': new gl.ProjectVariables(); break; case 'ci:lints:create': diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index 65c1b2050ac..19fed771197 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -2,6 +2,7 @@ import AjaxFilter from '~/droplab/plugins/ajax_filter'; import './filtered_search_dropdown'; +import { addClassIfElementExists } from '../lib/utils/dom_utils'; class DropdownUser extends gl.FilteredSearchDropdown { constructor(droplab, dropdown, input, tokenKeys, filter) { @@ -32,8 +33,7 @@ class DropdownUser extends gl.FilteredSearchDropdown { } hideCurrentUser() { - const currenUserItem = this.dropdown.querySelector('.js-current-user'); - currenUserItem.classList.add('hidden'); + addClassIfElementExists(this.dropdown.querySelector('.js-current-user'), 'hidden'); } itemClicked(e) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 1425769d2de..7872e9e68ad 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -3,6 +3,7 @@ import RecentSearchesRoot from './recent_searches_root'; import RecentSearchesStore from './stores/recent_searches_store'; import RecentSearchesService from './services/recent_searches_service'; import eventHub from './event_hub'; +import { addClassIfElementExists } from '../lib/utils/dom_utils'; class FilteredSearchManager { constructor(page) { @@ -227,11 +228,7 @@ class FilteredSearchManager { } addInputContainerFocus() { - const inputContainer = this.filteredSearchInput.closest('.filtered-search-box'); - - if (inputContainer) { - inputContainer.classList.add('focus'); - } + addClassIfElementExists(this.filteredSearchInput.closest('.filtered-search-box'), 'focus'); } removeInputContainerFocus(e) { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index f99bac7da1a..10a64f9032b 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -396,6 +396,13 @@ class GfmAutoComplete { this.cachedData = {}; } + destroy() { + this.input.each((i, input) => { + const $input = $(input); + $input.atwho('destroy'); + }); + } + static isLoading(data) { let dataToInspect = data; if (data && data.length > 0) { diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index dc9f114af99..4e8141b2956 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -21,6 +21,9 @@ function GLForm(form, enableGFM = false) { GLForm.prototype.destroy = function() { // Clean form listeners this.clearEventListeners(); + if (this.autoComplete) { + this.autoComplete.destroy(); + } return this.form.data('gl-form', null); }; @@ -33,7 +36,8 @@ GLForm.prototype.setupForm = function() { this.form.addClass('gfm-form'); // remove notify commit author checkbox for non-commit notes gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion')); - new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources).setup(this.form.find('.js-gfm-input'), { + this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); + this.autoComplete.setup(this.form.find('.js-gfm-input'), { emojis: true, members: this.enableGFM, issues: this.enableGFM, diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index 4223a8fea49..d0145fed396 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -39,6 +39,17 @@ runnerId() { return `#${this.job.runner.id}`; }, + renderBlock() { + return this.job.merge_request || + this.job.duration || + this.job.finished_data || + this.job.erased_at || + this.job.queued || + this.job.runner || + this.job.coverage || + this.job.tags.length || + this.job.cancel_path; + }, }, }; </script> @@ -63,7 +74,7 @@ Retry </a> </div> - <div class="block"> + <div :class="{block : renderBlock }"> <p class="build-detail-row js-job-mr" v-if="job.merge_request"> diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js new file mode 100644 index 00000000000..de65ea15a60 --- /dev/null +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -0,0 +1,7 @@ +/* eslint-disable import/prefer-default-export */ + +export const addClassIfElementExists = (element, className) => { + if (element) { + element.classList.add(className); + } +}; diff --git a/app/assets/javascripts/locale/eo/app.js b/app/assets/javascripts/locale/eo/app.js deleted file mode 100644 index 55f000e9b88..00000000000 --- a/app/assets/javascripts/locale/eo/app.js +++ /dev/null @@ -1 +0,0 @@ -var locales = locales || {}; locales['eo'] = {"domain":"app","locale_data":{"app":{"":{"Project-Id-Version":"gitlab 1.0.0","Report-Msgid-Bugs-To":"","POT-Creation-Date":"2017-06-15 21:59-0500","MIME-Version":"1.0","Content-Type":"text/plain; charset=UTF-8","Content-Transfer-Encoding":"8bit","PO-Revision-Date":"2017-06-20 06:24-0400","Last-Translator":"Lyubomir Vasilev <lyubomirv@abv.bg>","Language-Team":"Esperanto (https://translate.zanata.org/project/view/GitLab)","Language":"eo","X-Generator":"Zanata 3.9.6","Plural-Forms":"nplurals=2; plural=(n != 1)","lang":"eo","domain":"app","plural_forms":"nplurals=2; plural=(n != 1)"},"%{commit_author_link} committed %{commit_timeago}":["%{commit_author_link} enmetis %{commit_timeago}"],"About auto deploy":["Pri la aŭtomata disponigado"],"Active":["Aktiva"],"Activity":["Aktiveco"],"Add Changelog":["Aldoni liston de ŝanĝoj"],"Add Contribution guide":["Aldoni gvidliniojn por kontribuado"],"Add License":["Aldoni rajtigilon"],"Add an SSH key to your profile to pull or push via SSH.":["Aldonu SSH-ŝlosilon al via profilo por ebligi al vi eltiri kaj alpuŝi per SSH."],"Add new directory":["Aldoni novan dosierujon"],"Archived project! Repository is read-only":["Arkivita projekto! La deponejo permesas nur legadon"],"Are you sure you want to delete this pipeline schedule?":["Ĉu vi certe volas forigi ĉi tiun ĉenstablan planon?"],"Attach a file by drag & drop or %{upload_link}":["Alkroĉu dosieron per ŝovmetado aŭ %{upload_link}"],"Branch":["Branĉo","Branĉoj"],"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":["La branĉo <strong>%{branch_name}</strong> estis kreita. Por agordi aŭtomatan disponigadon, bonvolu elekti Yaml-ŝablonon por GitLab CI kaj enmeti viajn ŝanĝojn. %{link_to_autodeploy_doc}"],"Branches":["Branĉoj"],"Browse files":["Elekti dosierojn"],"ByAuthor|by":["de"],"CI configuration":["Agordoj de seninterrompa integrado"],"Cancel":["Nuligi"],"ChangeTypeActionLabel|Pick into branch":["Elekti en branĉon"],"ChangeTypeActionLabel|Revert in branch":["Malfari en branĉo"],"ChangeTypeAction|Cherry-pick":["Precize elekti"],"ChangeTypeAction|Revert":["Malfari"],"Changelog":["Listo de ŝanĝoj"],"Charts":["Diagramoj"],"Cherry-pick this commit":["Precize elekti ĉi tiun kunmetadon"],"Cherry-pick this merge request":["Precize elekti ĉi tiun peton pri kunfando"],"CiStatusLabel|canceled":["nuligita"],"CiStatusLabel|created":["kreita"],"CiStatusLabel|failed":["malsukcesa"],"CiStatusLabel|manual action":["mana ago"],"CiStatusLabel|passed":["sukcesa"],"CiStatusLabel|passed with warnings":["sukcesa, kun avertoj"],"CiStatusLabel|pending":["okazonta"],"CiStatusLabel|skipped":["transsaltita"],"CiStatusLabel|waiting for manual action":["atendanta manan agon"],"CiStatusText|blocked":["blokita"],"CiStatusText|canceled":["nuligita"],"CiStatusText|created":["kreita"],"CiStatusText|failed":["malsukcesa"],"CiStatusText|manual":["mana"],"CiStatusText|passed":["sukcesa"],"CiStatusText|pending":["okazonta"],"CiStatusText|skipped":["transsaltita"],"CiStatus|running":["plenumiĝanta"],"Commit":["Enmetado","Enmetadoj"],"Commit message":["Mesaĝo pri la enmetado"],"CommitBoxTitle|Commit":["Enmeti"],"CommitMessage|Add %{file_name}":["Aldoni „%{file_name}“"],"Commits":["Enmetadoj"],"Commits|History":["Historio"],"Committed by":["Enmetita de"],"Compare":["Kompari"],"Contribution guide":["Gvidlinioj por kontribuado"],"Contributors":["Kontribuantoj"],"Copy URL to clipboard":["Kopii la adreson en la kopibufron"],"Copy commit SHA to clipboard":["Kopii la identigilon de la enmetado"],"Create New Directory":["Krei novan dosierujon"],"Create directory":["Krei dosierujon"],"Create empty bare repository":["Krei malplenan deponejon"],"Create merge request":["Krei peton pri kunfando"],"Create new...":["Krei novan…"],"CreateNewFork|Fork":["Disbranĉigi"],"CreateTag|Tag":["Etikedo"],"Cron Timezone":["Horzono por Cron"],"Cron syntax":["La sintakso de Cron"],"Custom notification events":["Propraj sciigaj eventoj"],"Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}.":["La propraj sciigaj niveloj estas la samaj kiel la niveloj de partoprenado. Uzante la proprajn sciigajn nivelojn, vi ricevos ankaŭ sciigojn por elektitaj de vi eventoj. Por lerni pli, bonvolu vidi %{notification_link}."],"Cycle Analytics":["Cikla analizo"],"Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project.":["La cikla analizo esploras kiom da tempo necesas por disvolvi ideon ĝis ĝi fariĝos realaĵo."],"CycleAnalyticsStage|Code":["Programado"],"CycleAnalyticsStage|Issue":["Problemo"],"CycleAnalyticsStage|Plan":["Plano"],"CycleAnalyticsStage|Production":["Eldonado"],"CycleAnalyticsStage|Review":["Kontrolo"],"CycleAnalyticsStage|Staging":["Preparo por eldono"],"CycleAnalyticsStage|Test":["Testado"],"Define a custom pattern with cron syntax":["Difini propran ŝablonon, uzante la sintakson de Cron"],"Delete":["Forigi"],"Deploy":["Disponigado","Disponigadoj"],"Description":["Priskribo"],"Directory name":["Nomo de dosierujo"],"Don't show again":["Ne montru denove"],"Download":["Elŝuti"],"Download tar":["Elŝuti en formato „tar“"],"Download tar.bz2":["Elŝuti en formato „tar.bz2“"],"Download tar.gz":["Elŝuti en formato „tar.gz“"],"Download zip":["Elŝuti en formato „zip“"],"DownloadArtifacts|Download":["Elŝuti"],"DownloadCommit|Email Patches":["Sendi flikaĵojn per retpoŝto"],"DownloadCommit|Plain Diff":["Normala dosiero kun diferencoj"],"DownloadSource|Download":["Elŝuti"],"Edit":["Redakti"],"Edit Pipeline Schedule %{id}":["Redakti ĉenstablan planon %{id}"],"Every day (at 4:00am)":["Ĉiutage (je 4:00)"],"Every month (on the 1st at 4:00am)":["Ĉiumonate (en la 1a de la monato, je 4:00)"],"Every week (Sundays at 4:00am)":["Ĉiusemajne (en dimanĉo, je 4:00)"],"Failed to change the owner":["Ne eblas ŝanĝi la posedanton"],"Failed to remove the pipeline schedule":["Ne eblas forigi la ĉenstablan planon"],"Files":["Dosieroj"],"Find by path":["Trovi per dosierindiko"],"Find file":["Trovi dosieron"],"FirstPushedBy|First":["Unue"],"FirstPushedBy|pushed by":["alpuŝita de"],"Fork":["Disbranĉigo","Disbranĉigoj"],"ForkedFromProjectPath|Forked from":["Disbranĉigita el"],"From issue creation until deploy to production":["De la kreado de la problemo ĝis la disponigado en la publika versio"],"From merge request merge until deploy to production":["De la kunfandado de la peto pri kunfando ĝis la disponigado en la publika versio"],"Go to your fork":["Al via disbranĉigo"],"GoToYourFork|Fork":["Disbranĉigo"],"Home":["Hejmo"],"Housekeeping successfully started":["La refreŝigo komenciĝis sukcese"],"Import repository":["Enporti deponejon"],"Interval Pattern":["Intervala ŝablono"],"Introducing Cycle Analytics":["Ni prezentas al vi la ciklan analizon"],"LFSStatus|Disabled":["Malŝaltita"],"LFSStatus|Enabled":["Ŝaltita"],"Last %d day":["La lasta %d tago","La lastaj %d tagoj"],"Last Pipeline":["Lasta ĉenstablo"],"Last Update":["Lasta ĝisdatigo"],"Last commit":["Lasta enmetado"],"Learn more in the":["Lernu pli en la"],"Learn more in the|pipeline schedules documentation":["dokumentado pri ĉenstablaj planoj"],"Leave group":["Forlasi la grupon"],"Leave project":["Forlasi la projekton"],"Limited to showing %d event at most":["Limigita al montrado de ne pli ol %d evento","Limigita al montrado de ne pli ol %d eventoj"],"Median":["Mediano"],"MissingSSHKeyWarningLink|add an SSH key":["aldonos SSH-ŝlosilon"],"New Issue":["Nova problemo","Novaj problemoj"],"New Pipeline Schedule":["Nova ĉenstabla plano"],"New branch":["Nova branĉo"],"New directory":["Nova dosierujo"],"New file":["Nova dosiero"],"New issue":["Nova problemo"],"New merge request":["Nova peto pri kunfando"],"New schedule":["Nova plano"],"New snippet":["Nova kodaĵo"],"New tag":["Nova etikedo"],"No repository":["Ne estas deponejo"],"No schedules":["Ne estas planoj"],"Not available":["Ne disponebla"],"Not enough data":["Ne estas sufiĉe da datenoj"],"Notification events":["Sciigaj eventoj"],"NotificationEvent|Close issue":["Fermi problemon"],"NotificationEvent|Close merge request":["Fermi peton pri kunfando"],"NotificationEvent|Failed pipeline":["Malsukcesa ĉenstablo"],"NotificationEvent|Merge merge request":["Apliki peton pri kunfando"],"NotificationEvent|New issue":["Nova problemo"],"NotificationEvent|New merge request":["Nova peto pri kunfando"],"NotificationEvent|New note":["Nova noto"],"NotificationEvent|Reassign issue":["Reatribui problemon"],"NotificationEvent|Reassign merge request":["Reatribui peton pri kunfando"],"NotificationEvent|Reopen issue":["Remalfermi problemon"],"NotificationEvent|Successful pipeline":["Sukcesa ĉenstablo"],"NotificationLevel|Custom":["Propraj"],"NotificationLevel|Disabled":["Malŝaltitaj"],"NotificationLevel|Global":["Ĝeneralaj"],"NotificationLevel|On mention":["Ĉe mencio"],"NotificationLevel|Participate":["Partoprenado"],"NotificationLevel|Watch":["Rigardado"],"OfSearchInADropdown|Filter":["Filtrilo"],"OpenedNDaysAgo|Opened":["Malfermita"],"Options":["Opcioj"],"Owner":["Posedanto"],"Pipeline":["Ĉenstablo"],"Pipeline Health":["Stato"],"Pipeline Schedule":["Ĉenstabla plano"],"Pipeline Schedules":["Ĉenstablaj planoj"],"PipelineSchedules|Activated":["Ŝaltita"],"PipelineSchedules|Active":["Ŝaltitaj"],"PipelineSchedules|All":["Ĉiuj"],"PipelineSchedules|Inactive":["Malŝaltitaj"],"PipelineSchedules|Next Run":["Sekvanta plenumo"],"PipelineSchedules|None":["Nenio"],"PipelineSchedules|Provide a short description for this pipeline":["Entajpu mallongan priskribon pri ĉi tiu ĉenstablo"],"PipelineSchedules|Take ownership":["Akiri posedon"],"PipelineSchedules|Target":["Celo"],"PipelineSheduleIntervalPattern|Custom":["Propra"],"Pipeline|with stage":["kun etapo"],"Pipeline|with stages":["kun etapoj"],"Project '%{project_name}' queued for deletion.":["La projekto „%{project_name}“ estis alvicigita por forigado."],"Project '%{project_name}' was successfully created.":["La projekto „%{project_name}“ estis sukcese kreita."],"Project '%{project_name}' was successfully updated.":["La projekto „%{project_name}“ estis sukcese ĝisdatigita."],"Project '%{project_name}' will be deleted.":["La projekto „%{project_name}“ estos forigita."],"Project access must be granted explicitly to each user.":["Ĉiu uzanto devas akiri propran atingon al la projekto."],"Project export could not be deleted.":["Ne eblas forigi la projektan elporton."],"Project export has been deleted.":["La projekta elporto estis forigita."],"Project export link has expired. Please generate a new export from your project settings.":["La ligilo por la projekta elporto eksvalidiĝis. Bonvolu krei novan elporton en la agordoj de la projekto."],"Project export started. A download link will be sent by email.":["La elporto de la projekto komenciĝis. Vi ricevos ligilon per retpoŝto por elŝuti la datenoj."],"Project home":["Hejmo de la projekto"],"ProjectFeature|Disabled":["Malŝaltita"],"ProjectFeature|Everyone with access":["Ĉiu, kiu havas atingon"],"ProjectFeature|Only team members":["Nur skipanoj"],"ProjectFileTree|Name":["Nomo"],"ProjectLastActivity|Never":["Neniam"],"ProjectLifecycle|Stage":["Etapo"],"ProjectNetworkGraph|Graph":["Grafeo"],"Read more":["Legu pli"],"Readme":["LeguMin"],"RefSwitcher|Branches":["Branĉoj"],"RefSwitcher|Tags":["Etikedoj"],"Related Commits":["Rilataj enmetadoj"],"Related Deployed Jobs":["Rilataj disponigitaj taskoj"],"Related Issues":["Rilataj problemoj"],"Related Jobs":["Rilataj taskoj"],"Related Merge Requests":["Rilataj petoj pri kunfando"],"Related Merged Requests":["Rilataj aplikitaj petoj pri kunfando"],"Remind later":["Rememorigu denove"],"Remove project":["Forigi la projekton"],"Request Access":["Peti atingeblon"],"Revert this commit":["Malfari ĉi tiun enmetadon"],"Revert this merge request":["Malfari ĉi tiun peton pri kunfando"],"Save pipeline schedule":["Konservi ĉenstablan planon"],"Schedule a new pipeline":["Plani novan ĉenstablon"],"Scheduling Pipelines":["Planado de la ĉenstabloj"],"Search branches and tags":["Serĉu branĉon aŭ etikedon"],"Select Archive Format":["Elektu formaton de arkivo"],"Select a timezone":["Elektu horzonon"],"Select target branch":["Elektu celan branĉon"],"Set a password on your account to pull or push via %{protocol}":["Kreu pasvorton por via konto por ebligi al vi eltiri kaj alpuŝi per %{protocol}"],"Set up CI":["Agordi SI"],"Set up Koding":["Agordi „Koding“"],"Set up auto deploy":["Agordi aŭtomatan disponigadon"],"SetPasswordToCloneLink|set a password":["kreos pasvorton"],"Showing %d event":["Estas montrata %d evento","Estas montrataj %d eventoj"],"Source code":["Kodo"],"StarProject|Star":["Steligi"],"Start a %{new_merge_request} with these changes":["Kreu %{new_merge_request} kun ĉi tiuj ŝanĝoj"],"Switch branch/tag":["Iri al branĉo/etikedo"],"Tag":["Etikedo","Etikedoj"],"Tags":["Etikedoj"],"Target Branch":["Cela branĉo"],"The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.":["La etapo de programado montras la tempon de la unua enmetado ĝis la kreado de la peto pri kunfando. La datenoj aldoniĝos aŭtomate ĉi tie post kiam vi kreas la unuan peton pri kunfando."],"The collection of events added to the data gathered for that stage.":["La aro da eventoj, kiuj estas aldonitaj al la datenoj kolektitaj por la etapo."],"The fork relationship has been removed.":["La rilato de disbranĉigo estis forigita."],"The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.":["La etapo de la problemo montras kiom la tempo pasas de la kreado de problemo ĝis la atribuado de la problemo al cela etapo de la projekto, aŭ al listo sur la problemtabulo. Komencu krei problemojn por vidi la datenojn por ĉi tiu etapo."],"The phase of the development lifecycle.":["La etapo de la disvolva ciklo."],"The pipelines schedule runs pipelines in the future, repeatedly, for specific branches or tags. Those scheduled pipelines will inherit limited project access based on their associated user.":["La ĉenstabla plano plenumas ĉenstablojn en la estonteco, ripete, por difinitaj branĉoj aŭ etikedoj. Tiuj planitaj ĉenstabloj heredos la limigitan atingon al la projekto de la rilata uzanto."],"The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.":["La etapo de la plano montras la tempon de la antaŭa ŝtupo ĝis la alpuŝado de via unua enmetado. Ĉi tiu tempo aldoniĝos aŭtomate post kiam vi alpuŝas la unuan enmetadon."],"The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.":["La etapo de eldonado montras la tutan tempon de la kreado de problemo ĝis la disponigado en la publika versio. La datenoj aldoniĝos aŭtomate post kiam vi kompletigos plenan ciklon de ideo ĝis realaĵo."],"The project can be accessed by any logged in user.":["Ĉiu ensalutita uzanto havas atingon al la projekto"],"The project can be accessed without any authentication.":["Ĉiu povas havi atingon al la projekto, sen ensaluti"],"The repository for this project does not exist.":["La deponejo por ĉi tiu projekto ne ekzistas."],"The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.":["La etapo de la kontrolo montras la tempon de la kreado de la peto pri kunfando ĝis ĝia aplikado. La datenoj aldoniĝos aŭtomate post kiam vi aplikos la unuan peton pri kunfando."],"The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.":["La etapo de preparo por eldono montras la tempon inter la aplikado de la peto pri kunfando kaj la disponigado de la kodo en la publika versio. La datenoj aldoniĝos aŭtomate post kiam vi faros la unuan disponigadon en la publika versio."],"The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.":["La etapo de testado montras kiom da tempo necesas al „GitLab CI“ por plenumi ĉiujn ĉenstablojn por la rilata peto pri kunfando. La datenoj aldoniĝos aŭtomate post kiam via unua ĉenstablo finiĝos."],"The time taken by each data entry gathered by that stage.":["La tempo, kiu estas necesa por ĉiu dateno kolektita de la etapo."],"The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.":["La valoro, kiu troviĝas en la mezo de aro da rigardataj valoroj. Ekzemple: inter 3, 5 kaj 9, la mediano estas 5. Inter 3, 5, 7 kaj 8, la mediano estas (5+7)/2 = 6."],"This means you can not push code until you create an empty repository or import existing one.":["Ĉi tiu signifas, ke vi ne povos alpuŝi kodon, antaŭ ol vi kreos malplenan deponejon aŭ enportos jam ekzistantan."],"Time before an issue gets scheduled":["Tempo antaŭ problemo estas planita por ellabori"],"Time before an issue starts implementation":["Tempo antaŭ la komenco de laboro super problemo"],"Time between merge request creation and merge/close":["Tempo inter la kreado de poeto pri kunfando kaj ĝia aplikado/fermado"],"Time until first merge request":["Tempo ĝis la unua peto pri kunfando"],"Timeago|%s days ago":["antaŭ %s tagoj"],"Timeago|%s days remaining":["restas %s tagoj"],"Timeago|%s hours remaining":["restas %s horoj"],"Timeago|%s minutes ago":["antaŭ %s minutoj"],"Timeago|%s minutes remaining":["restas %s minutoj"],"Timeago|%s months ago":["antaŭ %s monatoj"],"Timeago|%s months remaining":["restas %s monatoj"],"Timeago|%s seconds remaining":["restas %s sekundoj"],"Timeago|%s weeks ago":["antaŭ %s semajnoj"],"Timeago|%s weeks remaining":["restas %s semajnoj"],"Timeago|%s years ago":["antaŭ %s jaroj"],"Timeago|%s years remaining":["restas %s jaroj"],"Timeago|1 day remaining":["restas 1 tago"],"Timeago|1 hour remaining":["restas 1 horo"],"Timeago|1 minute remaining":["restas 1 minuto"],"Timeago|1 month remaining":["restas 1 monato"],"Timeago|1 week remaining":["restas 1 semajno"],"Timeago|1 year remaining":["restas 1 jaro"],"Timeago|Past due":["Malfruiĝis"],"Timeago|a day ago":["antaŭ unu tago"],"Timeago|a month ago":["antaŭ unu monato"],"Timeago|a week ago":["antaŭ unu semajno"],"Timeago|a while":["antaŭ iom da tempo"],"Timeago|a year ago":["antaŭ unu jaro"],"Timeago|about %s hours ago":["antaŭ ĉirkaŭ %s horoj"],"Timeago|about a minute ago":["antaŭ ĉirkaŭ unu minuto"],"Timeago|about an hour ago":["antaŭ ĉirkaŭ unu horo"],"Timeago|in %s days":["post %s tagoj"],"Timeago|in %s hours":["post %s horoj"],"Timeago|in %s minutes":["post %s minutoj"],"Timeago|in %s months":["post %s monatoj"],"Timeago|in %s seconds":["post %s sekundoj"],"Timeago|in %s weeks":["post %s semajnoj"],"Timeago|in %s years":["post %s jaroj"],"Timeago|in 1 day":["post 1 tago"],"Timeago|in 1 hour":["post 1 horo"],"Timeago|in 1 minute":["post 1 minuto"],"Timeago|in 1 month":["post 1 monato"],"Timeago|in 1 week":["post 1 semajno"],"Timeago|in 1 year":["post 1 jaro"],"Timeago|less than a minute ago":["antaŭ malpli ol minuto"],"Time|hr":["h","h"],"Time|min":["min","min"],"Time|s":["s"],"Total Time":["Totala tempo"],"Total test time for all commits/merges":["Totala tempo por la testado de ĉiuj enmetadoj/kunfandoj"],"Unstar":["Malsteligi"],"Upload New File":["Alŝuti novan dosieron"],"Upload file":["Alŝuti dosieron"],"Use your global notification setting":["Uzi vian ĝeneralan agordon pri la sciigoj"],"VisibilityLevel|Internal":["Interna"],"VisibilityLevel|Private":["Privata"],"VisibilityLevel|Public":["Publika"],"Want to see the data? Please ask an administrator for access.":["Ĉu vi volas vidi la datenojn? Bonvolu peti atingeblon de administranto."],"We don't have enough data to show this stage.":["Ne estas sufiĉe da datenoj por montri ĉi tiun etapon."],"Withdraw Access Request":["Nuligi la peton pri atingeblo"],"You are going to remove %{project_name_with_namespace}.\\nRemoved project CANNOT be restored!\\nAre you ABSOLUTELY sure?":["Vi forigos „%{project_name_with_namespace}“.\\nOni NE POVAS malfari la forigon de projekto!\\nĈu vi estas ABSOLUTE certa?"],"You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?":["Vi forigos la rilaton de la disbranĉigo al la originala projekto, „%{forked_from_project}“. Ĉu vi estas ABSOLUTE certa?"],"You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?":["Vi transigos „%{project_name_with_namespace}“ al alia posedanto. Ĉu vi estas ABSOLUTE certa?"],"You can only add files when you are on a branch":["Oni povas aldoni dosierojn nur kiam oni estas en branĉo"],"You have reached your project limit":["Vi ne povas krei pliajn projektojn"],"You must sign in to star a project":["Oni devas ensaluti por steligi projekton"],"You need permission.":["VI bezonas permeson."],"You will not get any notifications via email":["VI ne ricevos sciigojn per retpoŝto"],"You will only receive notifications for the events you choose":["Vi ricevos sciigojn nur por la eventoj elektitaj de vi"],"You will only receive notifications for threads you have participated in":["Vi ricevos sciigojn nur por la fadenoj, en kiuj vi partoprenis"],"You will receive notifications for any activity":["Vi ricevos sciigojn por ĉiu ago"],"You will receive notifications only for comments in which you were @mentioned":["Vi ricevos sciigojn nur por komentoj, en kiuj vi estas @menciita"],"You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account":["Vi ne povos eltiri aŭ alpuŝi kodon per %{protocol} antaŭ ol vi %{set_password_link} por via konto"],"You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile":["Vi ne povos eltiri aŭ alpuŝi kodon per SSH antaŭ ol vi %{add_ssh_key_link} al via profilo"],"Your name":["Via nomo"],"day":["tago","tagoj"],"new merge request":["novan peton pri kunfando"],"notification emails":["sciigoj per retpoŝto"],"parent":["patro","patroj"]}}};
\ No newline at end of file diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 786b6014dc6..3cf3233cc65 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -168,9 +168,8 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; // Activate a tab based on the current action activateTab(action) { - const activate = action === 'show' ? 'notes' : action; // important note: the .tab('show') method triggers 'shown.bs.tab' event itself - $(`.merge-request-tabs a[data-action='${activate}']`).tab('show'); + $(`.merge-request-tabs a[data-action='${action}']`).tab('show'); } // Replaces the current Merge Request-specific action in the URL with a new one @@ -185,7 +184,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; // location.pathname # => "/namespace/project/merge_requests/1/diffs" // // location.pathname # => "/namespace/project/merge_requests/1/diffs" - // setCurrentAction('notes') + // setCurrentAction('show') // location.pathname # => "/namespace/project/merge_requests/1" // // location.pathname # => "/namespace/project/merge_requests/1/diffs" @@ -194,13 +193,13 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; // // Returns the new URL String setCurrentAction(action) { - this.currentAction = action === 'show' ? 'notes' : action; + this.currentAction = action; - // Remove a trailing '/commits' '/diffs' '/pipelines' '/new' '/new/diffs' - let newState = location.pathname.replace(/\/(commits|diffs|pipelines|new|new\/diffs)(\.html)?\/?$/, ''); + // Remove a trailing '/commits' '/diffs' '/pipelines' + let newState = location.pathname.replace(/\/(commits|diffs|pipelines)(\.html)?\/?$/, ''); // Append the new action if we're on a tab other than 'notes' - if (this.currentAction !== 'notes') { + if (this.currentAction !== 'show' && this.currentAction !== 'new') { newState += `/${this.currentAction}`; } diff --git a/app/assets/javascripts/monitoring/components/monitoring.vue b/app/assets/javascripts/monitoring/components/monitoring.vue new file mode 100644 index 00000000000..a6a2d3119e3 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/monitoring.vue @@ -0,0 +1,157 @@ +<script> + /* global Flash */ + import _ from 'underscore'; + import statusCodes from '../../lib/utils/http_status'; + import MonitoringService from '../services/monitoring_service'; + import monitoringRow from './monitoring_row.vue'; + import monitoringState from './monitoring_state.vue'; + import MonitoringStore from '../stores/monitoring_store'; + import eventHub from '../event_hub'; + + export default { + + data() { + const metricsData = document.querySelector('#prometheus-graphs').dataset; + const store = new MonitoringStore(); + + return { + store, + state: 'gettingStarted', + hasMetrics: gl.utils.convertPermissionToBoolean(metricsData.hasMetrics), + documentationPath: metricsData.documentationPath, + settingsPath: metricsData.settingsPath, + endpoint: metricsData.additionalMetrics, + deploymentEndpoint: metricsData.deploymentEndpoint, + showEmptyState: true, + backOffRequestCounter: 0, + updateAspectRatio: false, + updatedAspectRatios: 0, + resizeThrottled: {}, + }; + }, + + components: { + monitoringRow, + monitoringState, + }, + + methods: { + getGraphsData() { + const maxNumberOfRequests = 3; + this.state = 'loading'; + gl.utils.backOff((next, stop) => { + this.service.get().then((resp) => { + if (resp.status === statusCodes.NO_CONTENT) { + this.backOffRequestCounter = this.backOffRequestCounter += 1; + if (this.backOffRequestCounter < maxNumberOfRequests) { + next(); + } else { + stop(new Error('Failed to connect to the prometheus server')); + } + } else { + stop(resp); + } + }).catch(stop); + }) + .then((resp) => { + if (resp.status === statusCodes.NO_CONTENT) { + this.state = 'unableToConnect'; + return false; + } + return resp.json(); + }) + .then((metricGroupsData) => { + if (!metricGroupsData) return false; + this.store.storeMetrics(metricGroupsData.data); + return this.getDeploymentData(); + }) + .then((deploymentData) => { + if (deploymentData !== false) { + this.store.storeDeploymentData(deploymentData.deployments); + this.showEmptyState = false; + } + return {}; + }) + .catch(() => { + this.state = 'unableToConnect'; + }); + }, + + getDeploymentData() { + return this.service.getDeploymentData(this.deploymentEndpoint) + .then(resp => resp.json()) + .catch(() => new Flash('Error getting deployment information.')); + }, + + resize() { + this.updateAspectRatio = true; + }, + + toggleAspectRatio() { + this.updatedAspectRatios = this.updatedAspectRatios += 1; + if (this.store.getMetricsCount() === this.updatedAspectRatios) { + this.updateAspectRatio = !this.updateAspectRatio; + this.updatedAspectRatios = 0; + } + }, + + }, + + created() { + this.service = new MonitoringService(this.endpoint); + eventHub.$on('toggleAspectRatio', this.toggleAspectRatio); + }, + + beforeDestroy() { + eventHub.$off('toggleAspectRatio', this.toggleAspectRatio); + window.removeEventListener('resize', this.resizeThrottled, false); + }, + + mounted() { + this.resizeThrottled = _.throttle(this.resize, 600); + if (!this.hasMetrics) { + this.state = 'gettingStarted'; + } else { + this.getGraphsData(); + window.addEventListener('resize', this.resizeThrottled, false); + } + }, + }; +</script> +<template> + <div + class="prometheus-graphs" + v-if="!showEmptyState"> + <div + class="row" + v-for="(groupData, index) in store.groups" + :key="index"> + <div + class="col-md-12"> + <div + class="panel panel-default prometheus-panel"> + <div + class="panel-heading"> + <h4>{{groupData.group}}</h4> + </div> + <div + class="panel-body"> + <monitoring-row + v-for="(row, index) in groupData.metrics" + :key="index" + :row-data="row" + :update-aspect-ratio="updateAspectRatio" + :deployment-data="store.deploymentData" + /> + </div> + </div> + </div> + </div> + </div> + <monitoring-state + :selected-state="state" + :documentation-path="documentationPath" + :settings-path="settingsPath" + v-else + /> +</template> diff --git a/app/assets/javascripts/monitoring/components/monitoring_column.vue b/app/assets/javascripts/monitoring/components/monitoring_column.vue new file mode 100644 index 00000000000..4f4792877ee --- /dev/null +++ b/app/assets/javascripts/monitoring/components/monitoring_column.vue @@ -0,0 +1,291 @@ +<script> + /* global Breakpoints */ + import d3 from 'd3'; + import monitoringLegends from './monitoring_legends.vue'; + import monitoringFlag from './monitoring_flag.vue'; + import monitoringDeployment from './monitoring_deployment.vue'; + import MonitoringMixin from '../mixins/monitoring_mixins'; + import eventHub from '../event_hub'; + import measurements from '../utils/measurements'; + import { formatRelevantDigits } from '../../lib/utils/number_utils'; + + const bisectDate = d3.bisector(d => d.time).left; + + export default { + props: { + columnData: { + type: Object, + required: true, + }, + classType: { + type: String, + required: true, + }, + updateAspectRatio: { + type: Boolean, + required: true, + }, + deploymentData: { + type: Array, + required: true, + }, + }, + + mixins: [MonitoringMixin], + + data() { + return { + graphHeight: 500, + graphWidth: 600, + graphHeightOffset: 120, + xScale: {}, + yScale: {}, + margin: {}, + data: [], + breakpointHandler: Breakpoints.get(), + unitOfDisplay: '', + areaColorRgb: '#8fbce8', + lineColorRgb: '#1f78d1', + yAxisLabel: '', + legendTitle: '', + reducedDeploymentData: [], + area: '', + line: '', + measurements: measurements.large, + currentData: { + time: new Date(), + value: 0, + }, + currentYCoordinate: 0, + currentXCoordinate: 0, + currentFlagPosition: 0, + metricUsage: '', + showFlag: false, + showDeployInfo: true, + }; + }, + + components: { + monitoringLegends, + monitoringFlag, + monitoringDeployment, + }, + + computed: { + outterViewBox() { + return `0 0 ${this.graphWidth} ${this.graphHeight}`; + }, + + innerViewBox() { + if ((this.graphWidth - 150) > 0) { + return `0 0 ${this.graphWidth - 150} ${this.graphHeight}`; + } + return '0 0 0 0'; + }, + + axisTransform() { + return `translate(70, ${this.graphHeight - 100})`; + }, + + paddingBottomRootSvg() { + return (Math.ceil(this.graphHeight * 100) / this.graphWidth) || 0; + }, + }, + + methods: { + draw() { + const breakpointSize = this.breakpointHandler.getBreakpointSize(); + const query = this.columnData.queries[0]; + this.margin = measurements.large.margin; + if (breakpointSize === 'xs' || breakpointSize === 'sm') { + this.graphHeight = 300; + this.margin = measurements.small.margin; + this.measurements = measurements.small; + } + this.data = query.result[0].values; + this.unitOfDisplay = query.unit || 'N/A'; + this.yAxisLabel = this.columnData.y_axis || 'Values'; + this.legendTitle = query.legend || 'Average'; + this.graphWidth = this.$refs.baseSvg.clientWidth - + this.margin.left - this.margin.right; + this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; + if (this.data !== undefined) { + this.renderAxesPaths(); + this.formatDeployments(); + } + }, + + handleMouseOverGraph(e) { + let point = this.$refs.graphData.createSVGPoint(); + point.x = e.clientX; + point.y = e.clientY; + point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); + point.x = point.x += 7; + const timeValueOverlay = this.xScale.invert(point.x); + const overlayIndex = bisectDate(this.data, timeValueOverlay, 1); + const d0 = this.data[overlayIndex - 1]; + const d1 = this.data[overlayIndex]; + if (d0 === undefined || d1 === undefined) return; + const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; + this.currentData = evalTime ? d1 : d0; + this.currentXCoordinate = Math.floor(this.xScale(this.currentData.time)); + const currentDeployXPos = this.mouseOverDeployInfo(point.x); + this.currentYCoordinate = this.yScale(this.currentData.value); + + if (this.currentXCoordinate > (this.graphWidth - 200)) { + this.currentFlagPosition = this.currentXCoordinate - 103; + } else { + this.currentFlagPosition = this.currentXCoordinate; + } + + if (currentDeployXPos) { + this.showFlag = false; + } else { + this.showFlag = true; + } + + this.metricUsage = `${formatRelevantDigits(this.currentData.value)} ${this.unitOfDisplay}`; + }, + + renderAxesPaths() { + const axisXScale = d3.time.scale() + .range([0, this.graphWidth]); + this.yScale = d3.scale.linear() + .range([this.graphHeight - this.graphHeightOffset, 0]); + axisXScale.domain(d3.extent(this.data, d => d.time)); + this.yScale.domain([0, d3.max(this.data.map(d => d.value))]); + + const xAxis = d3.svg.axis() + .scale(axisXScale) + .ticks(measurements.ticks) + .orient('bottom'); + + const yAxis = d3.svg.axis() + .scale(this.yScale) + .ticks(measurements.ticks) + .orient('left'); + + d3.select(this.$refs.baseSvg).select('.x-axis').call(xAxis); + + const width = this.graphWidth; + d3.select(this.$refs.baseSvg).select('.y-axis').call(yAxis) + .selectAll('.tick') + .each(function createTickLines() { + d3.select(this).select('line').attr('x2', width); + }); // This will select all of the ticks once they're rendered + + this.xScale = d3.time.scale() + .range([0, this.graphWidth - 70]); + + this.xScale.domain(d3.extent(this.data, d => d.time)); + + const areaFunction = d3.svg.area() + .x(d => this.xScale(d.time)) + .y0(this.graphHeight - this.graphHeightOffset) + .y1(d => this.yScale(d.value)) + .interpolate('linear'); + + const lineFunction = d3.svg.line() + .x(d => this.xScale(d.time)) + .y(d => this.yScale(d.value)); + + this.line = lineFunction(this.data); + + this.area = areaFunction(this.data); + }, + }, + + watch: { + updateAspectRatio() { + if (this.updateAspectRatio) { + this.graphHeight = 500; + this.graphWidth = 600; + this.measurements = measurements.large; + this.draw(); + eventHub.$emit('toggleAspectRatio'); + } + }, + }, + + mounted() { + this.draw(); + }, + }; +</script> +<template> + <div + :class="classType"> + <h5 + class="text-center"> + {{columnData.title}} + </h5> + <div + class="prometheus-svg-container"> + <svg + :viewBox="outterViewBox" + :style="{ 'padding-bottom': paddingBottomRootSvg }" + ref="baseSvg"> + <g + class="x-axis" + :transform="axisTransform"> + </g> + <g + class="y-axis" + transform="translate(70, 20)"> + </g> + <monitoring-legends + :graph-width="graphWidth" + :graph-height="graphHeight" + :margin="margin" + :measurements="measurements" + :area-color-rgb="areaColorRgb" + :legend-title="legendTitle" + :y-axis-label="yAxisLabel" + :metric-usage="metricUsage" + /> + <svg + class="graph-data" + :viewBox="innerViewBox" + ref="graphData"> + <path + class="metric-area" + :d="area" + :fill="areaColorRgb" + transform="translate(-5, 20)"> + </path> + <path + class="metric-line" + :d="line" + :stroke="lineColorRgb" + fill="none" + stroke-width="2" + transform="translate(-5, 20)"> + </path> + <rect + class="prometheus-graph-overlay" + :width="(graphWidth - 70)" + :height="(graphHeight - 100)" + transform="translate(-5, 20)" + ref="graphOverlay" + @mousemove="handleMouseOverGraph($event)"> + </rect> + <monitoring-deployment + :show-deploy-info="showDeployInfo" + :deployment-data="reducedDeploymentData" + :graph-height="graphHeight" + :graph-height-offset="graphHeightOffset" + /> + <monitoring-flag + v-if="showFlag" + :current-x-coordinate="currentXCoordinate" + :current-y-coordinate="currentYCoordinate" + :current-data="currentData" + :current-flag-position="currentFlagPosition" + :graph-height="graphHeight" + :graph-height-offset="graphHeightOffset" + /> + </svg> + </svg> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/monitoring_deployment.vue b/app/assets/javascripts/monitoring/components/monitoring_deployment.vue new file mode 100644 index 00000000000..e6432ba3191 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/monitoring_deployment.vue @@ -0,0 +1,136 @@ +<script> + import { + dateFormat, + timeFormat, + } from '../constants'; + + export default { + props: { + showDeployInfo: { + type: Boolean, + required: true, + }, + deploymentData: { + type: Array, + required: true, + }, + graphHeight: { + type: Number, + required: true, + }, + graphHeightOffset: { + type: Number, + required: true, + }, + }, + + computed: { + calculatedHeight() { + return this.graphHeight - this.graphHeightOffset; + }, + }, + + methods: { + refText(d) { + return d.tag ? d.ref : d.sha.slice(0, 6); + }, + + formatTime(deploymentTime) { + return timeFormat(deploymentTime); + }, + + formatDate(deploymentTime) { + return dateFormat(deploymentTime); + }, + + nameDeploymentClass(deployment) { + return `deploy-info-${deployment.id}`; + }, + + transformDeploymentGroup(deployment) { + return `translate(${Math.floor(deployment.xPos) + 1}, 20)`; + }, + }, + }; +</script> +<template> + <g + class="deploy-info" + v-if="showDeployInfo"> + <g + v-for="(deployment, index) in deploymentData" + :key="index" + :class="nameDeploymentClass(deployment)" + :transform="transformDeploymentGroup(deployment)"> + <rect + x="0" + y="0" + :height="calculatedHeight" + width="3" + fill="url(#shadow-gradient)"> + </rect> + <line + class="deployment-line" + x1="0" + y1="0" + x2="0" + :y2="calculatedHeight" + stroke="#000"> + </line> + <svg + v-if="deployment.showDeploymentFlag" + class="js-deploy-info-box" + x="3" + y="0" + width="92" + height="60"> + <rect + class="rect-text-metric deploy-info-rect rect-metric" + x="1" + y="1" + rx="2" + width="90" + height="58"> + </rect> + <g + transform="translate(5, 2)"> + <text + class="deploy-info-text text-metric-bold"> + {{refText(deployment)}} + </text> + </g> + <text + class="deploy-info-text" + y="18" + transform="translate(5, 2)"> + {{formatDate(deployment.time)}} + </text> + <text + class="deploy-info-text text-metric-bold" + y="38" + transform="translate(5, 2)"> + {{formatTime(deployment.time)}} + </text> + </svg> + </g> + <svg + height="0" + width="0"> + <defs> + <linearGradient + id="shadow-gradient"> + <stop + offset="0%" + stop-color="#000" + stop-opacity="0.4"> + </stop> + <stop + offset="100%" + stop-color="#000" + stop-opacity="0"> + </stop> + </linearGradient> + </defs> + </svg> + </g> +</template> diff --git a/app/assets/javascripts/monitoring/components/monitoring_flag.vue b/app/assets/javascripts/monitoring/components/monitoring_flag.vue new file mode 100644 index 00000000000..180a771415b --- /dev/null +++ b/app/assets/javascripts/monitoring/components/monitoring_flag.vue @@ -0,0 +1,104 @@ +<script> + import { + dateFormat, + timeFormat, + } from '../constants'; + + export default { + props: { + currentXCoordinate: { + type: Number, + required: true, + }, + currentYCoordinate: { + type: Number, + required: true, + }, + currentFlagPosition: { + type: Number, + required: true, + }, + currentData: { + type: Object, + required: true, + }, + graphHeight: { + type: Number, + required: true, + }, + graphHeightOffset: { + type: Number, + required: true, + }, + }, + + data() { + return { + circleColorRgb: '#8fbce8', + }; + }, + + computed: { + formatTime() { + return timeFormat(this.currentData.time); + }, + + formatDate() { + return dateFormat(this.currentData.time); + }, + + calculatedHeight() { + return this.graphHeight - this.graphHeightOffset; + }, + }, + }; +</script> +<template> + <g class="mouse-over-flag"> + <line + class="selected-metric-line" + :x1="currentXCoordinate" + :y1="0" + :x2="currentXCoordinate" + :y2="calculatedHeight" + transform="translate(-5, 20)"> + </line> + <circle + class="circle-metric" + :fill="circleColorRgb" + stroke="#000" + :cx="currentXCoordinate" + :cy="currentYCoordinate" + r="5" + transform="translate(-5, 20)"> + </circle> + <svg + class="rect-text-metric" + :x="currentFlagPosition" + y="0"> + <rect + class="rect-metric" + x="4" + y="1" + rx="2" + width="90" + height="40" + transform="translate(-3, 20)"> + </rect> + <text + class="text-metric text-metric-bold" + x="8" + y="35" + transform="translate(-5, 20)"> + {{formatTime}} + </text> + <text + class="text-metric-date" + x="8" + y="15" + transform="translate(-5, 20)"> + {{formatDate}} + </text> + </svg> + </g> +</template> diff --git a/app/assets/javascripts/monitoring/components/monitoring_legends.vue b/app/assets/javascripts/monitoring/components/monitoring_legends.vue new file mode 100644 index 00000000000..b30ed3cc889 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/monitoring_legends.vue @@ -0,0 +1,144 @@ +<script> + export default { + props: { + graphWidth: { + type: Number, + required: true, + }, + graphHeight: { + type: Number, + required: true, + }, + margin: { + type: Object, + required: true, + }, + measurements: { + type: Object, + required: true, + }, + areaColorRgb: { + type: String, + required: true, + }, + legendTitle: { + type: String, + required: true, + }, + yAxisLabel: { + type: String, + required: true, + }, + metricUsage: { + type: String, + required: true, + }, + }, + data() { + return { + yLabelWidth: 0, + yLabelHeight: 0, + }; + }, + computed: { + textTransform() { + const yCoordinate = (((this.graphHeight - this.margin.top) + + this.measurements.axisLabelLineOffset) / 2) || 0; + + return `translate(15, ${yCoordinate}) rotate(-90)`; + }, + + rectTransform() { + const yCoordinate = ((this.graphHeight - this.margin.top) / 2) + + (this.yLabelWidth / 2) + 10 || 0; + + return `translate(0, ${yCoordinate}) rotate(-90)`; + }, + + xPosition() { + return (((this.graphWidth + this.measurements.axisLabelLineOffset) / 2) + - this.margin.right) || 0; + }, + + yPosition() { + return ((this.graphHeight - this.margin.top) + this.measurements.axisLabelLineOffset) || 0; + }, + }, + mounted() { + this.$nextTick(() => { + const bbox = this.$refs.ylabel.getBBox(); + this.yLabelWidth = bbox.width + 10; // Added some padding + this.yLabelHeight = bbox.height + 5; + }); + }, + }; +</script> +<template> + <g + class="axis-label-container"> + <line + class="label-x-axis-line" + stroke="#000000" + stroke-width="1" + x1="10" + :y1="yPosition" + :x2="graphWidth + 20" + :y2="yPosition"> + </line> + <line + class="label-y-axis-line" + stroke="#000000" + stroke-width="1" + x1="10" + y1="0" + :x2="10" + :y2="yPosition"> + </line> + <rect + class="rect-axis-text" + :transform="rectTransform" + :width="yLabelWidth" + :height="yLabelHeight"> + </rect> + <text + class="label-axis-text y-label-text" + text-anchor="middle" + :transform="textTransform" + ref="ylabel"> + {{yAxisLabel}} + </text> + <rect + class="rect-axis-text" + :x="xPosition + 50" + :y="graphHeight - 80" + width="50" + height="50"> + </rect> + <text + class="label-axis-text" + :x="xPosition + 60" + :y="yPosition" + dy=".35em"> + Time + </text> + <rect + :fill="areaColorRgb" + :width="measurements.legends.width" + :height="measurements.legends.height" + x="20" + :y="graphHeight - measurements.legendOffset"> + </rect> + <text + class="text-metric-title" + x="50" + :y="graphHeight - 40"> + {{legendTitle}} + </text> + <text + class="text-metric-usage" + x="50" + :y="graphHeight - 25"> + {{metricUsage}} + </text> + </g> +</template> diff --git a/app/assets/javascripts/monitoring/components/monitoring_row.vue b/app/assets/javascripts/monitoring/components/monitoring_row.vue new file mode 100644 index 00000000000..e5528f17880 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/monitoring_row.vue @@ -0,0 +1,41 @@ +<script> + import monitoringColumn from './monitoring_column.vue'; + + export default { + props: { + rowData: { + type: Array, + required: true, + }, + updateAspectRatio: { + type: Boolean, + required: true, + }, + deploymentData: { + type: Array, + required: true, + }, + }, + components: { + monitoringColumn, + }, + computed: { + bootstrapClass() { + return this.rowData.length >= 2 ? 'col-md-6' : 'col-md-12'; + }, + }, + }; +</script> +<template> + <div + class="prometheus-row row"> + <monitoring-column + v-for="(column, index) in rowData" + :column-data="column" + :class-type="bootstrapClass" + :key="index" + :update-aspect-ratio="updateAspectRatio" + :deployment-data="deploymentData" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/monitoring_state.vue b/app/assets/javascripts/monitoring/components/monitoring_state.vue new file mode 100644 index 00000000000..598021aa4df --- /dev/null +++ b/app/assets/javascripts/monitoring/components/monitoring_state.vue @@ -0,0 +1,112 @@ +<script> + import gettingStartedSvg from 'empty_states/monitoring/_getting_started.svg'; + import loadingSvg from 'empty_states/monitoring/_loading.svg'; + import unableToConnectSvg from 'empty_states/monitoring/_unable_to_connect.svg'; + + export default { + props: { + documentationPath: { + type: String, + required: true, + }, + settingsPath: { + type: String, + required: false, + default: '', + }, + selectedState: { + type: String, + required: true, + }, + }, + data() { + return { + states: { + gettingStarted: { + svg: gettingStartedSvg, + title: 'Get started with performance monitoring', + description: 'Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.', + buttonText: 'Configure Prometheus', + }, + loading: { + svg: loadingSvg, + title: 'Waiting for performance data', + description: 'Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.', + buttonText: 'View documentation', + }, + unableToConnect: { + svg: unableToConnectSvg, + title: 'Unable to connect to Prometheus server', + description: 'Ensure connectivity is available from the GitLab server to the ', + buttonText: 'View documentation', + }, + }, + }; + }, + computed: { + currentState() { + return this.states[this.selectedState]; + }, + + buttonPath() { + if (this.selectedState === 'gettingStarted') { + return this.settingsPath; + } + return this.documentationPath; + }, + + showButtonDescription() { + if (this.selectedState === 'unableToConnect') return true; + return false; + }, + }, + }; +</script> +<template> + <div + class="prometheus-state"> + <div + class="row"> + <div + class="col-md-4 col-md-offset-4 state-svg" + v-html="currentState.svg"> + </div> + </div> + <div + class="row"> + <div + class="col-md-6 col-md-offset-3"> + <h4 + class="text-center state-title"> + {{currentState.title}} + </h4> + </div> + </div> + <div + class="row"> + <div + class="col-md-6 col-md-offset-3"> + <div + class="description-text text-center state-description"> + {{currentState.description}} + <a + :href="settingsPath" + v-if="showButtonDescription"> + Prometheus server + </a> + </div> + </div> + </div> + <div + class="row state-button-section"> + <div + class="col-md-4 col-md-offset-4 text-center state-button"> + <a + class="btn btn-success" + :href="buttonPath"> + {{currentState.buttonText}} + </a> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/deployments.js b/app/assets/javascripts/monitoring/deployments.js deleted file mode 100644 index fc92ab61b31..00000000000 --- a/app/assets/javascripts/monitoring/deployments.js +++ /dev/null @@ -1,211 +0,0 @@ -/* global Flash */ -import d3 from 'd3'; -import { - dateFormat, - timeFormat, -} from './constants'; - -export default class Deployments { - constructor(width, height) { - this.width = width; - this.height = height; - - this.endpoint = document.getElementById('js-metrics').dataset.deploymentEndpoint; - - this.createGradientDef(); - } - - init(chartData) { - this.chartData = chartData; - - this.x = d3.time.scale().range([0, this.width]); - this.x.domain(d3.extent(this.chartData, d => d.time)); - - this.charts = d3.selectAll('.prometheus-graph'); - - this.getData(); - } - - getData() { - $.ajax({ - url: this.endpoint, - dataType: 'JSON', - }) - .fail(() => new Flash('Error getting deployment information.')) - .done((data) => { - this.data = data.deployments.reduce((deploymentDataArray, deployment) => { - const time = new Date(deployment.created_at); - const xPos = Math.floor(this.x(time)); - - time.setSeconds(this.chartData[0].time.getSeconds()); - - if (xPos >= 0) { - deploymentDataArray.push({ - id: deployment.id, - time, - sha: deployment.sha, - tag: deployment.tag, - ref: deployment.ref.name, - xPos, - }); - } - - return deploymentDataArray; - }, []); - - this.plotData(); - }); - } - - plotData() { - this.charts.each((d, i) => { - const svg = d3.select(this.charts[0][i]); - const chart = svg.select('.graph-container'); - const key = svg.node().getAttribute('graph-type'); - - this.createLine(chart, key); - this.createDeployInfoBox(chart, key); - }); - } - - createGradientDef() { - const defs = d3.select('body') - .append('svg') - .attr({ - height: 0, - width: 0, - }) - .append('defs'); - - defs.append('linearGradient') - .attr({ - id: 'shadow-gradient', - }) - .append('stop') - .attr({ - offset: '0%', - 'stop-color': '#000', - 'stop-opacity': 0.4, - }) - .select(this.selectParentNode) - .append('stop') - .attr({ - offset: '100%', - 'stop-color': '#000', - 'stop-opacity': 0, - }); - } - - createLine(chart, key) { - chart.append('g') - .attr({ - class: 'deploy-info', - }) - .selectAll('.deploy-info') - .data(this.data) - .enter() - .append('g') - .attr({ - class: d => `deploy-info-${d.id}-${key}`, - transform: d => `translate(${Math.floor(d.xPos) + 1}, 0)`, - }) - .append('rect') - .attr({ - x: 1, - y: 0, - height: this.height + 1, - width: 3, - fill: 'url(#shadow-gradient)', - }) - .select(this.selectParentNode) - .append('line') - .attr({ - class: 'deployment-line', - x1: 0, - x2: 0, - y1: 0, - y2: this.height + 1, - }); - } - - createDeployInfoBox(chart, key) { - chart.selectAll('.deploy-info') - .selectAll('.js-deploy-info-box') - .data(this.data) - .enter() - .select(d => document.querySelector(`.deploy-info-${d.id}-${key}`)) - .append('svg') - .attr({ - class: 'js-deploy-info-box hidden', - x: 3, - y: 0, - width: 92, - height: 60, - }) - .append('rect') - .attr({ - class: 'rect-text-metric deploy-info-rect rect-metric', - x: 1, - y: 1, - rx: 2, - width: 90, - height: 58, - }) - .select(this.selectParentNode) - .append('g') - .attr({ - transform: 'translate(5, 2)', - }) - .append('text') - .attr({ - class: 'deploy-info-text text-metric-bold', - }) - .text(Deployments.refText) - .select(this.selectParentNode) - .append('text') - .attr({ - class: 'deploy-info-text', - y: 18, - }) - .text(d => dateFormat(d.time)) - .select(this.selectParentNode) - .append('text') - .attr({ - class: 'deploy-info-text text-metric-bold', - y: 38, - }) - .text(d => timeFormat(d.time)); - } - - static toggleDeployTextbox(deploy, key, showInfoBox) { - d3.selectAll(`.deploy-info-${deploy.id}-${key} .js-deploy-info-box`) - .classed('hidden', !showInfoBox); - } - - mouseOverDeployInfo(mouseXPos, key) { - if (!this.data) return false; - - let dataFound = false; - - this.data.forEach((d) => { - if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) { - dataFound = d.xPos + 1; - - Deployments.toggleDeployTextbox(d, key, true); - } else { - Deployments.toggleDeployTextbox(d, key, false); - } - }); - - return dataFound; - } - - /* `this` is bound to the D3 node */ - selectParentNode() { - return this.parentNode; - } - - static refText(d) { - return d.tag ? d.ref : d.sha.slice(0, 6); - } -} diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/monitoring/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/monitoring/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js new file mode 100644 index 00000000000..8e62fa63f13 --- /dev/null +++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js @@ -0,0 +1,46 @@ +const mixins = { + methods: { + mouseOverDeployInfo(mouseXPos) { + if (!this.reducedDeploymentData) return false; + + let dataFound = false; + this.reducedDeploymentData = this.reducedDeploymentData.map((d) => { + const deployment = d; + if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) { + dataFound = d.xPos + 1; + + deployment.showDeploymentFlag = true; + } else { + deployment.showDeploymentFlag = false; + } + return deployment; + }); + + return dataFound; + }, + formatDeployments() { + this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => { + const time = new Date(deployment.created_at); + const xPos = Math.floor(this.xScale(time)); + + time.setSeconds(this.data[0].time.getSeconds()); + + if (xPos >= 0) { + deploymentDataArray.push({ + id: deployment.id, + time, + sha: deployment.sha, + tag: deployment.tag, + ref: deployment.ref.name, + xPos, + showDeploymentFlag: false, + }); + } + + return deploymentDataArray; + }, []); + }, + }, +}; + +export default mixins; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index b3ce9310417..5d5cb56af72 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,6 +1,10 @@ -import PrometheusGraph from './prometheus_graph'; +import Vue from 'vue'; +import Monitoring from './components/monitoring.vue'; -document.addEventListener('DOMContentLoaded', function onLoad() { - document.removeEventListener('DOMContentLoaded', onLoad, false); - return new PrometheusGraph(); -}, false); +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#prometheus-graphs', + components: { + 'monitoring-dashboard': Monitoring, + }, + render: createElement => createElement('monitoring-dashboard'), +})); diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js deleted file mode 100644 index 6af88769129..00000000000 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ /dev/null @@ -1,433 +0,0 @@ -/* eslint-disable no-new */ -/* global Flash */ - -import d3 from 'd3'; -import statusCodes from '~/lib/utils/http_status'; -import Deployments from './deployments'; -import '../lib/utils/common_utils'; -import { formatRelevantDigits } from '../lib/utils/number_utils'; -import '../flash'; -import { - dateFormat, - timeFormat, -} from './constants'; - -const prometheusContainer = '.prometheus-container'; -const prometheusParentGraphContainer = '.prometheus-graphs'; -const prometheusGraphsContainer = '.prometheus-graph'; -const prometheusStatesContainer = '.prometheus-state'; -const metricsEndpoint = 'metrics.json'; -const bisectDate = d3.bisector(d => d.time).left; -const extraAddedWidthParent = 100; - -class PrometheusGraph { - constructor() { - const $prometheusContainer = $(prometheusContainer); - const hasMetrics = $prometheusContainer.data('has-metrics'); - this.docLink = $prometheusContainer.data('doc-link'); - this.integrationLink = $prometheusContainer.data('prometheus-integration'); - this.state = ''; - - $(document).ajaxError(() => {}); - - if (hasMetrics) { - this.margin = { top: 80, right: 180, bottom: 80, left: 100 }; - this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 }; - const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + - extraAddedWidthParent; - this.originalWidth = parentContainerWidth; - this.originalHeight = 330; - this.width = parentContainerWidth - this.margin.left - this.margin.right; - this.height = this.originalHeight - this.margin.top - this.margin.bottom; - this.backOffRequestCounter = 0; - this.deployments = new Deployments(this.width, this.height); - this.configureGraph(); - this.init(); - } else { - const prevState = this.state; - this.state = '.js-getting-started'; - this.updateState(prevState); - } - } - - createGraph() { - Object.keys(this.graphSpecificProperties).forEach((key) => { - const value = this.graphSpecificProperties[key]; - if (value.data.length > 0) { - this.plotValues(key); - } - }); - } - - init() { - return this.getData().then((metricsResponse) => { - let enoughData = true; - if (typeof metricsResponse === 'undefined') { - enoughData = false; - } else { - Object.keys(metricsResponse.metrics).forEach((key) => { - if (key === 'cpu_values' || key === 'memory_values') { - const currentData = (metricsResponse.metrics[key])[0]; - if (currentData.values.length <= 2) { - enoughData = false; - } - } - }); - } - if (enoughData) { - $(prometheusStatesContainer).hide(); - $(prometheusParentGraphContainer).show(); - this.transformData(metricsResponse); - this.createGraph(); - - const firstMetricData = this.graphSpecificProperties[ - Object.keys(this.graphSpecificProperties)[0] - ].data; - - this.deployments.init(firstMetricData); - } - }); - } - - plotValues(key) { - const graphSpecifics = this.graphSpecificProperties[key]; - - const x = d3.time.scale() - .range([0, this.width]); - - const y = d3.scale.linear() - .range([this.height, 0]); - - graphSpecifics.xScale = x; - graphSpecifics.yScale = y; - - const prometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; - - const chart = d3.select(prometheusGraphContainer) - .attr('width', this.width + this.margin.left + this.margin.right) - .attr('height', this.height + this.margin.bottom + this.margin.top) - .append('g') - .attr('class', 'graph-container') - .attr('transform', `translate(${this.margin.left},${this.margin.top})`); - - const axisLabelContainer = d3.select(prometheusGraphContainer) - .attr('width', this.originalWidth) - .attr('height', this.originalHeight) - .append('g') - .attr('transform', `translate(${this.marginLabelContainer.left},${this.marginLabelContainer.top})`); - - x.domain(d3.extent(graphSpecifics.data, d => d.time)); - y.domain([0, d3.max(graphSpecifics.data.map(metricValue => metricValue.value))]); - - const xAxis = d3.svg.axis() - .scale(x) - .ticks(this.commonGraphProperties.axis_no_ticks) - .orient('bottom'); - - const yAxis = d3.svg.axis() - .scale(y) - .ticks(this.commonGraphProperties.axis_no_ticks) - .tickSize(-this.width) - .outerTickSize(0) - .orient('left'); - - this.createAxisLabelContainers(axisLabelContainer, key); - - chart.append('g') - .attr('class', 'x-axis') - .attr('transform', `translate(0,${this.height})`) - .call(xAxis); - - chart.append('g') - .attr('class', 'y-axis') - .call(yAxis); - - const area = d3.svg.area() - .x(d => x(d.time)) - .y0(this.height) - .y1(d => y(d.value)) - .interpolate('linear'); - - const line = d3.svg.line() - .x(d => x(d.time)) - .y(d => y(d.value)); - - chart.append('path') - .datum(graphSpecifics.data) - .attr('d', area) - .attr('class', 'metric-area') - .attr('fill', graphSpecifics.area_fill_color); - - chart.append('path') - .datum(graphSpecifics.data) - .attr('class', 'metric-line') - .attr('stroke', graphSpecifics.line_color) - .attr('fill', 'none') - .attr('stroke-width', this.commonGraphProperties.area_stroke_width) - .attr('d', line); - - // Overlay area for the mouseover events - chart.append('rect') - .attr('class', 'prometheus-graph-overlay') - .attr('width', this.width) - .attr('height', this.height) - .on('mousemove', this.handleMouseOverGraph.bind(this, prometheusGraphContainer)); - } - - // The legends from the metric - createAxisLabelContainers(axisLabelContainer, key) { - const graphSpecifics = this.graphSpecificProperties[key]; - - axisLabelContainer.append('line') - .attr('class', 'label-x-axis-line') - .attr('stroke', '#000000') - .attr('stroke-width', '1') - .attr({ - x1: 10, - y1: this.originalHeight - this.margin.top, - x2: (this.originalWidth - this.margin.right) + 10, - y2: this.originalHeight - this.margin.top, - }); - - axisLabelContainer.append('line') - .attr('class', 'label-y-axis-line') - .attr('stroke', '#000000') - .attr('stroke-width', '1') - .attr({ - x1: 10, - y1: 0, - x2: 10, - y2: this.originalHeight - this.margin.top, - }); - - axisLabelContainer.append('rect') - .attr('class', 'rect-axis-text') - .attr('x', 0) - .attr('y', 50) - .attr('width', 30) - .attr('height', 150); - - axisLabelContainer.append('text') - .attr('class', 'label-axis-text') - .attr('text-anchor', 'middle') - .attr('transform', `translate(15, ${(this.originalHeight - this.margin.top) / 2}) rotate(-90)`) - .text(graphSpecifics.graph_legend_title); - - axisLabelContainer.append('rect') - .attr('class', 'rect-axis-text') - .attr('x', (this.originalWidth / 2) - this.margin.right) - .attr('y', this.originalHeight - 100) - .attr('width', 30) - .attr('height', 80); - - axisLabelContainer.append('text') - .attr('class', 'label-axis-text') - .attr('x', (this.originalWidth / 2) - this.margin.right) - .attr('y', this.originalHeight - this.margin.top) - .attr('dy', '.35em') - .text('Time'); - - // Legends - - // Metric Usage - axisLabelContainer.append('rect') - .attr('x', this.originalWidth - 170) - .attr('y', (this.originalHeight / 2) - 60) - .style('fill', graphSpecifics.area_fill_color) - .attr('width', 20) - .attr('height', 35); - - axisLabelContainer.append('text') - .attr('class', 'text-metric-title') - .attr('x', this.originalWidth - 140) - .attr('y', (this.originalHeight / 2) - 50) - .text('Average'); - - axisLabelContainer.append('text') - .attr('class', 'text-metric-usage') - .attr('x', this.originalWidth - 140) - .attr('y', (this.originalHeight / 2) - 25); - } - - handleMouseOverGraph(prometheusGraphContainer) { - const rectOverlay = document.querySelector(`${prometheusGraphContainer} .prometheus-graph-overlay`); - const currentXCoordinate = d3.mouse(rectOverlay)[0]; - - Object.keys(this.graphSpecificProperties).forEach((key) => { - const currentGraphProps = this.graphSpecificProperties[key]; - const timeValueOverlay = currentGraphProps.xScale.invert(currentXCoordinate); - const overlayIndex = bisectDate(currentGraphProps.data, timeValueOverlay, 1); - const d0 = currentGraphProps.data[overlayIndex - 1]; - const d1 = currentGraphProps.data[overlayIndex]; - const evalTime = timeValueOverlay - d0.time > d1.time - timeValueOverlay; - const currentData = evalTime ? d1 : d0; - const currentTimeCoordinate = Math.floor(currentGraphProps.xScale(currentData.time)); - const currentDeployXPos = this.deployments.mouseOverDeployInfo(currentXCoordinate, key); - const currentPrometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; - const maxValueFromData = d3.max(currentGraphProps.data.map(metricValue => metricValue.value)); - const maxMetricValue = currentGraphProps.yScale(maxValueFromData); - - // Clear up all the pieces of the flag - d3.selectAll(`${currentPrometheusGraphContainer} .selected-metric-line`).remove(); - d3.selectAll(`${currentPrometheusGraphContainer} .circle-metric`).remove(); - d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric:not(.deploy-info-rect)`).remove(); - - const currentChart = d3.select(currentPrometheusGraphContainer).select('g'); - currentChart.append('line') - .attr({ - class: `${currentDeployXPos ? 'hidden' : ''} selected-metric-line`, - x1: currentTimeCoordinate, - y1: currentGraphProps.yScale(0), - x2: currentTimeCoordinate, - y2: maxMetricValue, - }); - - currentChart.append('circle') - .attr('class', 'circle-metric') - .attr('fill', currentGraphProps.line_color) - .attr('cx', currentDeployXPos || currentTimeCoordinate) - .attr('cy', currentGraphProps.yScale(currentData.value)) - .attr('r', this.commonGraphProperties.circle_radius_metric); - - if (currentDeployXPos) return; - - // The little box with text - const rectTextMetric = currentChart.append('svg') - .attr({ - class: 'rect-text-metric', - x: currentTimeCoordinate, - y: 0, - }); - - rectTextMetric.append('rect') - .attr({ - class: 'rect-metric', - x: 4, - y: 1, - rx: 2, - width: this.commonGraphProperties.rect_text_width, - height: this.commonGraphProperties.rect_text_height, - }); - - rectTextMetric.append('text') - .attr({ - class: 'text-metric text-metric-bold', - x: 8, - y: 35, - }) - .text(timeFormat(currentData.time)); - - rectTextMetric.append('text') - .attr({ - class: 'text-metric-date', - x: 8, - y: 15, - }) - .text(dateFormat(currentData.time)); - - let currentMetricValue = formatRelevantDigits(currentData.value); - if (key === 'cpu_values') { - currentMetricValue = `${currentMetricValue}%`; - } else { - currentMetricValue = `${currentMetricValue} MB`; - } - - d3.select(`${currentPrometheusGraphContainer} .text-metric-usage`) - .text(currentMetricValue); - }); - } - - configureGraph() { - this.graphSpecificProperties = { - cpu_values: { - area_fill_color: '#edf3fc', - line_color: '#5b99f7', - graph_legend_title: 'CPU Usage (Cores)', - data: [], - xScale: {}, - yScale: {}, - }, - memory_values: { - area_fill_color: '#fca326', - line_color: '#fc6d26', - graph_legend_title: 'Memory Usage (MB)', - data: [], - xScale: {}, - yScale: {}, - }, - }; - - this.commonGraphProperties = { - area_stroke_width: 2, - median_total_characters: 8, - circle_radius_metric: 5, - rect_text_width: 90, - rect_text_height: 40, - axis_no_ticks: 3, - }; - } - - getData() { - const maxNumberOfRequests = 3; - this.state = '.js-loading'; - this.updateState(); - return gl.utils.backOff((next, stop) => { - $.ajax({ - url: metricsEndpoint, - dataType: 'json', - }) - .done((data, statusText, resp) => { - if (resp.status === statusCodes.NO_CONTENT) { - this.backOffRequestCounter = this.backOffRequestCounter += 1; - if (this.backOffRequestCounter < maxNumberOfRequests) { - next(); - } else if (this.backOffRequestCounter >= maxNumberOfRequests) { - stop(new Error('loading')); - } - } else if (!data.success) { - stop(new Error('loading')); - } else { - stop({ - status: resp.status, - metrics: data, - }); - } - }).fail(stop); - }) - .then((resp) => { - if (resp.status === statusCodes.NO_CONTENT) { - return {}; - } - return resp.metrics; - }) - .catch(() => { - const prevState = this.state; - this.state = '.js-unable-to-connect'; - this.updateState(prevState); - }); - } - - transformData(metricsResponse) { - Object.keys(metricsResponse.metrics).forEach((key) => { - if (key === 'cpu_values' || key === 'memory_values') { - const metricValues = (metricsResponse.metrics[key])[0]; - this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({ - time: new Date(metric[0] * 1000), - value: metric[1], - })); - } - }); - } - - updateState(prevState) { - const $statesContainer = $(prometheusStatesContainer); - $(prometheusParentGraphContainer).hide(); - if (prevState) { - $(`${prevState}`, $statesContainer).addClass('hidden'); - } - $(`${this.state}`, $statesContainer).removeClass('hidden'); - $(prometheusStatesContainer).show(); - } -} - -export default PrometheusGraph; diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js new file mode 100644 index 00000000000..1e9ae934853 --- /dev/null +++ b/app/assets/javascripts/monitoring/services/monitoring_service.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class MonitoringService { + constructor(endpoint) { + this.graphs = Vue.resource(endpoint); + } + + get() { + return this.graphs.get(); + } + + // eslint-disable-next-line class-methods-use-this + getDeploymentData(endpoint) { + return Vue.http.get(endpoint); + } +} diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js new file mode 100644 index 00000000000..737c964f12e --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -0,0 +1,61 @@ +import _ from 'underscore'; + +class MonitoringStore { + constructor() { + this.groups = []; + this.deploymentData = []; + } + + // eslint-disable-next-line class-methods-use-this + createArrayRows(metrics = []) { + const currentMetrics = metrics; + const availableMetrics = []; + let metricsRow = []; + let index = 1; + Object.keys(currentMetrics).forEach((key) => { + const metricValues = currentMetrics[key].queries[0].result[0].values; + if (metricValues != null) { + const literalMetrics = metricValues.map(metric => ({ + time: new Date(metric[0] * 1000), + value: metric[1], + })); + currentMetrics[key].queries[0].result[0].values = literalMetrics; + metricsRow.push(currentMetrics[key]); + if (index % 2 === 0) { + availableMetrics.push(metricsRow); + metricsRow = []; + } + index = index += 1; + } + }); + if (metricsRow.length > 0) { + availableMetrics.push(metricsRow); + } + return availableMetrics; + } + + storeMetrics(groups = []) { + this.groups = groups.map((group) => { + const currentGroup = group; + currentGroup.metrics = _.chain(group.metrics).sortBy('weight').sortBy('title').value(); + currentGroup.metrics = this.createArrayRows(currentGroup.metrics); + return currentGroup; + }); + } + + storeDeploymentData(deploymentData = []) { + this.deploymentData = deploymentData; + } + + getMetricsCount() { + let metricsCount = 0; + this.groups.forEach((group) => { + group.metrics.forEach((metric) => { + metricsCount = metricsCount += metric.length; + }); + }); + return metricsCount; + } +} + +export default MonitoringStore; diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js new file mode 100644 index 00000000000..a60d2522f49 --- /dev/null +++ b/app/assets/javascripts/monitoring/utils/measurements.js @@ -0,0 +1,39 @@ +export default { + small: { // Covers both xs and sm screen sizes + margin: { + top: 40, + right: 40, + bottom: 50, + left: 40, + }, + legends: { + width: 15, + height: 30, + }, + backgroundLegend: { + width: 30, + height: 50, + }, + axisLabelLineOffset: -20, + legendOffset: 52, + }, + large: { // This covers both md and lg screen sizes + margin: { + top: 80, + right: 80, + bottom: 100, + left: 80, + }, + legends: { + width: 20, + height: 35, + }, + backgroundLegend: { + width: 30, + height: 150, + }, + axisLabelLineOffset: 20, + legendOffset: 55, + }, + ticks: 3, +}; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b21d7774920..34476f3303f 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1485,7 +1485,7 @@ export default class Notes { const cachedNoteBodyText = $noteBodyText.html(); // Show updated comment content temporarily - $noteBodyText.html(_.escape(formContent)); + $noteBodyText.html(formContent); $editingNote.removeClass('is-editing fade-in-full').addClass('being-posted fade-in-half'); $editingNote.find('.note-headline-meta a').html('<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>'); diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 322162afdb8..b5cd01044a3 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -10,6 +10,8 @@ import Cookies from 'js-cookie'; this.$sidebarInner = this.sidebar.find('.issuable-sidebar'); this.$navGitlab = $('.navbar-gitlab'); + this.$layoutNav = $('.layout-nav'); + this.$subScroll = $('.sub-nav-scroll'); this.$rightSidebar = $('.js-right-sidebar'); this.removeListeners(); @@ -27,14 +29,14 @@ import Cookies from 'js-cookie'; Sidebar.prototype.addEventListeners = function() { const $document = $(document); const throttledSetSidebarHeight = _.throttle(this.setSidebarHeight.bind(this), 20); - const debouncedSetSidebarHeight = _.debounce(this.setSidebarHeight.bind(this), 200); + const slowerThrottledSetSidebarHeight = _.throttle(this.setSidebarHeight.bind(this), 200); this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked); $('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden); $('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading); $('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded); $(window).on('resize', () => throttledSetSidebarHeight()); - $document.on('scroll', () => debouncedSetSidebarHeight()); + $document.on('scroll', () => slowerThrottledSetSidebarHeight()); $document.on('click', '.js-sidebar-toggle', function(e, triggered) { var $allGutterToggleIcons, $this, $thisIcon; e.preventDefault(); @@ -213,7 +215,7 @@ import Cookies from 'js-cookie'; }; Sidebar.prototype.setSidebarHeight = function() { - const $navHeight = this.$navGitlab.outerHeight(); + const $navHeight = this.$navGitlab.outerHeight() + this.$layoutNav.outerHeight() + (this.$subScroll ? this.$subScroll.outerHeight() : 0); const diff = $navHeight - $(window).scrollTop(); if (diff > 0) { this.$rightSidebar.outerHeight($(window).height() - diff); diff --git a/app/assets/javascripts/vue_shared/components/loading_icon.vue b/app/assets/javascripts/vue_shared/components/loading_icon.vue index 41b1d0165b0..15581d5c2a0 100644 --- a/app/assets/javascripts/vue_shared/components/loading_icon.vue +++ b/app/assets/javascripts/vue_shared/components/loading_icon.vue @@ -12,9 +12,18 @@ required: false, default: '1', }, + + inline: { + type: Boolean, + required: false, + default: false, + }, }, computed: { + rootElementType() { + return this.inline ? 'span' : 'div'; + }, cssClass() { return `fa-${this.size}x`; }, @@ -22,12 +31,14 @@ }; </script> <template> - <div class="text-center"> + <component + :is="this.rootElementType" + class="text-center"> <i class="fa fa-spin fa-spinner" :class="cssClass" aria-hidden="true" :aria-label="label"> </i> - </div> + </component> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index e6977681e96..8303c556f64 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -64,6 +64,12 @@ */ return new gl.GLForm($(this.$refs['gl-form']), true); }, + beforeDestroy() { + const glForm = $(this.$refs['gl-form']).data('gl-form'); + if (glForm) { + glForm.destroy(); + } + }, }; </script> diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 630f557602c..da4d91511e0 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -74,6 +74,8 @@ $red-700: #a62d19; $red-800: #8b2615; $red-900: #711e11; +$purple-600: #6e49cb; +$purple-650: #5c35ae; $purple-700: #4a2192; $purple-800: #2c0a5c; $purple-900: #380d75; @@ -103,6 +105,7 @@ $well-light-text-color: #5b6169; */ $gl-font-size: 14px; $gl-text-color: rgba(0, 0, 0, .85); +$gl-text-color-light: rgba(0, 0, 0, .7); $gl-text-color-secondary: rgba(0, 0, 0, .55); $gl-text-color-disabled: rgba(0, 0, 0, .35); $gl-text-color-inverted: rgba(255, 255, 255, 1.0); diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss index 441bfc479f6..3ce5b4fd073 100644 --- a/app/assets/stylesheets/new_nav.scss +++ b/app/assets/stylesheets/new_nav.scss @@ -11,20 +11,19 @@ header.navbar-gitlab-new { padding-left: 0; .title-container { + align-items: stretch; padding-top: 0; overflow: visible; } .title { - display: block; - height: 100%; + display: flex; padding-right: 0; color: currentColor; > a { display: flex; align-items: center; - height: 100%; padding-top: 3px; padding-right: $gl-padding; padding-left: $gl-padding; diff --git a/app/assets/stylesheets/new_sidebar.scss b/app/assets/stylesheets/new_sidebar.scss new file mode 100644 index 00000000000..be4cc02b3ea --- /dev/null +++ b/app/assets/stylesheets/new_sidebar.scss @@ -0,0 +1,150 @@ +@import "framework/variables"; +@import 'framework/tw_bootstrap_variables'; +@import "bootstrap/variables"; + +$new-sidebar-width: 220px; + +.page-with-new-sidebar { + @media (min-width: $screen-sm-min) { + padding-left: $new-sidebar-width; + } + + // Override position: absolute + .right-sidebar { + position: fixed; + height: 100%; + } +} + +.context-header { + background-color: $gray-normal; + border-bottom: 1px solid $border-color; + font-weight: 600; + display: flex; + align-items: center; + padding: 10px 14px; + + .avatar-container { + flex: 0 0 40px; + } + + &:hover { + background-color: $border-color; + } +} + +.settings-avatar { + background-color: $white-light; + + i { + font-size: 20px; + width: 100%; + color: $gl-text-color-secondary; + text-align: center; + align-self: center; + } +} + +.nav-sidebar { + position: fixed; + z-index: 400; + width: $new-sidebar-width; + top: 50px; + bottom: 0; + left: 0; + overflow: auto; + background-color: $gray-light; + border-right: 1px solid $border-color; + + ul { + padding: 0; + list-style: none; + } + + li { + a { + display: block; + padding: 12px 14px; + } + } + + a { + color: $gl-text-color; + text-decoration: none; + } +} + +.sidebar-sub-level-items { + display: none; + + > li { + a { + padding: 12px 24px; + color: $gl-text-color-light; + + &:hover { + color: $gl-text-color; + background-color: $border-color; + } + } + + &.active { + > a { + color: $purple-650; + font-weight: 600; + } + } + } +} + +.sidebar-top-level-items { + > li { + .badge { + float: right; + background-color: $border-color; + color: $gl-text-color; + } + + &.active { + > a { + background-color: $purple-600; + color: $white-light; + font-weight: 600; + } + + .badge { + background-color: $purple-700; + color: $white-light; + } + + .sidebar-sub-level-items { + background-color: $gray-normal; + border-left: 6px solid $purple-600; + display: block; + } + } + + &:not(.active) > a:hover { + background-color: $border-color; + + .badge { + transition: background-color 100ms linear; + background-color: $gray-normal; + } + } + } +} + + +// Make issue boards full-height now that sub-nav is gone + +.boards-list { + height: calc(100vh - 50px); + + @media (min-width: $screen-sm-min) { + height: 475px; // Needed for PhantomJS + // scss-lint:disable DuplicateProperty + height: calc(100vh - 120px); + // scss-lint:enable DuplicateProperty + } +} diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 7eee0a71c66..9cff99b839c 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -147,10 +147,9 @@ top: 35px; left: 10px; bottom: 0; - overflow-y: scroll; - overflow-x: hidden; padding: 10px 20px 20px 5px; - white-space: pre; + white-space: pre-wrap; + overflow: auto; } .environment-information { @@ -399,6 +398,7 @@ .build-light-text { color: $gl-text-color-secondary; + word-wrap: break-word; } .build-gutter-toggle { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 1046ebfa2e2..a2be957655f 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -140,23 +140,6 @@ } } -.prometheus-graph { - text { - fill: $gl-text-color; - stroke-width: 0; - } - - .label-axis-text, - .text-metric-usage { - fill: $black; - font-weight: 500; - } - - .legend-axis-text { - fill: $black; - } -} - .x-axis path, .y-axis path, .label-x-axis-line, @@ -205,6 +188,7 @@ .text-metric { font-weight: 600; + font-size: 14px; } .selected-metric-line { @@ -214,20 +198,15 @@ .deployment-line { stroke: $black; - stroke-width: 2; + stroke-width: 1; } .deploy-info-text { dominant-baseline: text-before-edge; } -.text-metric-bold { - font-weight: 600; -} - .prometheus-state { margin-top: 10px; - display: none; .state-button-section { margin-top: 10px; @@ -242,3 +221,59 @@ width: 38px; } } + +.prometheus-panel { + margin-top: 20px; +} + +.prometheus-svg-container { + position: relative; + height: 0; + width: 100%; + padding: 0; + padding-bottom: 100%; + + .text-metric-bold { + font-weight: 600; + } +} + +.prometheus-svg-container > svg { + position: absolute; + height: 100%; + width: 100%; + left: 0; + top: 0; + + text { + fill: $gl-text-color; + stroke-width: 0; + } + + .label-axis-text, + .text-metric-usage { + fill: $black; + font-weight: 500; + font-size: 14px; + } + + .legend-axis-text { + fill: $black; + } + + .tick > text { + font-size: 14px; + } + + @media (max-width: $screen-sm-max) { + .label-axis-text, + .text-metric-usage, + .legend-axis-text { + font-size: 8px; + } + + .tick > text { + font-size: 8px; + } + } +} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index e3ebcc8af6c..057d457b3a2 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -597,7 +597,38 @@ .issue-info-container { -webkit-flex: 1; flex: 1; + display: flex; padding-right: $gl-padding; + + .issue-main-info { + flex: 1 auto; + margin-right: 10px; + } + + .issuable-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + flex: 1 0 auto; + + .controls { + margin-bottom: 2px; + line-height: 20px; + padding: 0; + } + + .issue-updated-at { + line-height: 20px; + } + } + + @media(max-width: $screen-xs-max) { + .issuable-meta { + .controls li { + margin-right: 0; + } + } + } } .issue-check { @@ -609,6 +640,30 @@ vertical-align: text-top; } } + + .issuable-milestone, + .issuable-info, + .task-status, + .issuable-updated-at { + font-weight: normal; + color: $gl-text-color-secondary; + + a { + color: $gl-text-color; + + .fa { + color: $gl-text-color-secondary; + } + } + } + + @media(max-width: $screen-md-max) { + .task-status, + .issuable-due-date, + .project-ref-path { + display: none; + } + } } } diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index b158416b940..ee48f7a3626 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -279,5 +279,9 @@ .label-link { display: inline-block; - vertical-align: text-top; + vertical-align: top; + + .label { + vertical-align: inherit; + } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 562ecbc6986..ba530bf7f9b 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -377,6 +377,7 @@ a.deploy-project-label { } .breadcrumb.repo-breadcrumb { + flex: 1; padding: 0; background: transparent; border: none; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index ce1a13c6afa..dc88cf3e699 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -1,4 +1,5 @@ .tree-holder { + .nav-block { margin: 10px 0; @@ -15,6 +16,11 @@ .btn-group { margin-left: 10px; } + + .control { + float: left; + margin-left: 10px; + } } .tree-ref-holder { @@ -70,7 +76,8 @@ } .file-finder { - width: 50%; + max-width: 500px; + width: 100%; .file-finder-input { width: 95%; diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb index 2eac0cabf7a..ed13ead63f9 100644 --- a/app/controllers/abuse_reports_controller.rb +++ b/app/controllers/abuse_reports_controller.rb @@ -1,7 +1,9 @@ class AbuseReportsController < ApplicationController + before_action :set_user, only: [:new] + def new @abuse_report = AbuseReport.new - @abuse_report.user_id = params[:user_id] + @abuse_report.user_id = @user.id @ref_url = params.fetch(:ref_url, '') end @@ -27,4 +29,14 @@ class AbuseReportsController < ApplicationController user_id )) end + + def set_user + @user = User.find_by(id: params[:user_id]) + + if @user.nil? + redirect_to root_path, alert: "Cannot create the abuse report. The user has been deleted." + elsif @user.blocked? + redirect_to @user, alert: "Cannot create the abuse report. This user has been blocked." + end + end end diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 1a9904bbe57..f87db4d9e84 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -78,7 +78,7 @@ module CreatesCommit end def new_merge_request_path - new_namespace_project_merge_request_path( + namespace_project_new_merge_request_path( @project_to_commit_into.namespace, @project_to_commit_into, merge_request: { diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index dfc6baa34a4..ca483c105b6 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -267,10 +267,22 @@ class Projects::IssuesController < Projects::ApplicationController end def issue_params - params.require(:issue).permit( - :title, :assignee_id, :position, :description, :confidential, - :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [], assignee_ids: [] - ) + params.require(:issue).permit(*issue_params_attributes) + end + + def issue_params_attributes + %i[ + title + assignee_id + position + description + confidential + milestone_id + due_date + state_event + task_num + lock_version + ] + [{ label_ids: [], assignee_ids: [] }] end def authenticate_user! diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb new file mode 100644 index 00000000000..5de0f828010 --- /dev/null +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -0,0 +1,48 @@ +class Projects::MergeRequests::ApplicationController < Projects::ApplicationController + before_action :check_merge_requests_available! + before_action :merge_request + before_action :authorize_read_merge_request! + before_action :ensure_ref_fetched + + private + + def merge_request + @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) + end + + # Make sure merge requests created before 8.0 + # have head file in refs/merge-requests/ + def ensure_ref_fetched + @merge_request.ensure_ref_fetched + end + + def merge_request_params + params.require(:merge_request) + .permit(merge_request_params_attributes) + end + + def merge_request_params_attributes + [ + :assignee_id, + :description, + :force_remove_source_branch, + :lock_version, + :milestone_id, + :source_branch, + :source_project_id, + :state_event, + :target_branch, + :target_project_id, + :task_num, + :title, + + label_ids: [] + ] + end + + def set_pipeline_variables + @pipelines = @merge_request.all_pipelines + @pipeline = @merge_request.head_pipeline + @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0 + end +end diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb new file mode 100644 index 00000000000..a71f23e790d --- /dev/null +++ b/app/controllers/projects/merge_requests/conflicts_controller.rb @@ -0,0 +1,66 @@ +class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::ApplicationController + include IssuableActions + + before_action :authorize_can_resolve_conflicts! + + def show + respond_to do |format| + format.html do + labels + end + + format.json do + if @conflicts_list.can_be_resolved_in_ui? + render json: @conflicts_list + 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 conflict_for_path + return render_404 unless @conflicts_list.can_be_resolved_in_ui? + + file = @conflicts_list.file_for_path(params[:old_path], params[:new_path]) + + return render_404 unless file + + render json: file, full_content: true + end + + def resolve_conflicts + return render_404 unless @conflicts_list.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::Conflicts::ResolveService + .new(merge_request) + .execute(current_user, params) + + 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::ResolutionError => e + render status: :bad_request, json: { message: e.message } + end + end + + def authorize_can_resolve_conflicts! + @conflicts_list = ::MergeRequests::Conflicts::ListService.new(@merge_request) + + return render_404 unless @conflicts_list.can_be_resolved_by?(current_user) + end +end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb new file mode 100644 index 00000000000..da058da795e --- /dev/null +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -0,0 +1,128 @@ +class Projects::MergeRequests::CreationsController < Projects::MergeRequests::ApplicationController + include DiffForPath + include DiffHelper + + skip_before_action :merge_request + skip_before_action :ensure_ref_fetched + before_action :authorize_create_merge_request! + before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path] + before_action :build_merge_request, except: [:create] + + def new + define_new_vars + end + + def create + @target_branches ||= [] + @merge_request = ::MergeRequests::CreateService.new(project, current_user, merge_request_params).execute + + if @merge_request.valid? + redirect_to(merge_request_path(@merge_request)) + else + @source_project = @merge_request.source_project + @target_project = @merge_request.target_project + + define_new_vars + render action: "new" + end + end + + def pipelines + @pipelines = @merge_request.all_pipelines + + Gitlab::PollingInterval.set_header(response, interval: 10_000) + + render json: { + pipelines: PipelineSerializer + .new(project: @project, current_user: @current_user) + .represent(@pipelines) + } + end + + def diffs + @diffs = if @merge_request.can_be_created + @merge_request.diffs(diff_options) + else + [] + end + @diff_notes_disabled = true + + @environment = @merge_request.environments_for(current_user).last + + render json: { html: view_to_html_string('projects/merge_requests/creations/_diffs', diffs: @diffs, environment: @environment) } + end + + def diff_for_path + @diffs = @merge_request.diffs(diff_options) + @diff_notes_disabled = true + + render_diff_for_path(@diffs) + end + + def branch_from + # This is always source + @source_project = @merge_request.nil? ? @project : @merge_request.source_project + + if params[:ref].present? + @ref = params[:ref] + @commit = @repository.commit("refs/heads/#{@ref}") + end + + render layout: false + end + + def branch_to + @target_project = selected_target_project + + if params[:ref].present? + @ref = params[:ref] + @commit = @target_project.commit("refs/heads/#{@ref}") + end + + render layout: false + end + + def update_branches + @target_project = selected_target_project + @target_branches = @target_project.repository.branch_names + + render layout: false + end + + private + + def build_merge_request + params[:merge_request] ||= ActionController::Parameters.new(source_project: @project) + @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute + end + + def define_new_vars + @noteable = @merge_request + + @target_branches = if @merge_request.target_project + @merge_request.target_project.repository.branch_names + else + [] + end + + @target_project = @merge_request.target_project + @source_project = @merge_request.source_project + @commits = @merge_request.compare_commits.reverse + @commit = @merge_request.diff_head_commit + + @note_counts = Note.where(commit_id: @commits.map(&:id)) + .group(:commit_id).count + + @labels = LabelsFinder.new(current_user, project_id: @project.id).execute + + set_pipeline_variables + end + + def selected_target_project + if @project.id.to_s == params[:target_project_id] || @project.forked_project_link.nil? + @project + else + @project.forked_project_link.forked_from_project + end + end +end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb new file mode 100644 index 00000000000..330b7df4541 --- /dev/null +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -0,0 +1,66 @@ +class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController + include DiffForPath + include DiffHelper + include RendersNotes + + before_action :apply_diff_view_cookie! + before_action :define_diff_vars + before_action :define_diff_comment_vars + + def show + @environment = @merge_request.environments_for(current_user).last + + render json: { html: view_to_html_string("projects/merge_requests/diffs/_diffs") } + end + + def diff_for_path + render_diff_for_path(@diffs) + end + + private + + def define_diff_vars + @merge_request_diff = + if params[:diff_id] + @merge_request.merge_request_diffs.viewable.find(params[:diff_id]) + else + @merge_request.merge_request_diff + end + + @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff + @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id } + + if params[:start_sha].present? + @start_sha = params[:start_sha] + @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha } + + unless @start_version + @start_sha = @merge_request_diff.head_commit_sha + @start_version = @merge_request_diff + end + end + + @compare = + if @start_sha + @merge_request_diff.compare_with(@start_sha) + else + @merge_request_diff + end + + @diffs = @compare.diffs(diff_options) + end + + def define_diff_comment_vars + @new_diff_note_attrs = { + noteable_type: 'MergeRequest', + noteable_id: @merge_request.id + } + + @diff_notes_disabled = false + + @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? + + @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs) + @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) + end +end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 879ff6d393e..04f8e95aa09 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -1,38 +1,17 @@ -class Projects::MergeRequestsController < Projects::ApplicationController +class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationController include ToggleSubscriptionAction - include DiffForPath - include DiffHelper include IssuableActions include RendersNotes include ToggleAwardEmoji include IssuableCollections - before_action :check_merge_requests_available! - before_action :merge_request, only: [ - :edit, :update, :show, :diffs, :commits, :conflicts, :conflict_for_path, :pipelines, :merge, - :pipeline_status, :ci_environments_status, :toggle_subscription, :cancel_merge_when_pipeline_succeeds, :remove_wip, :resolve_conflicts, :assign_related_issues, :commit_change_content - ] - before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines] - before_action :define_show_vars, only: [:diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines] - before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines] - before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines] - before_action :check_if_can_be_merged, only: :show - before_action :apply_diff_view_cookie!, only: [:new_diffs] - before_action :build_merge_request, only: [:new, :new_diffs] - - # Allow read any merge_request - before_action :authorize_read_merge_request! - - # Allow write(create) merge_request - before_action :authorize_create_merge_request!, only: [:new, :create] - - # Allow modify merge_request + skip_before_action :merge_request, only: [:index, :bulk_update] + skip_before_action :ensure_ref_fetched, only: [:index, :bulk_update] + before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort] before_action :authenticate_user!, only: [:assign_related_issues] - before_action :authorize_can_resolve_conflicts!, only: [:conflicts, :conflict_for_path, :resolve_conflicts] - def index @collection_type = "MergeRequest" @merge_requests = merge_requests_collection @@ -72,10 +51,30 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def show + validates_merge_request + ensure_ref_fetched + close_merge_request_without_source_project + check_if_can_be_merged + respond_to do |format| format.html do - define_discussion_vars - define_show_vars + # Build a note object for comment form + @note = @project.notes.new(noteable: @merge_request) + + @discussions = @merge_request.discussions + @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) + + @noteable = @merge_request + @commits_count = @merge_request.commits_count + + if @merge_request.locked_long_ago? + @merge_request.unlock_mr + @merge_request.close + end + + labels + + set_pipeline_variables end format.json do @@ -98,198 +97,40 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - def diffs - apply_diff_view_cookie! - - respond_to do |format| - format.html { define_discussion_vars } - format.json do - define_diff_vars - define_diff_comment_vars - - @environment = @merge_request.environments_for(current_user).last - - render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") } - end - end - end - - # With an ID param, loads the MR at that ID. Otherwise, accepts the same params as #new - # and uses that (unsaved) MR. - # - def diff_for_path - if params[:id] - merge_request - define_diff_vars - define_diff_comment_vars - else - build_merge_request - @compare = @merge_request - @diffs = @compare.diffs(diff_options) - @diff_notes_disabled = true - end - - render_diff_for_path(@diffs) - end - def commits - respond_to do |format| - format.html do - define_discussion_vars - - render 'show' - end - format.json do - # Get commits from repository - # or from cache if already merged - @commits = @merge_request.commits - @note_counts = Note.where(commit_id: @commits.map(&:id)) - .group(:commit_id).count - - render json: { html: view_to_html_string('projects/merge_requests/show/_commits') } - end - end - end - - def conflicts - respond_to do |format| - format.html { define_discussion_vars } - - format.json do - if @conflicts_list.can_be_resolved_in_ui? - render json: @conflicts_list - 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 conflict_for_path - return render_404 unless @conflicts_list.can_be_resolved_in_ui? - - file = @conflicts_list.file_for_path(params[:old_path], params[:new_path]) - - return render_404 unless file - - render json: file, full_content: true - end - - def resolve_conflicts - return render_404 unless @conflicts_list.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::Conflicts::ResolveService - .new(merge_request) - .execute(current_user, params) - - flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.' + # Get commits from repository + # or from cache if already merged + @commits = @merge_request.commits + @note_counts = Note.where(commit_id: @commits.map(&:id)) + .group(:commit_id).count - render json: { redirect_to: namespace_project_merge_request_url(@project.namespace, @project, @merge_request, resolved_conflicts: true) } - rescue Gitlab::Conflict::ResolutionError => e - render status: :bad_request, json: { message: e.message } - end + render json: { html: view_to_html_string('projects/merge_requests/_commits') } end def pipelines @pipelines = @merge_request.all_pipelines - respond_to do |format| - format.html do - define_discussion_vars - - render 'show' - end - - format.json do - Gitlab::PollingInterval.set_header(response, interval: 10_000) + Gitlab::PollingInterval.set_header(response, interval: 10_000) - render json: PipelineSerializer - .new(project: @project, current_user: @current_user) - .represent(@pipelines) - end - end - end - - def new - respond_to do |format| - format.html { define_new_vars } - format.json do - define_pipelines_vars - - Gitlab::PollingInterval.set_header(response, interval: 10_000) - - render json: { - pipelines: PipelineSerializer - .new(project: @project, current_user: @current_user) - .represent(@pipelines) - } - end - end - end - - def new_diffs - respond_to do |format| - format.html do - define_new_vars - @show_changes_tab = true - render "new" - end - format.json do - @diffs = if @merge_request.can_be_created - @merge_request.diffs(diff_options) - else - [] - end - @diff_notes_disabled = true - - @environment = @merge_request.environments_for(current_user).last - - render json: { html: view_to_html_string('projects/merge_requests/_new_diffs', diffs: @diffs, environment: @environment) } - end - end - end - - def create - @target_branches ||= [] - @merge_request = MergeRequests::CreateService.new(project, current_user, merge_request_params).execute - - if @merge_request.valid? - redirect_to(merge_request_path(@merge_request)) - else - @source_project = @merge_request.source_project - @target_project = @merge_request.target_project - render action: "new" - end + render json: PipelineSerializer + .new(project: @project, current_user: @current_user) + .represent(@pipelines) end def edit - @source_project = @merge_request.source_project - @target_project = @merge_request.target_project - @target_branches = @merge_request.target_project.repository.branch_names + define_edit_vars end def update - @merge_request = MergeRequests::UpdateService.new(project, current_user, merge_request_params).execute(@merge_request) + @merge_request = ::MergeRequests::UpdateService.new(project, current_user, merge_request_params).execute(@merge_request) respond_to do |format| format.html do if @merge_request.valid? redirect_to([@merge_request.target_project.namespace.becomes(Namespace), @merge_request.target_project, @merge_request]) else + define_edit_vars + render :edit end end @@ -299,11 +140,13 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end rescue ActiveRecord::StaleObjectError + define_edit_vars if request.format.html? + render_conflict_response end def remove_wip - @merge_request = MergeRequests::UpdateService + @merge_request = ::MergeRequests::UpdateService .new(project, current_user, wip_event: 'unwip') .execute(@merge_request) @@ -319,7 +162,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController return access_denied! end - MergeRequests::MergeWhenPipelineSucceedsService + ::MergeRequests::MergeWhenPipelineSucceedsService .new(@project, current_user) .cancel(@merge_request) @@ -338,53 +181,19 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - def branch_from - # This is always source - @source_project = @merge_request.nil? ? @project : @merge_request.source_project - - if params[:ref].present? - @ref = params[:ref] - @commit = @repository.commit("refs/heads/#{@ref}") - end - - render layout: false - end - - def branch_to - @target_project = selected_target_project - - if params[:ref].present? - @ref = params[:ref] - @commit = @target_project.commit("refs/heads/#{@ref}") - end - - render layout: false - end - - def update_branches - @target_project = selected_target_project - @target_branches = @target_project.repository.branch_names - - render layout: false - end - def assign_related_issues - result = MergeRequests::AssignIssuesService.new(project, current_user, merge_request: @merge_request).execute + result = ::MergeRequests::AssignIssuesService.new(project, current_user, merge_request: @merge_request).execute - respond_to do |format| - format.html do - case result[:count] - when 0 - flash[:error] = "Failed to assign you issues related to the merge request" - when 1 - flash[:notice] = "1 issue has been assigned to you" - else - flash[:notice] = "#{result[:count]} issues have been assigned to you" - end - - redirect_to(merge_request_path(@merge_request)) - end + case result[:count] + when 0 + flash[:error] = "Failed to assign you issues related to the merge request" + when 1 + flash[:notice] = "1 issue has been assigned to you" + else + flash[:notice] = "#{result[:count]} issues have been assigned to you" end + + redirect_to(merge_request_path(@merge_request)) end def pipeline_status @@ -432,17 +241,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController protected - def selected_target_project - if @project.id.to_s == params[:target_project_id] || @project.forked_project_link.nil? - @project - else - @project.forked_project_link.forked_from_project - end - end - - def merge_request - @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) - end alias_method :subscribable_resource, :merge_request alias_method :issuable, :merge_request alias_method :awardable, :merge_request @@ -455,12 +253,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController return render_404 unless can?(current_user, :admin_merge_request, @merge_request) end - def authorize_can_resolve_conflicts! - @conflicts_list = MergeRequests::Conflicts::ListService.new(@merge_request) - - return render_404 unless @conflicts_list.can_be_resolved_by?(current_user) - end - def validates_merge_request # Show git not found page # if there is no saved commits between source & target branch @@ -470,133 +262,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end - def define_show_vars - @noteable = @merge_request - @commits_count = @merge_request.commits_count - - if @merge_request.locked_long_ago? - @merge_request.unlock_mr - @merge_request.close - end - - labels - define_pipelines_vars - end - - # Discussion tab data is rendered on html responses of actions - # :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: @merge_request) - - @discussions = @merge_request.discussions - @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) - end - - def define_diff_vars - @merge_request_diff = - if params[:diff_id] - @merge_request.merge_request_diffs.viewable.find(params[:diff_id]) - else - @merge_request.merge_request_diff - end - - @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff - @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id } - - if params[:start_sha].present? - @start_sha = params[:start_sha] - @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha } - - unless @start_version - @start_sha = @merge_request_diff.head_commit_sha - @start_version = @merge_request_diff - end - end - - @compare = - if @start_sha - @merge_request_diff.compare_with(@start_sha) - else - @merge_request_diff - end - - @diffs = @compare.diffs(diff_options) - end - - def define_diff_comment_vars - @new_diff_note_attrs = { - noteable_type: 'MergeRequest', - noteable_id: @merge_request.id - } - - @diff_notes_disabled = false - - @use_legacy_diff_notes = !@merge_request.has_complete_diff_refs? - - @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs) - @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes)) - end - - def define_pipelines_vars - @pipelines = @merge_request.all_pipelines - @pipeline = @merge_request.head_pipeline - @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0 - end - - def define_new_vars - @noteable = @merge_request - - @target_branches = if @merge_request.target_project - @merge_request.target_project.repository.branch_names - else - [] - end - - @target_project = merge_request.target_project - @source_project = merge_request.source_project - @commits = @merge_request.compare_commits.reverse - @commit = @merge_request.diff_head_commit - - @note_counts = Note.where(commit_id: @commits.map(&:id)) - .group(:commit_id).count - - @labels = LabelsFinder.new(current_user, project_id: @project.id).execute - - @show_changes_tab = params[:show_changes].present? - - define_pipelines_vars - end - def invalid_mr # Render special view for MR with removed target branch render 'invalid' end - def merge_request_params - params.require(:merge_request) - .permit(merge_request_params_attributes) - end - - def merge_request_params_attributes - [ - :assignee_id, - :description, - :force_remove_source_branch, - :lock_version, - :milestone_id, - :source_branch, - :source_project_id, - :state_event, - :target_branch, - :target_project_id, - :task_num, - :title, - - label_ids: [] - ] - end - def merge_params params.permit(merge_params_attributes) end @@ -605,22 +275,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController [:should_remove_source_branch, :commit_message] end - # Make sure merge requests created before 8.0 - # have head file in refs/merge-requests/ - def ensure_ref_fetched - @merge_request.ensure_ref_fetched - end - def merge_when_pipeline_succeeds_active? params[:merge_when_pipeline_succeeds].present? && @merge_request.head_pipeline && @merge_request.head_pipeline.active? end - def build_merge_request - params[:merge_request] ||= ActionController::Parameters.new(source_project: @project) - @merge_request = MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute - end - def close_merge_request_without_source_project if !@merge_request.source_project && @merge_request.open? @merge_request.close @@ -648,7 +307,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController return :failed unless @merge_request.head_pipeline if @merge_request.head_pipeline.active? - MergeRequests::MergeWhenPipelineSucceedsService + ::MergeRequests::MergeWhenPipelineSucceedsService .new(@project, current_user, merge_params) .execute(@merge_request) @@ -672,4 +331,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController def serializer MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) end + + def define_edit_vars + @source_project = @merge_request.source_project + @target_project = @merge_request.target_project + @target_branches = @merge_request.target_project.repository.branch_names + end end diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index ef4f083b98f..60db179277b 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -1,6 +1,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :authorize_read_pipeline_schedule! - before_action :authorize_create_pipeline_schedule!, only: [:new, :create, :edit, :take_ownership, :update] + before_action :authorize_create_pipeline_schedule!, only: [:new, :create] + before_action :authorize_update_pipeline_schedule!, only: [:edit, :take_ownership, :update] before_action :authorize_admin_pipeline_schedule!, only: [:destroy] before_action :schedule, only: [:edit, :update, :destroy, :take_ownership] diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 5480814874b..450895cdf3a 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -97,7 +97,7 @@ class ProjectsController < Projects::ApplicationController end if @project.pending_delete? - flash[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name } + flash.now[:alert] = _("Project '%{project_name}' queued for deletion.") % { project_name: @project.name } end respond_to do |format| diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index c358f23f541..3fe37c75381 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -83,6 +83,8 @@ class TodosFinder if project? @project = Project.find(params[:project_id]) + @project = nil if @project.pending_delete? + unless Ability.allowed?(current_user, :read_project, @project) @project = nil end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index dc7ff78f3df..7be8e3b96cf 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -131,10 +131,7 @@ module ApplicationHelper end def body_data_page - path = controller.controller_path.split('/') - namespace = path.first if path.second - - [namespace, controller.controller_name, controller.action_name].compact.join(':') + [*controller.controller_path.split('/'), controller.action_name].compact.join(':') end # shortcut for gitlab config diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 3efa7c36057..ee36617ba9a 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -284,7 +284,7 @@ module BlobHelper merge_project = can?(current_user, :create_merge_request, project) ? project : (current_user && current_user.fork_of(project)) if merge_project - options << link_to("create a merge request", new_namespace_project_merge_request_path(project.namespace, project)) + options << link_to("create a merge request", namespace_project_new_merge_request_path(project.namespace, project)) end options diff --git a/app/helpers/compare_helper.rb b/app/helpers/compare_helper.rb index 2aa0449c46e..424ded2b69d 100644 --- a/app/helpers/compare_helper.rb +++ b/app/helpers/compare_helper.rb @@ -9,7 +9,7 @@ module CompareHelper end def create_mr_path(from = params[:from], to = params[:to], project = @project) - new_namespace_project_merge_request_path( + namespace_project_new_merge_request_path( project.namespace, project, merge_request: { diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 39d30631646..54d6f86fa11 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -1,7 +1,7 @@ module MergeRequestsHelper def new_mr_path_from_push_event(event) target_project = event.project.default_merge_request_target - new_namespace_project_merge_request_path( + namespace_project_new_merge_request_path( event.project.namespace, event.project, new_mr_from_push_event(event, target_project) @@ -48,7 +48,7 @@ module MergeRequestsHelper end def mr_change_branches_path(merge_request) - new_namespace_project_merge_request_path( + namespace_project_new_merge_request_path( @project.namespace, @project, merge_request: { source_project_id: merge_request.source_project_id, diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 833d3c36b28..e589ed4e56d 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -1,11 +1,7 @@ module NavHelper def page_gutter_class if current_path?('merge_requests#show') || - current_path?('merge_requests#diffs') || - current_path?('merge_requests#commits') || - current_path?('merge_requests#builds') || - current_path?('merge_requests#conflicts') || - current_path?('merge_requests#pipelines') || + current_path?('projects/merge_requests/conflicts#show') || current_path?('issues#show') || current_path?('milestones#show') if cookies[:collapsed_gutter] == 'true' diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index 8e0a1e2ecdf..b24039fb349 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -73,6 +73,7 @@ module SubmoduleHelper end def relative_self_links(url, commit) + url.rstrip! # Map relative links to a namespace and project # For example: # ../bar.git -> same namespace, repo bar diff --git a/app/models/ability.rb b/app/models/ability.rb index f3692a5a067..0b6bcbde5d9 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -1,35 +1,20 @@ +require_dependency 'declarative_policy' + class Ability class << self # Given a list of users and a project this method returns the users that can # read the given project. def users_that_can_read_project(users, project) - if project.public? - users - else - users.select do |user| - if user.admin? - true - elsif project.internal? && !user.external? - true - elsif project.owner == user - true - elsif project.team.members.include?(user) - true - else - false - end - end + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :read_project, project) } end end # Given a list of users and a snippet this method returns the users that can # read the given snippet. def users_that_can_read_personal_snippet(users, snippet) - case snippet.visibility_level - when Snippet::INTERNAL, Snippet::PUBLIC - users - when Snippet::PRIVATE - users.include?(snippet.author) ? [snippet.author] : [] + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :read_personal_snippet, snippet) } end end @@ -38,42 +23,35 @@ class Ability # issues - The issues to reduce down to those readable by the user. # user - The User for which to check the issues def issues_readable_by_user(issues, user = nil) - return issues if user && user.admin? - - issues.select { |issue| issue.visible_to_user?(user) } + DeclarativePolicy.user_scope do + issues.select { |issue| issue.visible_to_user?(user) } + end end - # TODO: make this private and use the actual abilities stuff for this def can_edit_note?(user, note) - return false if !note.editable? || !user.present? - return true if note.author == user || user.admin? - - if note.project - max_access_level = note.project.team.max_member_access(user.id) - max_access_level >= Gitlab::Access::MASTER - else - false - end + allowed?(user, :edit_note, note) end - def allowed?(user, action, subject = :global) - allowed(user, subject).include?(action) - end + def allowed?(user, action, subject = :global, opts = {}) + if subject.is_a?(Hash) + opts, subject = subject, :global + end - def allowed(user, subject = :global) - return BasePolicy::RuleSet.none if subject.nil? - return uncached_allowed(user, subject) unless RequestStore.active? + policy = policy_for(user, subject) - user_key = user ? user.id : 'anonymous' - subject_key = subject == :global ? 'global' : "#{subject.class.name}/#{subject.id}" - key = "/ability/#{user_key}/#{subject_key}" - RequestStore[key] ||= uncached_allowed(user, subject).freeze + case opts[:scope] + when :user + DeclarativePolicy.user_scope { policy.can?(action) } + when :subject + DeclarativePolicy.subject_scope { policy.can?(action) } + else + policy.can?(action) + end end - private - - def uncached_allowed(user, subject) - BasePolicy.class_for(subject).abilities(user, subject) + def policy_for(user, subject = :global) + cache = RequestStore.active? ? RequestStore : {} + DeclarativePolicy.policy_for(user, subject, cache: cache) end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 3a514718ca8..57bf5a8a4c5 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -140,6 +140,7 @@ module Ci where(id: max_id) end end + scope :internal, -> { where(source: internal_sources) } def self.latest_status(ref = nil) latest(ref).status @@ -163,6 +164,10 @@ module Ci where.not(duration: nil).sum(:duration) end + def self.internal_sources + sources.reject { |source| source == "external" }.values + end + def stages_count statuses.select(:stage).distinct.count end diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index f235260208f..96d6e120998 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -1,27 +1,12 @@ module Ci class Variable < ActiveRecord::Base extend Ci::Model + include HasVariable belongs_to :project - validates :key, - presence: true, - uniqueness: { scope: :project_id }, - length: { maximum: 255 }, - format: { with: /\A[a-zA-Z0-9_]+\z/, - message: "can contain only letters, digits and '_'." } + validates :key, uniqueness: { scope: :project_id } - scope :order_key_asc, -> { reorder(key: :asc) } scope :unprotected, -> { where(protected: false) } - - attr_encrypted :value, - mode: :per_attribute_iv_and_salt, - insecure_mode: true, - key: Gitlab::Application.secrets.db_key_base, - algorithm: 'aes-256-cbc' - - def to_runner_variable - { key: key, value: value, public: false } - end end end diff --git a/app/models/concerns/feature_gate.rb b/app/models/concerns/feature_gate.rb new file mode 100644 index 00000000000..5db64fe82c4 --- /dev/null +++ b/app/models/concerns/feature_gate.rb @@ -0,0 +1,7 @@ +module FeatureGate + def flipper_id + return nil if new_record? + + "#{self.class.name}:#{id}" + end +end diff --git a/app/models/concerns/has_variable.rb b/app/models/concerns/has_variable.rb new file mode 100644 index 00000000000..9585b5583dc --- /dev/null +++ b/app/models/concerns/has_variable.rb @@ -0,0 +1,23 @@ +module HasVariable + extend ActiveSupport::Concern + + included do + validates :key, + presence: true, + length: { maximum: 255 }, + format: { with: /\A[a-zA-Z0-9_]+\z/, + message: "can contain only letters, digits and '_'." } + + scope :order_key_asc, -> { reorder(key: :asc) } + + attr_encrypted :value, + mode: :per_attribute_iv_and_salt, + insecure_mode: true, + key: Gitlab::Application.secrets.db_key_base, + algorithm: 'aes-256-cbc' + + def to_runner_variable + { key: key, value: value, public: false } + end + end +end diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb new file mode 100644 index 00000000000..c28974a3cdf --- /dev/null +++ b/app/models/concerns/sha_attribute.rb @@ -0,0 +1,18 @@ +module ShaAttribute + extend ActiveSupport::Concern + + module ClassMethods + def sha_attribute(name) + column = columns.find { |c| c.name == name.to_s } + + # In case the table doesn't exist we won't be able to find the column, + # thus we will only check the type if the column is present. + if column && column.type != :binary + raise ArgumentError, + "sha_attribute #{name.inspect} is invalid since the column type is not :binary" + end + + attribute(name, Gitlab::Database::ShaAttribute.new) + end + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 0b93460d473..a6fdb30f84c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -222,6 +222,12 @@ class Group < Namespace User.where(id: members_with_parents.select(:user_id)) end + def users_with_descendants + members_with_descendants = GroupMember.non_request.where(source_id: descendants.pluck(:id).push(id)) + + User.where(id: members_with_descendants.select(:user_id)) + end + def max_member_access_for_user(user) return GroupMember::OWNER if user.admin? diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 583d4fb5244..672eab94c07 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -1,5 +1,5 @@ class Namespace < ActiveRecord::Base - acts_as_paranoid + acts_as_paranoid without_default_scope: true include CacheMarkdownField include Sortable @@ -219,6 +219,12 @@ class Namespace < ActiveRecord::Base parent.present? end + def soft_delete_without_removing_associations + # We can't use paranoia's `#destroy` since this will hard-delete projects. + # Project uses `pending_delete` instead of the acts_as_paranoia gem. + self.deleted_at = Time.now + end + private def repository_storage_paths diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index b0df7aeb323..81844b1e2ca 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -19,7 +19,7 @@ class NotificationSetting < ActiveRecord::Base # pending delete). # scope :for_projects, -> do - includes(:project).references(:projects).where(source_type: 'Project').where.not(projects: { id: nil }) + includes(:project).references(:projects).where(source_type: 'Project').where.not(projects: { id: nil, pending_delete: true }) end EMAIL_EVENTS = [ diff --git a/app/models/project.rb b/app/models/project.rb index 0601f7fb977..507dffde18b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -228,9 +228,8 @@ class Project < ActiveRecord::Base has_many :uploads, as: :model, dependent: :destroy # Scopes - default_scope { where(pending_delete: false) } - - scope :with_deleted, -> { unscope(where: :pending_delete) } + scope :pending_delete, -> { where(pending_delete: true) } + scope :without_deleted, -> { where(pending_delete: false) } scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) } scope :sorted_by_stars, -> { reorder('projects.star_count DESC') } @@ -358,7 +357,16 @@ class Project < ActiveRecord::Base after_transition started: :finished do |project, _| project.reset_cache_and_import_attrs - project.perform_housekeeping + + if Gitlab::ImportSources.importer_names.include?(project.import_type) && project.repo_exists? + project.run_after_commit do + begin + Projects::HousekeepingService.new(project).execute + rescue Projects::HousekeepingService::LeaseTaken => e + Rails.logger.info("Could not perform housekeeping for project #{project.path_with_namespace} (#{project.id}): #{e}") + end + end + end end end @@ -516,22 +524,6 @@ class Project < ActiveRecord::Base ProjectCacheWorker.perform_async(self.id) end - remove_import_data - end - - def perform_housekeeping - return unless repo_exists? - - run_after_commit do - begin - Projects::HousekeepingService.new(self).execute - rescue Projects::HousekeepingService::LeaseTaken => e - Rails.logger.info("Could not perform housekeeping for project #{self.path_with_namespace} (#{self.id}): #{e}") - end - end - end - - def remove_import_data import_data&.destroy end @@ -1101,6 +1093,10 @@ class Project < ActiveRecord::Base end end + def ensure_repository + create_repository unless repository_exists? + end + def repository_exists? !!repository.exists? end @@ -1463,7 +1459,7 @@ class Project < ActiveRecord::Base def pending_delete_twin return false unless path - Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace) + Project.pending_delete.find_by_full_path(path_with_namespace) end ## diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 48edd0738ee..c8fabb16dc1 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -51,8 +51,11 @@ class ProjectFeature < ActiveRecord::Base default_value_for :repository_access_level, value: ENABLED, allows_nil: false def feature_available?(feature, user) - access_level = public_send(ProjectFeature.access_level_attribute(feature)) - get_permission(user, access_level) + get_permission(user, access_level(feature)) + end + + def access_level(feature) + public_send(ProjectFeature.access_level_attribute(feature)) end def builds_enabled? diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index f38fbda7839..f26ee57510c 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -149,6 +149,10 @@ class ProjectWiki wiki end + def ensure_repository + create_repo! unless repository_exists? + end + def hook_attrs { web_url: web_url, diff --git a/app/models/repository.rb b/app/models/repository.rb index 6b2c61b1782..10b429c707e 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -605,22 +605,6 @@ class Repository end end - # Returns url for submodule - # - # Ex. - # @repository.submodule_url_for('master', 'rack') - # # => git@localhost:rack.git - # - def submodule_url_for(ref, path) - if submodules(ref).any? - submodule = submodules(ref)[path] - - if submodule - submodule['url'] - end - end - end - def last_commit_for_path(sha, path) sha = last_commit_id_for_path(sha, path) commit(sha) diff --git a/app/models/user.rb b/app/models/user.rb index 6dd1b1415d6..0febae84873 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,6 +11,7 @@ class User < ActiveRecord::Base include CaseSensitivity include TokenAuthenticatable include IgnorableColumn + include FeatureGate DEFAULT_NOTIFICATION_LEVEL = :participating @@ -299,11 +300,20 @@ class User < ActiveRecord::Base table = arel_table pattern = "%#{query}%" + order = <<~SQL + CASE + WHEN users.name = %{query} THEN 0 + WHEN users.username = %{query} THEN 1 + WHEN users.email = %{query} THEN 2 + ELSE 3 + END + SQL + where( table[:name].matches(pattern) .or(table[:email].matches(pattern)) .or(table[:username].matches(pattern)) - ) + ).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, id: :desc) end # searches user by given pattern diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 623424c63e0..191c2e78a08 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -1,127 +1,13 @@ -class BasePolicy - class RuleSet - attr_reader :can_set, :cannot_set - def initialize(can_set, cannot_set) - @can_set = can_set - @cannot_set = cannot_set - end +require_dependency 'declarative_policy' - delegate :size, to: :to_set +class BasePolicy < DeclarativePolicy::Base + desc "User is an instance admin" + with_options scope: :user, score: 0 + condition(:admin) { @user&.admin? } - def self.empty - new(Set.new, Set.new) - end + with_options scope: :user, score: 0 + condition(:external_user) { @user.nil? || @user.external? } - def self.none - empty.freeze - end - - def can?(ability) - @can_set.include?(ability) && !@cannot_set.include?(ability) - end - - def include?(ability) - can?(ability) - end - - def to_set - @can_set - @cannot_set - end - - def merge(other) - @can_set.merge(other.can_set) - @cannot_set.merge(other.cannot_set) - end - - def can!(*abilities) - @can_set.merge(abilities) - end - - def cannot!(*abilities) - @cannot_set.merge(abilities) - end - - def freeze - @can_set.freeze - @cannot_set.freeze - super - end - end - - def self.abilities(user, subject) - new(user, subject).abilities - end - - def self.class_for(subject) - return GlobalPolicy if subject == :global - raise ArgumentError, 'no policy for nil' if subject.nil? - - if subject.class.try(:presenter?) - subject = subject.subject - end - - subject.class.ancestors.each do |klass| - next unless klass.name - - begin - policy_class = "#{klass.name}Policy".constantize - - # NOTE: the < operator here tests whether policy_class - # inherits from BasePolicy - return policy_class if policy_class < BasePolicy - rescue NameError - nil - end - end - - raise "no policy for #{subject.class.name}" - end - - attr_reader :user, :subject - def initialize(user, subject) - @user = user - @subject = subject - end - - def abilities - return RuleSet.none if @user && @user.blocked? - return anonymous_abilities if @user.nil? - collect_rules { rules } - end - - def anonymous_abilities - collect_rules { anonymous_rules } - end - - def anonymous_rules - rules - end - - def rules - raise NotImplementedError - end - - def delegate!(new_subject) - @rule_set.merge(Ability.allowed(@user, new_subject)) - end - - def can?(rule) - @rule_set.can?(rule) - end - - def can!(*rules) - @rule_set.can!(*rules) - end - - def cannot!(*rules) - @rule_set.cannot!(*rules) - end - - private - - def collect_rules(&b) - @rule_set = RuleSet.empty - yield - @rule_set - end + with_options scope: :user, score: 0 + condition(:can_create_group) { @user&.can_create_group } end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 2d7405dc240..a886efc1360 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -1,29 +1,13 @@ module Ci class BuildPolicy < CommitStatusPolicy - alias_method :build, :subject - - def rules - super - - # If we can't read build we should also not have that - # ability when looking at this in context of commit_status - %w[read create update admin].each do |rule| - cannot! :"#{rule}_commit_status" unless can? :"#{rule}_build" - end - - if can?(:update_build) && protected_action? - cannot! :update_build - end - end - - private - - def protected_action? - return false unless build.action? + condition(:protected_action) do + next false unless @subject.action? !::Gitlab::UserAccess - .new(user, project: build.project) - .can_merge_to_branch?(build.ref) + .new(@user, project: @subject.project) + .can_merge_to_branch?(@subject.ref) end + + rule { protected_action }.prevent :update_build end end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 10aa2d3e72a..a2dde95dbc8 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -1,7 +1,5 @@ module Ci class PipelinePolicy < BasePolicy - def rules - delegate! @subject.project - end + delegate { @subject.project } end end diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb index 416d93ffe63..7dff8470e23 100644 --- a/app/policies/ci/runner_policy.rb +++ b/app/policies/ci/runner_policy.rb @@ -1,13 +1,16 @@ module Ci class RunnerPolicy < BasePolicy - def rules - return unless @user + with_options scope: :subject, score: 0 + condition(:shared) { @subject.is_shared? } - can! :assign_runner if @user.admin? + with_options scope: :subject, score: 0 + condition(:locked, scope: :subject) { @subject.locked? } - return if @subject.is_shared? || @subject.locked? + condition(:authorized_runner) { @user.ci_authorized_runners.include?(@subject) } - can! :assign_runner if @user.ci_authorized_runners.include?(@subject) - end + rule { anonymous }.prevent_all + rule { admin | authorized_runner }.enable :assign_runner + rule { ~admin & shared }.prevent :assign_runner + rule { ~admin & locked }.prevent :assign_runner end end diff --git a/app/policies/ci/trigger_policy.rb b/app/policies/ci/trigger_policy.rb index c90c9ac0583..5592ac30812 100644 --- a/app/policies/ci/trigger_policy.rb +++ b/app/policies/ci/trigger_policy.rb @@ -1,13 +1,16 @@ module Ci class TriggerPolicy < BasePolicy - def rules - delegate! @subject.project - - if can?(:admin_build) - can! :admin_trigger if @subject.owner.blank? || - @subject.owner == @user - can! :manage_trigger - end - end + delegate { @subject.project } + + with_options scope: :subject, score: 0 + condition(:legacy) { @subject.legacy? } + + with_score 0 + condition(:is_owner) { @user && @subject.owner_id == @user.id } + + rule { ~can?(:admin_build) }.prevent :admin_trigger + rule { legacy | is_owner }.enable :admin_trigger + + rule { can?(:admin_build) }.enable :manage_trigger end end diff --git a/app/policies/commit_status_policy.rb b/app/policies/commit_status_policy.rb index 593df738328..24b2a4cc7fd 100644 --- a/app/policies/commit_status_policy.rb +++ b/app/policies/commit_status_policy.rb @@ -1,5 +1,7 @@ class CommitStatusPolicy < BasePolicy - def rules - delegate! @subject.project + delegate { @subject.project } + + %w[read create update admin].each do |action| + rule { ~can?(:"#{action}_commit_status") }.prevent :"#{action}_build" end end diff --git a/app/policies/deploy_key_policy.rb b/app/policies/deploy_key_policy.rb index ebab213e6be..62a22a59be6 100644 --- a/app/policies/deploy_key_policy.rb +++ b/app/policies/deploy_key_policy.rb @@ -1,11 +1,11 @@ class DeployKeyPolicy < BasePolicy - def rules - return unless @user + with_options scope: :subject, score: 0 + condition(:private_deploy_key) { @subject.private? } - can! :update_deploy_key if @user.admin? + condition(:has_deploy_key) { @user.project_deploy_keys.exists?(id: @subject.id) } - if @subject.private? && @user.project_deploy_keys.exists?(id: @subject.id) - can! :update_deploy_key - end - end + rule { anonymous }.prevent_all + + rule { admin }.enable :update_deploy_key + rule { private_deploy_key & has_deploy_key }.enable :update_deploy_key end diff --git a/app/policies/deployment_policy.rb b/app/policies/deployment_policy.rb index 163d070ff90..62b63b9f87b 100644 --- a/app/policies/deployment_policy.rb +++ b/app/policies/deployment_policy.rb @@ -1,5 +1,3 @@ class DeploymentPolicy < BasePolicy - def rules - delegate! @subject.project - end + delegate { @subject.project } end diff --git a/app/policies/environment_policy.rb b/app/policies/environment_policy.rb index 2fa15e64562..375a5535359 100644 --- a/app/policies/environment_policy.rb +++ b/app/policies/environment_policy.rb @@ -1,17 +1,9 @@ class EnvironmentPolicy < BasePolicy - alias_method :environment, :subject + delegate { @subject.project } - def rules - delegate! environment.project - - if can?(:create_deployment) && environment.stop_action? - can! :stop_environment if can_play_stop_action? - end + condition(:stop_action_allowed) do + @subject.stop_action? && can?(:update_build, @subject.stop_action) end - private - - def can_play_stop_action? - Ability.allowed?(user, :update_build, environment.stop_action) - end + rule { can?(:create_deployment) & stop_action_allowed }.enable :stop_environment end diff --git a/app/policies/external_issue_policy.rb b/app/policies/external_issue_policy.rb index d9e28bd107a..e031b38078c 100644 --- a/app/policies/external_issue_policy.rb +++ b/app/policies/external_issue_policy.rb @@ -1,5 +1,3 @@ class ExternalIssuePolicy < BasePolicy - def rules - delegate! @subject.project - end + delegate { @subject.project } end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 2683aaad981..535faa922dd 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -1,16 +1,40 @@ class GlobalPolicy < BasePolicy - def rules - return unless @user + desc "User is blocked" + with_options scope: :user, score: 0 + condition(:blocked) { @user.blocked? } - can! :create_group if @user.can_create_group - can! :read_users_list + desc "User is an internal user" + with_options scope: :user, score: 0 + condition(:internal) { @user.internal? } - unless @user.blocked? || @user.internal? - can! :log_in unless @user.access_locked? - can! :access_api - can! :access_git - can! :receive_notifications - can! :use_quick_actions - end + desc "User's access has been locked" + with_options scope: :user, score: 0 + condition(:access_locked) { @user.access_locked? } + + rule { anonymous }.prevent_all + + rule { default }.policy do + enable :read_users_list + enable :log_in + enable :access_api + enable :access_git + enable :receive_notifications + enable :use_quick_actions + end + + rule { blocked | internal }.policy do + prevent :log_in + prevent :access_api + prevent :access_git + prevent :receive_notifications + prevent :use_quick_actions + end + + rule { can_create_group }.policy do + enable :create_group + end + + rule { access_locked }.policy do + prevent :log_in end end diff --git a/app/policies/group_label_policy.rb b/app/policies/group_label_policy.rb index 7b34aa182eb..e3dd3296699 100644 --- a/app/policies/group_label_policy.rb +++ b/app/policies/group_label_policy.rb @@ -1,5 +1,3 @@ class GroupLabelPolicy < BasePolicy - def rules - delegate! @subject.group - end + delegate { @subject.group } end diff --git a/app/policies/group_member_policy.rb b/app/policies/group_member_policy.rb index 5a3fe814b77..23dd0d7cd23 100644 --- a/app/policies/group_member_policy.rb +++ b/app/policies/group_member_policy.rb @@ -1,25 +1,22 @@ class GroupMemberPolicy < BasePolicy - def rules - return unless @user + delegate :group - target_user = @subject.user - group = @subject.group + with_scope :subject + condition(:last_owner) { @subject.group.last_owner?(@subject.user) } - return if group.last_owner?(target_user) + desc "Membership is users' own" + with_score 0 + condition(:is_target_user) { @user && @subject.user_id == @user.id } - can_manage = Ability.allowed?(@user, :admin_group_member, group) + rule { anonymous }.prevent_all + rule { last_owner }.prevent_all - if can_manage - can! :update_group_member - can! :destroy_group_member - elsif @user == target_user - can! :destroy_group_member - end - - additional_rules! + rule { can?(:admin_group_member) }.policy do + enable :update_group_member + enable :destroy_group_member end - def additional_rules! - # This is meant to be overriden in EE + rule { is_target_user }.policy do + enable :destroy_group_member end end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index fb07298c6c2..dcb37416ca3 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -1,50 +1,58 @@ class GroupPolicy < BasePolicy - def rules - can! :read_group if @subject.public? - return unless @user - - globally_viewable = @subject.public? || (@subject.internal? && !@user.external?) - access_level = @subject.max_member_access_for_user(@user) - owner = access_level >= GroupMember::OWNER - master = access_level >= GroupMember::MASTER - reporter = access_level >= GroupMember::REPORTER - - can_read = false - can_read ||= globally_viewable - can_read ||= access_level >= GroupMember::GUEST - can_read ||= GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any? - can! :read_group if can_read - - if reporter - can! :admin_label - end - - # Only group masters and group owners can create new projects - if master - can! :create_projects - can! :admin_milestones - end - - # Only group owner and administrators can admin group - if owner - can! :admin_group - can! :admin_namespace - can! :admin_group_member - can! :change_visibility_level - can! :create_subgroup if @user.can_create_group - end - - if globally_viewable && @subject.request_access_enabled && access_level == GroupMember::NO_ACCESS - can! :request_access - end - end + desc "Group is public" + with_options scope: :subject, score: 0 + condition(:public_group) { @subject.public? } + + with_score 0 + condition(:logged_in_viewable) { @user && @subject.internal? && !@user.external? } + + condition(:has_access) { access_level != GroupMember::NO_ACCESS } - def can_read_group? - return true if @subject.public? - return true if @user.admin? - return true if @subject.internal? && !@user.external? - return true if @subject.users.include?(@user) + condition(:guest) { access_level >= GroupMember::GUEST } + condition(:owner) { access_level >= GroupMember::OWNER } + condition(:master) { access_level >= GroupMember::MASTER } + condition(:reporter) { access_level >= GroupMember::REPORTER } + condition(:has_projects) do GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any? end + + with_options scope: :subject, score: 0 + condition(:request_access_enabled) { @subject.request_access_enabled } + + rule { public_group } .enable :read_group + rule { logged_in_viewable }.enable :read_group + rule { guest } .enable :read_group + rule { admin } .enable :read_group + rule { has_projects } .enable :read_group + + rule { reporter }.enable :admin_label + + rule { master }.policy do + enable :create_projects + enable :admin_milestones + end + + rule { owner }.policy do + enable :admin_group + enable :admin_namespace + enable :admin_group_member + enable :change_visibility_level + end + + rule { owner & can_create_group }.enable :create_subgroup + + rule { public_group | logged_in_viewable }.enable :view_globally + + rule { default }.enable(:request_access) + + rule { ~request_access_enabled }.prevent :request_access + rule { ~can?(:view_globally) }.prevent :request_access + rule { has_access }.prevent :request_access + + def access_level + return GroupMember::NO_ACCESS if @user.nil? + + @access_level ||= @subject.max_member_access_for_user(@user) + end end diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 9501e499507..daf6fa9e18a 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -1,14 +1,15 @@ class IssuablePolicy < BasePolicy - def action_name - @subject.class.name.underscore - end + delegate { @subject.project } - def rules - if @user && @subject.assignee_or_author?(@user) - can! :"read_#{action_name}" - can! :"update_#{action_name}" - end + desc "User is the assignee or author" + condition(:assignee_or_author) do + @user && @subject.assignee_or_author?(@user) + end - delegate! @subject.project + rule { assignee_or_author }.policy do + enable :read_issue + enable :update_issue + enable :read_merge_request + enable :update_merge_request end end diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 88f3179c6ff..bd2d417b2a8 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -3,25 +3,17 @@ class IssuePolicy < IssuablePolicy # Make sure to sync this class checks with issue.rb to avoid security problems. # Check commit 002ad215818450d2cbbc5fa065850a953dc7ada8 for more information. - def issue - @subject + desc "User can read confidential issues" + condition(:can_read_confidential) do + @user && IssueCollection.new([@subject]).visible_to(@user).any? end - def rules - super + desc "Issue is confidential" + condition(:confidential, scope: :subject) { @subject.confidential? } - if @subject.confidential? && !can_read_confidential? - cannot! :read_issue - cannot! :update_issue - cannot! :admin_issue - end - end - - private - - def can_read_confidential? - return false unless @user - - IssueCollection.new([@subject]).visible_to(@user).any? + rule { confidential & ~can_read_confidential }.policy do + prevent :read_issue + prevent :update_issue + prevent :admin_issue end end diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index 29bb357e00a..85b67f0a237 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -1,10 +1,10 @@ class NamespacePolicy < BasePolicy - def rules - return unless @user + rule { anonymous }.prevent_all - if @subject.owner == @user || @user.admin? - can! :create_projects - can! :admin_namespace - end + condition(:owner) { @subject.owner == @user } + + rule { owner | admin }.policy do + enable :create_projects + enable :admin_namespace end end diff --git a/app/policies/nil_policy.rb b/app/policies/nil_policy.rb new file mode 100644 index 00000000000..13f46ba60f0 --- /dev/null +++ b/app/policies/nil_policy.rb @@ -0,0 +1,3 @@ +class NilPolicy < BasePolicy + rule { default }.prevent_all +end diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index 5326061bd07..20cd51cfb99 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -1,19 +1,24 @@ class NotePolicy < BasePolicy - def rules - delegate! @subject.project + delegate { @subject.project } - return unless @user + condition(:is_author) { @user && @subject.author == @user } + condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? } + condition(:is_noteable_author) { @user && @subject.noteable.author_id == @user.id } - if @subject.author == @user - can! :read_note - can! :update_note - can! :admin_note - can! :resolve_note - end + condition(:editable, scope: :subject) { @subject.editable? } - if @subject.for_merge_request? && - @subject.noteable.author == @user - can! :resolve_note - end + rule { ~editable | anonymous }.prevent :edit_note + rule { is_author | admin }.enable :edit_note + rule { can?(:master_access) }.enable :edit_note + + rule { is_author }.policy do + enable :read_note + enable :update_note + enable :admin_note + enable :resolve_note + end + + rule { for_merge_request & is_noteable_author }.policy do + enable :resolve_note end end diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index e1e5336da8c..cac0530b9f7 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -1,27 +1,28 @@ class PersonalSnippetPolicy < BasePolicy - def rules - can! :read_personal_snippet if @subject.public? - return unless @user + condition(:public_snippet, scope: :subject) { @subject.public? } + condition(:is_author) { @user && @subject.author == @user } + condition(:internal_snippet, scope: :subject) { @subject.internal? } - if @subject.public? - can! :comment_personal_snippet - end + rule { public_snippet }.policy do + enable :read_personal_snippet + enable :comment_personal_snippet + end - if @subject.author == @user - can! :read_personal_snippet - can! :update_personal_snippet - can! :destroy_personal_snippet - can! :admin_personal_snippet - can! :comment_personal_snippet - end + rule { is_author }.policy do + enable :read_personal_snippet + enable :update_personal_snippet + enable :destroy_personal_snippet + enable :admin_personal_snippet + enable :comment_personal_snippet + end - unless @user.external? - can! :create_personal_snippet - end + rule { ~anonymous }.enable :create_personal_snippet + rule { external_user }.prevent :create_personal_snippet - if @subject.internal? && !@user.external? - can! :read_personal_snippet - can! :comment_personal_snippet - end + rule { internal_snippet & ~external_user }.policy do + enable :read_personal_snippet + enable :comment_personal_snippet end + + rule { anonymous }.prevent :comment_personal_snippet end diff --git a/app/policies/project_label_policy.rb b/app/policies/project_label_policy.rb index b12b4c5166b..2d0f021118b 100644 --- a/app/policies/project_label_policy.rb +++ b/app/policies/project_label_policy.rb @@ -1,5 +1,3 @@ class ProjectLabelPolicy < BasePolicy - def rules - delegate! @subject.project - end + delegate { @subject.project } end diff --git a/app/policies/project_member_policy.rb b/app/policies/project_member_policy.rb index 1c038dddd4b..9aedb620be9 100644 --- a/app/policies/project_member_policy.rb +++ b/app/policies/project_member_policy.rb @@ -1,22 +1,16 @@ class ProjectMemberPolicy < BasePolicy - def rules - # anonymous users have no abilities here - return unless @user + delegate { @subject.project } - target_user = @subject.user - project = @subject.project + condition(:target_is_owner, scope: :subject) { @subject.user == @subject.project.owner } + condition(:target_is_self) { @user && @subject.user == @user } - return if target_user == project.owner + rule { anonymous }.prevent_all + rule { target_is_owner }.prevent_all - can_manage = Ability.allowed?(@user, :admin_project_member, project) - - if can_manage - can! :update_project_member - can! :destroy_project_member - end - - if @user == target_user - can! :destroy_project_member - end + rule { can?(:admin_project_member) }.policy do + enable :update_project_member + enable :destroy_project_member end + + rule { target_is_self }.enable :destroy_project_member end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 47518dddb61..7cbca63fab4 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -1,297 +1,353 @@ class ProjectPolicy < BasePolicy - def rules - team_access!(user) + def self.create_read_update_admin(name) + [ + :"create_#{name}", + :"read_#{name}", + :"update_#{name}", + :"admin_#{name}" + ] + end - owner_access! if user.admin? || owner? - team_member_owner_access! if owner? + desc "User is a project owner" + condition :owner do + @user && project.owner == @user || (project.group && project.group.has_owner?(@user)) + end - if project.public? || (project.internal? && !user.external?) - guest_access! - public_access! - can! :request_access if access_requestable? - end + desc "Project has public builds enabled" + condition(:public_builds, scope: :subject) { project.public_builds? } + + # For guest access we use #is_team_member? so we can use + # project.members, which gets cached in subject scope. + # This is safe because team_access_level is guaranteed + # by ProjectAuthorization's validation to be at minimum + # GUEST + desc "User has guest access" + condition(:guest) { is_team_member? } - archived_access! if project.archived? + desc "User has reporter access" + condition(:reporter) { team_access_level >= Gitlab::Access::REPORTER } - disabled_features! + desc "User has developer access" + condition(:developer) { team_access_level >= Gitlab::Access::DEVELOPER } + + desc "User has master access" + condition(:master) { team_access_level >= Gitlab::Access::MASTER } + + desc "Project is public" + condition(:public_project, scope: :subject) { project.public? } + + desc "Project is visible to internal users" + condition(:internal_access) do + project.internal? && !user.external? end - def project - @subject + desc "User is a member of the group" + condition(:group_member, scope: :subject) { project_group_member? } + + desc "Project is archived" + condition(:archived, scope: :subject) { project.archived? } + + condition(:default_issues_tracker, scope: :subject) { project.default_issues_tracker? } + + desc "Container registry is disabled" + condition(:container_registry_disabled, scope: :subject) do + !project.container_registry_enabled end - def owner? - return @owner if defined?(@owner) - - @owner = project.owner == user || - (project.group && project.group.has_owner?(user)) - end - - def guest_access! - can! :read_project - can! :read_board - can! :read_list - can! :read_wiki - can! :read_issue - can! :read_label - can! :read_milestone - can! :read_project_snippet - can! :read_project_member - can! :read_note - can! :create_project - can! :create_issue - can! :create_note - can! :upload_file - can! :read_cycle_analytics - - if project.public_builds? - can! :read_pipeline - can! :read_pipeline_schedule - can! :read_build - end + desc "Project has an external wiki" + condition(:has_external_wiki, scope: :subject) { project.has_external_wiki? } + + desc "Project has request access enabled" + condition(:request_access_enabled, scope: :subject) { project.request_access_enabled } + + features = %w[ + merge_requests + issues + repository + snippets + wiki + builds + ] + + features.each do |f| + # these are scored high because they are unlikely + desc "Project has #{f} disabled" + condition(:"#{f}_disabled", score: 32) { !feature_available?(f.to_sym) } end - def reporter_access! - can! :download_code - can! :download_wiki_code - can! :fork_project - can! :create_project_snippet - can! :update_issue - can! :admin_issue - can! :admin_label - can! :admin_list - can! :read_commit_status - can! :read_build - can! :read_container_image - can! :read_pipeline - can! :read_pipeline_schedule - can! :read_environment - can! :read_deployment - can! :read_merge_request - end - - # Permissions given when an user is team member of a project - def team_member_reporter_access! - can! :build_download_code - can! :build_read_container_image - end - - def developer_access! - can! :admin_merge_request - can! :update_merge_request - can! :create_commit_status - can! :update_commit_status - can! :create_build - can! :update_build - can! :create_pipeline - can! :update_pipeline - can! :create_pipeline_schedule - can! :update_pipeline_schedule - can! :create_merge_request - can! :create_wiki - can! :push_code - can! :resolve_note - can! :create_container_image - can! :update_container_image - can! :create_environment - can! :create_deployment - end - - def master_access! - can! :delete_protected_branch - can! :update_project_snippet - can! :update_environment - can! :update_deployment - can! :admin_milestone - can! :admin_project_snippet - can! :admin_project_member - can! :admin_note - can! :admin_wiki - can! :admin_project - can! :admin_commit_status - can! :admin_build - can! :admin_container_image - can! :admin_pipeline - can! :admin_pipeline_schedule - can! :admin_environment - can! :admin_deployment - can! :admin_pages - can! :read_pages - can! :update_pages - end - - def public_access! - can! :download_code - can! :fork_project - can! :read_commit_status - can! :read_pipeline - can! :read_pipeline_schedule - can! :read_container_image - can! :build_download_code - can! :build_read_container_image - can! :read_merge_request - end - - def owner_access! - guest_access! - reporter_access! - developer_access! - master_access! - can! :change_namespace - can! :change_visibility_level - can! :rename_project - can! :remove_project - can! :archive_project - can! :remove_fork_project - can! :destroy_merge_request - can! :destroy_issue - can! :remove_pages - end - - def team_member_owner_access! - team_member_reporter_access! - end - - # Push abilities on the users team role - def team_access!(user) - access = project.team.max_member_access(user.id) - - return if access < Gitlab::Access::GUEST - guest_access! - - return if access < Gitlab::Access::REPORTER - reporter_access! - team_member_reporter_access! - - return if access < Gitlab::Access::DEVELOPER - developer_access! - - return if access < Gitlab::Access::MASTER - master_access! - end - - def archived_access! - cannot! :create_merge_request - cannot! :push_code - cannot! :delete_protected_branch - cannot! :update_merge_request - cannot! :admin_merge_request - end - - def disabled_features! - repository_enabled = project.feature_available?(:repository, user) - - block_issues_abilities - - unless project.feature_available?(:merge_requests, user) && repository_enabled - cannot!(*named_abilities(:merge_request)) - end + rule { guest }.enable :guest_access + rule { reporter }.enable :reporter_access + rule { developer }.enable :developer_access + rule { master }.enable :master_access + + rule { owner | admin }.policy do + enable :guest_access + enable :reporter_access + enable :developer_access + enable :master_access + + enable :change_namespace + enable :change_visibility_level + enable :rename_project + enable :remove_project + enable :archive_project + enable :remove_fork_project + enable :destroy_merge_request + enable :destroy_issue + enable :remove_pages + end - unless project.feature_available?(:issues, user) || project.feature_available?(:merge_requests, user) - cannot!(*named_abilities(:label)) - cannot!(*named_abilities(:milestone)) - end + rule { owner | reporter }.policy do + enable :build_download_code + enable :build_read_container_image + end - unless project.feature_available?(:snippets, user) - cannot!(*named_abilities(:project_snippet)) - end + rule { can?(:guest_access) }.policy do + enable :read_project + enable :read_board + enable :read_list + enable :read_wiki + enable :read_issue + enable :read_label + enable :read_milestone + enable :read_project_snippet + enable :read_project_member + enable :read_note + enable :create_project + enable :create_issue + enable :create_note + enable :upload_file + enable :read_cycle_analytics + enable :read_project_snippet + end - unless project.feature_available?(:wiki, user) || project.has_external_wiki? - cannot!(*named_abilities(:wiki)) - cannot!(:download_wiki_code) - end + rule { can?(:reporter_access) }.policy do + enable :download_code + enable :download_wiki_code + enable :fork_project + enable :create_project_snippet + enable :update_issue + enable :admin_issue + enable :admin_label + enable :admin_list + enable :read_commit_status + enable :read_build + enable :read_container_image + enable :read_pipeline + enable :read_pipeline_schedule + enable :read_environment + enable :read_deployment + enable :read_merge_request + end - unless project.feature_available?(:builds, user) && repository_enabled - cannot!(*named_abilities(:build)) - cannot!(*named_abilities(:pipeline) - [:read_pipeline]) - cannot!(*named_abilities(:pipeline_schedule)) - cannot!(*named_abilities(:environment)) - cannot!(*named_abilities(:deployment)) - end + rule { (~anonymous & public_project) | internal_access }.policy do + enable :public_user_access + end - unless repository_enabled - cannot! :push_code - cannot! :delete_protected_branch - cannot! :download_code - cannot! :fork_project - cannot! :read_commit_status - end + rule { can?(:public_user_access) }.policy do + enable :guest_access + enable :request_access + end - unless project.container_registry_enabled - cannot!(*named_abilities(:container_image)) - end + rule { owner | admin | guest | group_member }.prevent :request_access + rule { ~request_access_enabled }.prevent :request_access + + rule { can?(:developer_access) }.policy do + enable :admin_merge_request + enable :update_merge_request + enable :create_commit_status + enable :update_commit_status + enable :create_build + enable :update_build + enable :create_pipeline + enable :update_pipeline + enable :create_pipeline_schedule + enable :update_pipeline_schedule + enable :create_merge_request + enable :create_wiki + enable :push_code + enable :resolve_note + enable :create_container_image + enable :update_container_image + enable :create_environment + enable :create_deployment end - def anonymous_rules - return unless project.public? + rule { can?(:master_access) }.policy do + enable :delete_protected_branch + enable :update_project_snippet + enable :update_environment + enable :update_deployment + enable :admin_milestone + enable :admin_project_snippet + enable :admin_project_member + enable :admin_note + enable :admin_wiki + enable :admin_project + enable :admin_commit_status + enable :admin_build + enable :admin_container_image + enable :admin_pipeline + enable :admin_pipeline_schedule + enable :admin_environment + enable :admin_deployment + enable :admin_pages + enable :read_pages + enable :update_pages + end - base_readonly_access! + rule { can?(:public_user_access) }.policy do + enable :public_access - # Allow to read builds by anonymous user if guests are allowed - can! :read_build if project.public_builds? + enable :fork_project + enable :build_download_code + enable :build_read_container_image + end - disabled_features! + rule { archived }.policy do + prevent :create_merge_request + prevent :push_code + prevent :delete_protected_branch + prevent :update_merge_request + prevent :admin_merge_request end - def block_issues_abilities - unless project.feature_available?(:issues, user) - cannot! :read_issue if project.default_issues_tracker? - cannot! :create_issue - cannot! :update_issue - cannot! :admin_issue - end + rule { merge_requests_disabled | repository_disabled }.policy do + prevent(*create_read_update_admin(:merge_request)) end - def named_abilities(name) - [ - :"read_#{name}", - :"create_#{name}", - :"update_#{name}", - :"admin_#{name}" - ] + rule { issues_disabled & merge_requests_disabled }.policy do + prevent(*create_read_update_admin(:label)) + prevent(*create_read_update_admin(:milestone)) + end + + rule { snippets_disabled }.policy do + prevent(*create_read_update_admin(:project_snippet)) + end + + rule { wiki_disabled & ~has_external_wiki }.policy do + prevent(*create_read_update_admin(:wiki)) + prevent(:download_wiki_code) + end + + rule { builds_disabled | repository_disabled }.policy do + prevent(*create_read_update_admin(:build)) + prevent(*(create_read_update_admin(:pipeline) - [:read_pipeline])) + prevent(*create_read_update_admin(:pipeline_schedule)) + prevent(*create_read_update_admin(:environment)) + prevent(*create_read_update_admin(:deployment)) + end + + rule { repository_disabled }.policy do + prevent :push_code + prevent :push_code_to_protected_branches + prevent :download_code + prevent :fork_project + prevent :read_commit_status + end + + rule { container_registry_disabled }.policy do + prevent(*create_read_update_admin(:container_image)) + end + + rule { anonymous & ~public_project }.prevent_all + rule { public_project }.enable(:public_access) + + rule { can?(:public_access) }.policy do + enable :read_project + enable :read_board + enable :read_list + enable :read_wiki + enable :read_label + enable :read_milestone + enable :read_project_snippet + enable :read_project_member + enable :read_merge_request + enable :read_note + enable :read_pipeline + enable :read_pipeline_schedule + enable :read_commit_status + enable :read_container_image + enable :download_code + enable :download_wiki_code + enable :read_cycle_analytics + + # NOTE: may be overridden by IssuePolicy + enable :read_issue + end + + rule { public_builds }.policy do + enable :read_build + end + + rule { public_builds & can?(:guest_access) }.policy do + enable :read_pipeline + enable :read_pipeline_schedule + end + + rule { issues_disabled }.policy do + prevent :create_issue + prevent :update_issue + prevent :admin_issue + end + + rule { issues_disabled & default_issues_tracker }.policy do + prevent :read_issue end private - def project_group_member?(user) + def is_team_member? + return false if @user.nil? + + greedy_load_subject = false + + # when scoping by subject, we want to be greedy + # and load *all* the members with one query. + greedy_load_subject ||= DeclarativePolicy.preferred_scope == :subject + + # in this case we're likely to have loaded #members already + # anyways, and #member? would fail with an error + greedy_load_subject ||= !@user.persisted? + + if greedy_load_subject + project.team.members.include?(user) + else + # otherwise we just make a specific query for + # this particular user. + team_access_level >= Gitlab::Access::GUEST + end + end + + def project_group_member? + return false if @user.nil? + project.group && ( - project.group.members_with_parents.exists?(user_id: user.id) || - project.group.requesters.exists?(user_id: user.id) + project.group.members_with_parents.exists?(user_id: @user.id) || + project.group.requesters.exists?(user_id: @user.id) ) end - def access_requestable? - project.request_access_enabled && - !owner? && - !user.admin? && - !project.team.member?(user) && - !project_group_member?(user) - end - - # A base set of abilities for read-only users, which - # is then augmented as necessary for anonymous and other - # read-only users. - def base_readonly_access! - can! :read_project - can! :read_board - can! :read_list - can! :read_wiki - can! :read_label - can! :read_milestone - can! :read_project_snippet - can! :read_project_member - can! :read_merge_request - can! :read_note - can! :read_pipeline - can! :read_pipeline_schedule - can! :read_commit_status - can! :read_container_image - can! :download_code - can! :download_wiki_code - can! :read_cycle_analytics + def team_access_level + return -1 if @user.nil? - # NOTE: may be overridden by IssuePolicy - can! :read_issue + # NOTE: max_member_access has its own cache + project.team.max_member_access(@user.id) + end + + def feature_available?(feature) + case project.project_feature.access_level(feature) + when ProjectFeature::DISABLED + false + when ProjectFeature::PRIVATE + guest? || admin? + else + true + end + end + + def project + @subject end end diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index bc5c4f32f79..dd270643bbf 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -1,25 +1,45 @@ class ProjectSnippetPolicy < BasePolicy - def rules - # We have to check both project feature visibility and a snippet visibility and take the stricter one - # This will be simplified - check https://gitlab.com/gitlab-org/gitlab-ce/issues/27573 - return unless @subject.project.feature_available?(:snippets, @user) - return unless Ability.allowed?(@user, :read_project, @subject.project) - - can! :read_project_snippet if @subject.public? - return unless @user - - if @user && (@subject.author == @user || @user.admin?) - can! :read_project_snippet - can! :update_project_snippet - can! :admin_project_snippet - end - - if @subject.internal? && !@user.external? - can! :read_project_snippet - end - - if @subject.project.team.member?(@user) - can! :read_project_snippet - end + delegate :project + + desc "Snippet is public" + condition(:public_snippet, scope: :subject) { @subject.public? } + condition(:private_snippet, scope: :subject) { @subject.private? } + condition(:public_project, scope: :subject) { @subject.project.public? } + + condition(:is_author) { @user && @subject.author == @user } + + condition(:internal, scope: :subject) { @subject.internal? } + + # We have to check both project feature visibility and a snippet visibility and take the stricter one + # This will be simplified - check https://gitlab.com/gitlab-org/gitlab-ce/issues/27573 + rule { ~can?(:read_project) }.policy do + prevent :read_project_snippet + prevent :update_project_snippet + prevent :admin_project_snippet + end + + # we have to use this complicated prevent because the delegated project policy + # is overly greedy in allowing :read_project_snippet, since it doesn't have any + # information about the snippet. However, :read_project_snippet on the *project* + # is used to hide/show various snippet-related controls, so we can't just move + # all of the handling here. + rule do + all?(private_snippet | (internal & external_user), + ~project.guest, + ~admin, + ~is_author) + end.prevent :read_project_snippet + + rule { internal & ~is_author & ~admin }.policy do + prevent :update_project_snippet + prevent :admin_project_snippet + end + + rule { public_snippet }.enable :read_project_snippet + + rule { is_author | admin }.policy do + enable :read_project_snippet + enable :update_project_snippet + enable :admin_project_snippet end end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 229846e368c..0181ddf85e0 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -1,19 +1,20 @@ class UserPolicy < BasePolicy include Gitlab::CurrentSettings - def rules - can! :read_user if @user || !restricted_public_level? + desc "The application is restricted from public visibility" + condition(:restricted_public_level, scope: :global) do + current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC) + end - if @user - if @user.admin? || @subject == @user - can! :destroy_user - end + desc "The current user is the user in question" + condition(:user_is_self, score: 0) { @subject == @user } - cannot! :destroy_user if @subject.ghost? - end - end + desc "This is the ghost user" + condition(:subject_ghost, scope: :subject, score: 0) { @subject.ghost? } - def restricted_public_level? - current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC) - end + rule { ~restricted_public_level }.enable :read_user + rule { ~anonymous }.enable :read_user + + rule { user_is_self | admin }.enable :destroy_user + rule { subject_ghost }.prevent :destroy_user end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 418fa9afd6e..a1d67cbc244 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -3,7 +3,7 @@ module Boards class ListService < BaseService def execute issues = IssuesFinder.new(current_user, filter_params).execute - issues = without_board_labels(issues) unless movable_list? + issues = without_board_labels(issues) unless movable_list? || closed_list? issues = with_list_label(issues) if movable_list? issues.order_by_position_and_priority end @@ -21,7 +21,15 @@ module Boards end def movable_list? - @movable_list ||= list.present? && list.movable? + return @movable_list if defined?(@movable_list) + + @movable_list = list.present? && list.movable? + end + + def closed_list? + return @closed_list if defined?(@closed_list) + + @closed_list = list.present? && list.closed? end def filter_params diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index af84d4c7427..b951e8d0c9f 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -54,7 +54,7 @@ module Ci def builds_for_shared_runner new_builds. # don't run projects which have not enabled shared runners and builds - joins(:project).where(projects: { shared_runners_enabled: true }) + joins(:project).where(projects: { shared_runners_enabled: true, pending_delete: false }) .joins('LEFT JOIN project_features ON ci_builds.project_id = project_features.project_id') .where('project_features.builds_access_level IS NULL or project_features.builds_access_level > 0'). @@ -66,7 +66,7 @@ module Ci end def builds_for_specific_runner - new_builds.where(project: runner.projects.with_builds_enabled).order('created_at ASC') + new_builds.where(project: runner.projects.without_deleted.with_builds_enabled).order('created_at ASC') end def running_builds_for_shared_runners diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb index 497fdb09cdc..80c51cb5a72 100644 --- a/app/services/groups/destroy_service.rb +++ b/app/services/groups/destroy_service.rb @@ -1,8 +1,7 @@ module Groups class DestroyService < Groups::BaseService def async_execute - # Soft delete via paranoia gem - group.destroy + group.soft_delete_without_removing_associations 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 @@ -10,7 +9,7 @@ module Groups def execute group.prepare_for_destroy - group.projects.with_deleted.each do |project| + 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 these repositories diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb index f00a33969a8..5dd40e07c0d 100644 --- a/app/services/merge_requests/get_urls_service.rb +++ b/app/services/merge_requests/get_urls_service.rb @@ -49,7 +49,7 @@ module MergeRequests 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) + url = Gitlab::Routing.url_helpers.namespace_project_new_merge_request_url(project.namespace, project, merge_request: merge_request_params) { branch_name: branch_name, url: url, new_merge_request: true } end diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 8d1820bc504..9ac561e4bd2 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -11,7 +11,7 @@ class NotificationRecipientService def build_recipients(target, current_user, action:, previous_assignee: nil, skip_current_user: true) custom_action = build_custom_key(action, target) - recipients = target.participants(current_user) + recipients = participants(target, current_user) recipients = add_project_watchers(recipients) recipients = add_custom_notifications(recipients, custom_action) recipients = reject_mention_users(recipients) @@ -86,12 +86,7 @@ class NotificationRecipientService mentioned_users = note.mentioned_users.select { |user| user.can?(ability, subject) } # Add all users participating in the thread (author, assignee, comment authors) - recipients = - if target.respond_to?(:participants) - target.participants(note.author) - else - mentioned_users - end + recipients = participants(target, note.author) || mentioned_users unless note.for_personal_snippet? # Merge project watchers @@ -123,6 +118,14 @@ class NotificationRecipientService protected + # Ensure that if we modify this array, we aren't modifying the memoised + # participants on the target. + def participants(target, user) + return unless target.respond_to?(:participants) + + target.participants(user).dup + end + # Get project/group users with CUSTOM notification level def add_custom_notifications(recipients, action) user_ids = [] diff --git a/app/services/projects/unlink_fork_service.rb b/app/services/projects/unlink_fork_service.rb index 315c3e16292..f385e426827 100644 --- a/app/services/projects/unlink_fork_service.rb +++ b/app/services/projects/unlink_fork_service.rb @@ -10,7 +10,7 @@ module Projects merge_requests = @project.forked_from_project.merge_requests.opened.from_project(@project) merge_requests.each do |mr| - MergeRequests::CloseService.new(@project, @current_user).execute(mr) + ::MergeRequests::CloseService.new(@project, @current_user).execute(mr) end @project.forked_project_link.destroy diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 17cf71cf098..e60b854f916 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -93,10 +93,11 @@ module Projects end # Requires UnZip at least 6.00 Info-ZIP. + # -qq be (very) quiet # -n never overwrite existing files # We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories site_path = File.join(SITE_PATH, '*') - unless system(*%W(unzip -n #{artifacts} #{site_path} -d #{temp_path})) + unless system(*%W(unzip -qq -n #{artifacts} #{site_path} -d #{temp_path})) raise 'pages failed to extract' end end diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index 673afb8b5b9..9d7237c2fbb 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -35,7 +35,7 @@ module Users Groups::DestroyService.new(group, current_user).execute end - user.personal_projects.with_deleted.each do |project| + 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).execute diff --git a/app/views/doorkeeper/applications/edit.html.haml b/app/views/doorkeeper/applications/edit.html.haml index fb6aa30acee..49f90298a50 100644 --- a/app/views/doorkeeper/applications/edit.html.haml +++ b/app/views/doorkeeper/applications/edit.html.haml @@ -1,3 +1,4 @@ - page_title "Edit", @application.name, "Applications" +- @content_class = "limit-container-width" unless fluid_layout %h3.page-title Edit application = render 'form', application: @application diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml index aa271150b07..d1237d7bf6f 100644 --- a/app/views/doorkeeper/applications/index.html.haml +++ b/app/views/doorkeeper/applications/index.html.haml @@ -1,7 +1,8 @@ - page_title "Applications" +- @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 = page_title %p @@ -10,7 +11,7 @@ and applications that you've authorized to use your account. - else Manage applications that you've authorized to use your account. - .col-lg-9 + .col-lg-8 - if user_oauth_applications? %h5.prepend-top-0 Add new application diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index 559de63d96d..72eab964766 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -1,4 +1,6 @@ - page_title @application.name, "Applications" +- @content_class = "limit-container-width" unless fluid_layout + %h3.page-title Application: #{@application.name} diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index f7a1d7e8844..cc710f4ec7d 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -11,6 +11,8 @@ %meta{ property: 'og:title', content: page_title } %meta{ property: 'og:description', content: page_description } %meta{ property: 'og:image', content: page_image } + %meta{ property: 'og:image:width', content: '64' } + %meta{ property: 'og:image:height', content: '64' } %meta{ property: 'og:url', content: request.base_url + request.fullpath } -# Twitter Card - https://dev.twitter.com/cards/types/summary @@ -32,6 +34,7 @@ - if show_new_nav? = stylesheet_link_tag "new_nav", media: "all" + = stylesheet_link_tag "new_sidebar", media: "all" = Gon::Base.render_data diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index b7df11681d3..62a76a1b00e 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,11 +1,15 @@ -.page-with-sidebar{ class: page_gutter_class } - - if defined?(nav) && nav - .layout-nav - .container-fluid - = render "layouts/nav/#{nav}" - - if content_for?(:sub_nav) - = yield :sub_nav - .content-wrapper{ class: layout_nav_class } +.page-with-sidebar{ class: "#{('page-with-new-sidebar' if defined?(@new_sidebar) && @new_sidebar)} #{page_gutter_class}" } + - if show_new_nav? + - if defined?(nav) && nav + = render "layouts/nav/#{nav}" + - else + - if defined?(nav) && nav + .layout-nav + .container-fluid + = render "layouts/nav/#{nav}" + - if content_for?(:sub_nav) + = yield :sub_nav + .content-wrapper{ class: "#{(layout_nav_class unless show_new_nav?)}" } .alert-wrapper = render "layouts/broadcast" = render "layouts/flash" diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml index 87064cc9b3f..ae9eee215e0 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -1,5 +1,9 @@ - page_title "Admin Area" - header_title "Admin Area", admin_root_path -- nav "admin" +- if show_new_nav? + - nav "new_admin_sidebar" + - @new_sidebar = true +- else + - nav "admin" = render template: "layouts/application" diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index f06acc98ca1..35abfa0e80c 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -1,6 +1,10 @@ - page_title @group.name - page_description @group.description unless page_description - header_title group_title(@group) unless header_title -- nav "group" +- if show_new_nav? + - nav "new_group_sidebar" + - @new_sidebar = true +- else + - nav "group" = render template: "layouts/application" diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index 9ff1164f2ee..4d41579168c 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -33,7 +33,7 @@ = link_to 'New issue', new_namespace_project_issue_path(@project.namespace, @project) - if merge_project %li - = link_to 'New merge request', new_namespace_project_merge_request_path(merge_project.namespace, merge_project) + = link_to 'New merge request', namespace_project_new_merge_request_path(merge_project.namespace, merge_project) - if create_project_snippet %li.header-new-project-snippet = link_to 'New snippet', new_namespace_project_snippet_path(@project.namespace, @project) diff --git a/app/views/layouts/nav/_new_admin_sidebar.html.haml b/app/views/layouts/nav/_new_admin_sidebar.html.haml new file mode 100644 index 00000000000..40c1ca7b53e --- /dev/null +++ b/app/views/layouts/nav/_new_admin_sidebar.html.haml @@ -0,0 +1,123 @@ +.nav-sidebar + = link_to admin_root_path, title: 'Admin Overview', class: 'context-header' do + .avatar-container.s40.settings-avatar + = icon('wrench') + .project-title Admin Area + %ul.sidebar-top-level-items + = nav_link(controller: %w(dashboard admin projects users groups builds runners cohorts), html_options: {class: 'home'}) do + = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do + %span + Overview + + %ul.sidebar-sub-level-items + = nav_link(controller: :dashboard, html_options: {class: 'home'}) do + = link_to admin_root_path, title: 'Overview' do + %span + Overview + = nav_link(controller: [:admin, :projects]) do + = link_to admin_projects_path, title: 'Projects' do + %span + Projects + = nav_link(controller: :users) do + = link_to admin_users_path, title: 'Users' do + %span + Users + = nav_link(controller: :groups) do + = link_to admin_groups_path, title: 'Groups' do + %span + Groups + = nav_link path: 'builds#index' do + = link_to admin_jobs_path, title: 'Jobs' do + %span + Jobs + = nav_link path: ['runners#index', 'runners#show'] do + = link_to admin_runners_path, title: 'Runners' do + %span + Runners + = nav_link path: 'cohorts#index' do + = link_to admin_cohorts_path, title: 'Cohorts' do + %span + Cohorts + + = nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do + = link_to admin_conversational_development_index_path, title: 'Monitoring' do + %span + Monitoring + + %ul.sidebar-sub-level-items + = nav_link(controller: :conversational_development_index) do + = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do + %span + ConvDev Index + = nav_link(controller: :system_info) do + = link_to admin_system_info_path, title: 'System Info' do + %span + System Info + = nav_link(controller: :background_jobs) do + = link_to admin_background_jobs_path, title: 'Background Jobs' do + %span + Background Jobs + = nav_link(controller: :logs) do + = link_to admin_logs_path, title: 'Logs' do + %span + Logs + = nav_link(controller: :health_check) do + = link_to admin_health_check_path, title: 'Health Check' do + %span + Health Check + = nav_link(controller: :requests_profiles) do + = link_to admin_requests_profiles_path, title: 'Requests Profiles' do + %span + Requests Profiles + + = nav_link(controller: :broadcast_messages) do + = link_to admin_broadcast_messages_path, title: 'Messages' do + %span + Messages + = nav_link(controller: [:hooks, :hook_logs]) do + = link_to admin_hooks_path, title: 'Hooks' do + %span + System Hooks + + = nav_link(controller: :applications) do + = link_to admin_applications_path, title: 'Applications' do + %span + Applications + + = nav_link(controller: :abuse_reports) do + = link_to admin_abuse_reports_path, title: "Abuse Reports" do + %span + Abuse Reports + %span.badge.count= number_with_delimiter(AbuseReport.count(:all)) + + - if akismet_enabled? + = nav_link(controller: :spam_logs) do + = link_to admin_spam_logs_path, title: "Spam Logs" do + %span + Spam Logs + + = nav_link(controller: :deploy_keys) do + = link_to admin_deploy_keys_path, title: 'Deploy Keys' do + %span + Deploy Keys + + = nav_link(controller: :services) do + = link_to admin_application_settings_services_path, title: 'Service Templates' do + %span + Service Templates + + = nav_link(controller: :labels) do + = link_to admin_labels_path, title: 'Labels' do + %span + Labels + + = nav_link(controller: :appearances) do + = link_to admin_appearances_path, title: 'Appearances' do + %span + Appearance + + %li.divider + = nav_link(controller: :application_settings) do + = link_to admin_application_settings_path, title: 'Settings' do + %span + Settings diff --git a/app/views/layouts/nav/_new_group_sidebar.html.haml b/app/views/layouts/nav/_new_group_sidebar.html.haml new file mode 100644 index 00000000000..b7ac04cc3e5 --- /dev/null +++ b/app/views/layouts/nav/_new_group_sidebar.html.haml @@ -0,0 +1,61 @@ +.nav-sidebar + = link_to group_path(@group), title: 'Group', class: 'context-header' do + .avatar-container.s40.group-avatar + = image_tag group_icon(@group), class: "avatar s40 avatar-tile" + .group-title + = @group.name + %ul.sidebar-top-level-items + = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: 'home' }) do + = link_to group_path(@group), title: 'Home' do + %span + Group + + %ul.sidebar-sub-level-items + = nav_link(path: ['groups#show', 'groups#subgroups'], html_options: { class: 'home' }) do + = link_to group_path(@group), title: 'Group Home' do + %span + Home + + = nav_link(path: 'groups#activity') do + = link_to activity_group_path(@group), title: 'Activity' do + %span + Activity + + = nav_link(path: ['groups#issues', 'labels#index', 'milestones#index']) do + = link_to issues_group_path(@group), title: 'Issues' do + %span + Issues + - issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute + %span.badge.count= number_with_delimiter(issues.count) + + %ul.sidebar-sub-level-items + = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do + = link_to issues_group_path(@group), title: 'List' do + %span + List + + = nav_link(path: 'labels#index') do + = link_to group_labels_path(@group), title: 'Labels' do + %span + Labels + + = nav_link(path: 'milestones#index') do + = link_to group_milestones_path(@group), title: 'Milestones' do + %span + Milestones + + = nav_link(path: 'groups#merge_requests') do + = link_to merge_requests_group_path(@group), title: 'Merge Requests' do + %span + Merge Requests + - merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute + %span.badge.count= number_with_delimiter(merge_requests.count) + = nav_link(path: 'group_members#index') do + = link_to group_group_members_path(@group), title: 'Members' do + %span + Members + - if current_user && can?(current_user, :admin_group, @group) + = nav_link(path: %w[groups#projects groups#edit]) do + = link_to edit_group_path(@group), title: 'Settings' do + %span + Settings diff --git a/app/views/layouts/nav/_new_profile_sidebar.html.haml b/app/views/layouts/nav/_new_profile_sidebar.html.haml new file mode 100644 index 00000000000..033ea149cfb --- /dev/null +++ b/app/views/layouts/nav/_new_profile_sidebar.html.haml @@ -0,0 +1,53 @@ +.nav-sidebar + = link_to profile_path, title: 'Profile Settings', class: 'context-header' do + .avatar-container.s40.settings-avatar + = icon('user') + .project-title User Settings + %ul.sidebar-top-level-items + = nav_link(path: 'profiles#show', html_options: {class: 'home'}) do + = link_to profile_path, title: 'Profile Settings' do + %span + Profile + = nav_link(controller: [:accounts, :two_factor_auths]) do + = link_to profile_account_path, title: 'Account' do + %span + Account + - if current_application_settings.user_oauth_applications? + = nav_link(controller: 'oauth/applications') do + = link_to applications_profile_path, title: 'Applications' do + %span + Applications + = nav_link(controller: :chat_names) do + = link_to profile_chat_names_path, title: 'Chat' do + %span + Chat + = nav_link(controller: :personal_access_tokens) do + = link_to profile_personal_access_tokens_path, title: 'Access Tokens' do + %span + Access Tokens + = nav_link(controller: :emails) do + = link_to profile_emails_path, title: 'Emails' do + %span + Emails + - unless current_user.ldap_user? + = nav_link(controller: :passwords) do + = link_to edit_profile_password_path, title: 'Password' do + %span + Password + = nav_link(controller: :notifications) do + = link_to profile_notifications_path, title: 'Notifications' do + %span + Notifications + + = nav_link(controller: :keys) do + = link_to profile_keys_path, title: 'SSH Keys' do + %span + SSH Keys + = nav_link(controller: :preferences) do + = link_to profile_preferences_path, title: 'Preferences' do + %span + Preferences + = nav_link(path: 'profiles#audit_log') do + = link_to audit_log_profile_path, title: 'Authentication log' do + %span + Authentication log diff --git a/app/views/layouts/nav/_new_project_sidebar.html.haml b/app/views/layouts/nav/_new_project_sidebar.html.haml new file mode 100644 index 00000000000..eae9da5da14 --- /dev/null +++ b/app/views/layouts/nav/_new_project_sidebar.html.haml @@ -0,0 +1,247 @@ +.nav-sidebar + - can_edit = can?(current_user, :admin_project, @project) + = link_to project_path(@project), title: 'Project', class: 'context-header' do + .avatar-container.s40.project-avatar + = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile') + .project-title + = @project.name + %ul.sidebar-top-level-items + = nav_link(path: ['projects#show', 'projects#activity', 'cycle_analytics#show'], html_options: { class: 'home' }) do + = link_to project_path(@project), title: 'Project', class: 'shortcuts-project' do + %span + Project + + %ul.sidebar-sub-level-items + = nav_link(path: 'projects#show') do + = link_to project_path(@project), title: _('Project home'), class: 'shortcuts-project' do + %span= _('Home') + + = nav_link(path: 'projects#activity') do + = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do + %span= _('Activity') + + - if can?(current_user, :read_cycle_analytics, @project) + = nav_link(path: 'cycle_analytics#show') do + = link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do + %span= _('Cycle Analytics') + + - if project_nav_tab? :files + = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network)) do + = link_to project_files_path(@project), title: 'Repository', class: 'shortcuts-tree' do + %span + Repository + + %ul.sidebar-sub-level-items + = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do + = link_to project_files_path(@project) do + #{ _('Files') } + + = nav_link(controller: [:commit, :commits]) do + = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do + #{ _('Commits') } + + = nav_link(html_options: {class: branches_tab_class}) do + = link_to namespace_project_branches_path(@project.namespace, @project) do + #{ _('Branches') } + + = nav_link(controller: [:tags, :releases]) do + = link_to namespace_project_tags_path(@project.namespace, @project) do + #{ _('Tags') } + + = nav_link(path: 'graphs#show') do + = link_to namespace_project_graph_path(@project.namespace, @project, current_ref) do + #{ _('Contributors') } + + = nav_link(controller: %w(network)) do + = link_to namespace_project_network_path(@project.namespace, @project, current_ref) do + #{ s_('ProjectNetworkGraph|Graph') } + + = nav_link(controller: :compare) do + = link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: current_ref) do + #{ _('Compare') } + + = nav_link(path: 'graphs#charts') do + = link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref) do + #{ _('Charts') } + + - if project_nav_tab? :container_registry + = nav_link(controller: %w[projects/registry/repositories]) do + = link_to project_container_registry_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do + %span + Registry + + - if project_nav_tab? :issues + = nav_link(controller: @project.default_issues_tracker? ? [:issues, :labels, :milestones, :boards] : :issues) do + = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do + %span + Issues + - if @project.default_issues_tracker? + %span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count) + + %ul.sidebar-sub-level-items + - if project_nav_tab?(:issues) && !current_controller?(:merge_requests) + = nav_link(controller: :issues) do + = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues' do + %span + List + + = nav_link(controller: :boards) do + = link_to namespace_project_boards_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 + %span + Merge Requests + + - if project_nav_tab? :labels + = nav_link(controller: :labels) do + = link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do + %span + Labels + + - if project_nav_tab? :milestones + = nav_link(controller: :milestones) do + = link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do + %span + Milestones + + - if project_nav_tab? :merge_requests + = nav_link(controller: @project.default_issues_tracker? ? :merge_requests : [:merge_requests, :labels, :milestones]) do + = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do + %span + Merge Requests + %span.badge.count.merge_counter.js-merge-counter= number_with_delimiter(MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened.count) + + - if project_nav_tab? :pipelines + = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts]) do + = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do + %span + Pipelines + + %ul.sidebar-sub-level-items + - if project_nav_tab? :pipelines + = nav_link(path: ['pipelines#index', 'pipelines#show']) do + = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do + %span + Pipelines + + - if project_nav_tab? :builds + = nav_link(controller: [:jobs, :artifacts]) do + = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do + %span + Jobs + + - if project_nav_tab? :pipelines + = nav_link(controller: :pipeline_schedules) do + = link_to pipeline_schedules_path(@project), title: 'Schedules', class: 'shortcuts-builds' do + %span + Schedules + + - if project_nav_tab? :environments + = nav_link(controller: :environments) do + = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do + %span + Environments + + - if @project.feature_available?(:builds, current_user) && !@project.empty_repo? + = nav_link(path: 'pipelines#charts') do + = link_to charts_namespace_project_pipelines_path(@project.namespace, @project), title: 'Charts', class: 'shortcuts-pipelines-charts' do + %span + Charts + + - if project_nav_tab? :wiki + = nav_link(controller: :wikis) do + = link_to get_project_wiki_path(@project), title: 'Wiki', class: 'shortcuts-wiki' do + %span + Wiki + + - if project_nav_tab? :snippets + = nav_link(controller: :snippets) do + = link_to namespace_project_snippets_path(@project.namespace, @project), title: 'Snippets', class: 'shortcuts-snippets' do + %span + Snippets + + - if project_nav_tab? :settings + = nav_link(path: %w[projects#edit members#show integrations#show services#edit repository#show ci_cd#show pages#show]) do + = link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do + %span + Settings + + %ul.sidebar-sub-level-items + - can_edit = can?(current_user, :admin_project, @project) + - if can_edit + = nav_link(controller: :projects) do + = link_to edit_project_path(@project), title: 'General' do + %span + General + = nav_link(controller: :members) do + = link_to project_settings_members_path(@project), title: 'Members' do + %span + Members + - if can_edit + = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do + = link_to project_settings_integrations_path(@project), title: 'Integrations' do + %span + Integrations + = nav_link(controller: :repository) do + = link_to namespace_project_settings_repository_path(@project.namespace, @project), title: 'Repository' do + %span + Repository + - if @project.feature_available?(:builds, current_user) + = nav_link(controller: :ci_cd) do + = link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'Pipelines' do + %span + Pipelines + - if Gitlab.config.pages.enabled + = nav_link(controller: :pages) do + = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages' do + %span + Pages + + - else + = nav_link(path: %w[members#show]) do + = link_to namespace_project_settings_members_path(@project.namespace, @project), title: 'Settings', class: 'shortcuts-tree' do + %span + Settings + + -# Shortcut to Project > Activity + %li.hidden + = link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do + %span + Activity + + -# Shortcut to Repository > Graph (formerly, Network) + - if project_nav_tab? :network + %li.hidden + = link_to namespace_project_network_path(@project.namespace, @project, current_ref), title: 'Network', class: 'shortcuts-network' do + Graph + + -# Shortcut to Repository > Charts (formerly, top-nav item "Graphs") + - unless @project.empty_repo? + %li.hidden + = link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref), title: 'Charts', class: 'shortcuts-repository-charts' do + Charts + + -# Shortcut to Issues > New Issue + %li.hidden + = link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'shortcuts-new-issue' do + Create a new issue + + -# Shortcut to Pipelines > Jobs + - if project_nav_tab? :builds + %li.hidden + = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do + Jobs + + -# Shortcut to commits page + - if project_nav_tab? :commits + %li.hidden + = link_to project_commits_path(@project), title: 'Commits', class: 'shortcuts-commits' do + Commits + + -# Shortcut to issue boards + %li.hidden + = link_to 'Issue Boards', namespace_project_boards_path(@project.namespace, @project), title: 'Issue Boards', class: 'shortcuts-issue-boards' diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 29658da7792..68024d782a6 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -31,7 +31,9 @@ %span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count) - if project_nav_tab? :merge_requests - = nav_link(controller: @project.default_issues_tracker? ? :merge_requests : [:merge_requests, :labels, :milestones]) do + - controllers = [:merge_requests, 'projects/merge_requests/conflicts'] + - controllers.push(:merge_requests, :labels, :milestones) unless @project.default_issues_tracker? + = nav_link(controller: controllers) do = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do %span Merge Requests diff --git a/app/views/layouts/profile.html.haml b/app/views/layouts/profile.html.haml index 0ee8a57dbd4..c365839e605 100644 --- a/app/views/layouts/profile.html.haml +++ b/app/views/layouts/profile.html.haml @@ -1,6 +1,10 @@ - page_title "User Settings" - header_title "User Settings", profile_path unless header_title - sidebar "dashboard" -- nav "profile" +- if show_new_nav? + - nav "new_profile_sidebar" + - @new_sidebar = true +- else + - nav "profile" = render template: "layouts/application" diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 3f5b0c54e50..4458c3c2c23 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -1,7 +1,11 @@ - page_title @project.name_with_namespace - page_description @project.description unless page_description - header_title project_title(@project) unless header_title -- nav "project" +- if show_new_nav? + - nav "new_project_sidebar" + - @new_sidebar = true +- else + - nav "project" - content_for :project_javascripts do - project = @target_project || @project diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index a319b18e507..ed079ed7dfb 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -1,4 +1,5 @@ - page_title "Account" +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' - if current_user.ldap_user? @@ -6,13 +7,13 @@ Some options are unavailable for LDAP accounts .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Private Tokens %p Keep these tokens secret, anyone with access to them can interact with GitLab as if they were you. - .col-lg-9.private-tokens-reset + .col-lg-8.private-tokens-reset = render partial: 'reset_token', locals: { label: 'Private token', button_label: 'Reset private token', help_text: 'Your private token is used to access the API and Atom feeds without username/password authentication.' } = render partial: 'reset_token', locals: { label: 'RSS token', button_label: 'Reset RSS token', help_text: 'Your RSS token is used to create urls for personalized RSS feeds.' } @@ -22,12 +23,12 @@ %hr .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Two-Factor Authentication %p Increase your account's security by enabling Two-Factor Authentication (2FA). - .col-lg-9 + .col-lg-8 %p Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'} - if current_user.two_factor_enabled? @@ -43,12 +44,12 @@ %hr - if button_based_providers.any? .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Social sign-in %p Activate signin with one of the following services - .col-lg-9 + .col-lg-8 %label.label-light Connected Accounts %p Click on icon to activate signin with one of the following services @@ -69,12 +70,12 @@ %hr - if current_user.can_change_username? .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0.warning-title Change username %p Changing your username will change path to all personal projects! - .col-lg-9 + .col-lg-8 = form_for @user, url: update_username_profile_path, method: :put, html: {class: "update-username"} do |f| .form-group = f.label :username, "Path", class: "label-light" @@ -93,10 +94,10 @@ - if signup_enabled? .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0.danger-title Remove account - .col-lg-9 + .col-lg-8 - if @user.can_be_removed? && can?(current_user, :destroy_user, @user) %p Deleting an account has the following effects: diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml index a24b7fd101d..1a392e29e2a 100644 --- a/app/views/profiles/audit_log.html.haml +++ b/app/views/profiles/audit_log.html.haml @@ -1,11 +1,12 @@ - page_title "Authentication log" +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h3.prepend-top-0 = page_title %p This is a security log of important events involving your account. - .col-lg-9 + .col-lg-8 = render 'event_table', events: @events diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml index 20cc636b2da..8f7121afe02 100644 --- a/app/views/profiles/chat_names/index.html.haml +++ b/app/views/profiles/chat_names/index.html.haml @@ -1,14 +1,15 @@ - page_title 'Chat' +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 = page_title %p You can see your Chat accounts. - .col-lg-9 + .col-lg-8 %h5 Active chat names (#{@chat_names.size}) - if @chat_names.present? diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index f5a323dbaf8..612ecbbb96a 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -1,13 +1,14 @@ - page_title "Emails" +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 = page_title %p Control emails linked to your account - .col-lg-9 + .col-lg-8 %h4.prepend-top-0 Add email address = form_for 'email', url: profile_emails_path do |f| diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 71b224a413b..5f7b41cf30e 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -1,13 +1,14 @@ - page_title "SSH Keys" +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 = page_title %p SSH keys allow you to establish a secure connection between your computer and GitLab. - .col-lg-9 + .col-lg-8 %h5.prepend-top-0 Add an SSH key %p.profile-settings-content diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml index 6283ceebf10..172c0450381 100644 --- a/app/views/profiles/keys/show.html.haml +++ b/app/views/profiles/keys/show.html.haml @@ -1,3 +1,4 @@ - page_title @key.title, "SSH Keys" +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' = render "key_details" diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 51c4e8e5a73..e98fdfc7a3d 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -1,4 +1,5 @@ - page_title "Notifications" +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' %div @@ -10,14 +11,14 @@ = hidden_field_tag :notification_type, 'global' .row - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4 = page_title %p You can specify notification level per group or per project. %p By default, all projects and groups will use the global notifications setting. - .col-lg-9 + .col-lg-8 %h5 Global notification settings diff --git a/app/views/profiles/passwords/edit.html.haml b/app/views/profiles/passwords/edit.html.haml index 243428b690e..985bb79508f 100644 --- a/app/views/profiles/passwords/edit.html.haml +++ b/app/views/profiles/passwords/edit.html.haml @@ -1,12 +1,13 @@ - page_title "Password" +- @content_class = "limit-container-width" unless fluid_layout .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 = page_title %p After a successful password update, you will be redirected to the login page where you can log in with your new password. - .col-lg-9 + .col-lg-8 %h5.prepend-top-0 Change your password - unless @user.password_automatically_set? diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index c852107e69a..cf750378e25 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -1,8 +1,9 @@ - page_title "Personal Access Tokens" +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' .row.prepend-top-default - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 = page_title %p @@ -11,7 +12,7 @@ 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 + .col-lg-8 - if flash[:personal_access_token] .created-personal-access-token-container diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 0b5995415e9..a089aeb2447 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -1,15 +1,16 @@ - page_title 'Preferences' +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' = form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f| - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Syntax highlighting theme %p This setting allows you to customize the appearance of the syntax. = succeed '.' do = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank' - .col-lg-9.syntax-theme + .col-lg-8.syntax-theme - Gitlab::ColorSchemes.each do |scheme| = label_tag do .preview= image_tag "#{scheme.css_class}-scheme-preview.png" @@ -36,14 +37,14 @@ New .col-sm-12 %hr - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Behavior %p This setting allows you to customize the behavior of the system layout and default views. = succeed '.' do = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank' - .col-lg-9 + .col-lg-8 .form-group = f.label :layout, class: 'label-light' do Layout width diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 819c98946ab..bac75a49075 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,10 +1,11 @@ +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' = bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default' }, authenticity_token: true do |f| = form_errors(@user) .row - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Public Avatar %p @@ -16,7 +17,7 @@ You can upload an avatar here - if gravatar_enabled? or change it at #{link_to Gitlab.config.gravatar.host, 'http://' + Gitlab.config.gravatar.host} - .col-lg-9 + .col-lg-8 .clearfix.avatar-image.append-bottom-default = link_to avatar_icon(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon(@user, 160), alt: '', class: 'avatar s160' @@ -34,14 +35,14 @@ = link_to 'Remove avatar', profile_avatar_path, data: { confirm: 'Avatar will be removed. Are you sure?' }, method: :delete, class: 'btn btn-gray' %hr .row - .col-lg-3.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 Main settings %p This information will appear on your profile. - if current_user.ldap_user? Some options are unavailable for LDAP accounts - .col-lg-9 + .col-lg-8 .row = f.text_field :name, required: true, wrapper: { class: 'col-md-9' }, help: 'Enter your name, so people you know can recognize you.' diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 0ff05098cd7..67792de3870 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -1,5 +1,6 @@ - page_title 'Two-Factor Authentication', 'Account' - header_title "Two-Factor Authentication", profile_two_factor_auth_path +- @content_class = "limit-container-width" unless fluid_layout = render 'profiles/head' - if inject_u2f_api? @@ -7,12 +8,12 @@ = page_specific_javascript_bundle_tag('u2f') .row.prepend-top-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0 Register Two-Factor Authentication App %p Use an app on your mobile device to enable two-factor authentication (2FA). - .col-lg-9 + .col-lg-8 - if current_user.two_factor_otp_enabled? = icon "check inverse", base: "circle", class: "text-success", text: "You've already enabled two-factor authentication using mobile authenticator applications. You can disable it from your account settings page." - else @@ -20,9 +21,9 @@ Download the Google Authenticator application from App Store or Google Play Store and scan this code. More information is available in the #{link_to('documentation', help_page_path('profile/two_factor_authentication'))}. .row.append-bottom-10 - .col-md-3 + .col-md-4 = raw @qr_code - .col-md-9 + .col-md-8 .account-well %p.prepend-top-0.append-bottom-0 Can't scan the code? @@ -50,7 +51,7 @@ .row.prepend-top-default - .col-lg-3 + .col-lg-4 %h4.prepend-top-0 Register Universal Two-Factor (U2F) Device %p @@ -59,7 +60,7 @@ As U2F devices are only supported by a few browsers, we require that you set up a 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 + .col-lg-8 - if @u2f_registration.errors.present? = form_errors(@u2f_registration) = render "u2f/register" diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 960b57a8008..aa1a533b5cb 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -16,7 +16,7 @@ - if merge_project %li - = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project) do + = link_to namespace_project_new_merge_request_path(merge_project.namespace, merge_project) do = icon('tasks fw') #{ _('New merge request') } diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 93fd0789c11..cf8dffc8957 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -8,7 +8,7 @@ %li.commits-row{ data: { day: day } } %ul.content-list.commit-list - = render commits, project: project, ref: ref + = render partial: 'projects/commits/commit', collection: commits, locals: { project: project, ref: ref } - if hidden > 0 %li.alert.alert-warning diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index fabd825aec8..7ed7e441344 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -8,27 +8,28 @@ = render "head" %div{ class: container_class } - .row-content-block.second-block.content-component-block.flex-container-block - .tree-ref-holder - = render 'shared/ref_switcher', destination: 'commits' + .tree-holder + .nav-block + .tree-ref-container + .tree-ref-holder + = render 'shared/ref_switcher', destination: 'commits' + + %ul.breadcrumb.repo-breadcrumb + = commits_breadcrumbs + .tree-controls.hidden-xs.hidden-sm + - if @merge_request.present? + .control + = link_to _("View open merge request"), namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn' + - elsif create_mr_button?(@repository.root_ref, @ref) + .control + = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' - %ul.breadcrumb.repo-breadcrumb - = commits_breadcrumbs - - .block-controls.hidden-xs.hidden-sm - - if @merge_request.present? .control - = link_to _("View open merge request"), namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn' - - elsif create_mr_button?(@repository.root_ref, @ref) + = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'commits-search-form') do + = search_field_tag :search, params[:search], { placeholder: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false } .control - = link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success' - - .control - = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'commits-search-form') do - = search_field_tag :search, params[:search], { placeholder: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false } - .control - = link_to namespace_project_commits_path(@project.namespace, @project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do - = icon("rss") + = link_to namespace_project_commits_path(@project.namespace, @project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do + = icon("rss") %div{ id: dom_id(@project) } %ol#commits-list.list-unstyled.content_list diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index d538c4c86c8..f9385459a66 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -10,7 +10,7 @@ - if show_whitespace_toggle - if current_controller?(:commit) = commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs') - - elsif current_controller?(:merge_requests) + - elsif current_controller?('projects/merge_requests/diffs') = diff_merge_request_whitespace_link(diffs.project, @merge_request, class: 'hidden-xs') - elsif current_controller?(:compare) = diff_compare_whitespace_link(diffs.project, params[:from], params[:to], class: 'hidden-xs') diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml index 295a1b62535..402c18c447e 100644 --- a/app/views/projects/diffs/_warning.html.haml +++ b/app/views/projects/diffs/_warning.html.haml @@ -2,13 +2,12 @@ %h4 Too many changes to show. .pull-right - - if current_controller?(:commit) or current_controller?(:merge_requests) - - if current_controller?(:commit) - = link_to "Plain diff", namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff), class: "btn btn-sm" - = link_to "Email patch", namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch), class: "btn btn-sm" - - elsif @merge_request && @merge_request.persisted? - = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-sm" - = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-sm" + - if current_controller?(:commit) + = link_to "Plain diff", namespace_project_commit_path(@project.namespace, @project, @commit, format: :diff), class: "btn btn-sm" + = link_to "Email patch", namespace_project_commit_path(@project.namespace, @project, @commit, format: :patch), class: "btn btn-sm" + - elsif current_controller?('projects/merge_requests/diffs') && @merge_request&.persisted? + = link_to "Plain diff", merge_request_path(@merge_request, format: :diff), class: "btn btn-sm" + = link_to "Email patch", merge_request_path(@merge_request, format: :patch), class: "btn btn-sm" %p To preserve performance only %strong #{diff_files.size} of #{diff_files.real_size} diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index e8f8fbbcf09..c5722cf5997 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -1,11 +1,12 @@ - @no_container = true - page_title "Metrics for environment", @environment.name - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_d3') - = page_specific_javascript_bundle_tag('monitoring') + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'common_d3' + = webpack_bundle_tag 'monitoring' = render "projects/pipelines/head" -#js-metrics.prometheus-container{ class: container_class, data: { has_metrics: "#{@environment.has_metrics?}", deployment_endpoint: namespace_project_environment_deployments_path(@project.namespace, @project, @environment, format: :json) } } +.prometheus-container{ class: container_class } .top-area .row .col-sm-6 @@ -13,68 +14,8 @@ Environment: = link_to @environment.name, environment_path(@environment) - .prometheus-state - .js-getting-started.hidden - .row - .col-md-4.col-md-offset-4.state-svg - = render "shared/empty_states/monitoring/getting_started.svg" - .row - .col-md-6.col-md-offset-3 - %h4.text-center.state-title - Get started with performance monitoring - .row - .col-md-6.col-md-offset-3 - .description-text.text-center.state-description - Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments. - = link_to help_page_path('administration/monitoring/prometheus/index.md') do - Learn more about performance monitoring - .row.state-button-section - .col-md-4.col-md-offset-4.text-center.state-button - = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus'), class: 'btn btn-success' do - Configure Prometheus - .js-loading.hidden - .row - .col-md-4.col-md-offset-4.state-svg - = render "shared/empty_states/monitoring/loading.svg" - .row - .col-md-6.col-md-offset-3 - %h4.text-center.state-title - Waiting for performance data - .row - .col-md-6.col-md-offset-3 - .description-text.text-center.state-description - Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available. - .row.state-button-section - .col-md-4.col-md-offset-4.text-center.state-button - = link_to help_page_path('administration/monitoring/prometheus/index.md'), class: 'btn btn-success' do - View documentation - .js-unable-to-connect.hidden - .row - .col-md-4.col-md-offset-4.state-svg - = render "shared/empty_states/monitoring/unable_to_connect.svg" - .row - .col-md-6.col-md-offset-3 - %h4.text-center.state-title - Unable to connect to Prometheus server - .row - .col-md-6.col-md-offset-3 - .description-text.text-center.state-description - Ensure connectivity is available from the GitLab server to the - = link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus') do - Prometheus server - .row.state-button-section - .col-md-4.col-md-offset-4.text-center.state-button - = link_to help_page_path('administration/monitoring/prometheus/index.md'), class:'btn btn-success' do - View documentation + #prometheus-graphs{ data: { "settings-path": edit_namespace_project_service_path(@project.namespace, @project, 'prometheus'), + "documentation-path": help_page_path('administration/monitoring/prometheus/index.md'), + "additional-metrics": additional_metrics_namespace_project_environment_path(@project.namespace, @project, @environment, format: :json), + "has-metrics": "#{@environment.has_metrics?}", deployment_endpoint: namespace_project_environment_deployments_path(@project.namespace, @project, @environment, format: :json) } } - .prometheus-graphs - .row - .col-sm-12 - %h4 - CPU utilization - %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } - .row - .col-sm-12 - %h4 - Memory usage - %svg.prometheus-graph{ 'graph-type' => 'memory_values' } diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 9e4e6934ca9..6a0d96f50cd 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -4,43 +4,49 @@ .issue-check.hidden = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected_issue" .issue-info-container - .issue-title.title - %span.issue-title-text - = confidential_icon(issue) - = link_to issue.title, issue_path(issue) + .issue-main-info + .issue-title.title + %span.issue-title-text + = confidential_icon(issue) + = link_to issue.title, issue_path(issue) + - if issue.tasks? + %span.task-status.hidden-xs + + = issue.task_status + + .issuable-info + %span.issuable-reference + #{issuable_reference(issue)} + %span.issuable-authored.hidden-xs + · + opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} + by #{link_to_member(@project, issue.author, avatar: false)} + - if issue.milestone + %span.issuable-milestone.hidden-xs + + = link_to namespace_project_issues_path(issue.project.namespace, issue.project, milestone_title: issue.milestone.title) do + = icon('clock-o') + = issue.milestone.title + - if issue.due_date + %span.issuable-due-date.hidden-xs{ class: "#{'cred' if issue.overdue?}" } + + = icon('calendar') + = issue.due_date.to_s(:medium) + - if issue.labels.any? + + - issue.labels.each do |label| + = link_to_label(label, subject: issue.project, css_class: 'label-link') + + .issuable-meta %ul.controls - if issue.closed? - %li + %li.issuable-status CLOSED - - if issue.assignees.any? %li = render 'shared/issuable/assignees', project: @project, issue: issue = render 'shared/issuable_meta_data', issuable: issue - .issue-info - #{issuable_reference(issue)} · - opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} - by #{link_to_member(@project, issue.author, avatar: false)} - - if issue.milestone - - = link_to namespace_project_issues_path(issue.project.namespace, issue.project, milestone_title: issue.milestone.title) do - = icon('clock-o') - = issue.milestone.title - - if issue.due_date - %span{ class: "#{'cred' if issue.overdue?}" } - - = icon('calendar') - = issue.due_date.to_s(:medium) - - if issue.labels.any? - - - issue.labels.each do |label| - = link_to_label(label, subject: issue.project, css_class: 'label-link') - - if issue.tasks? - - %span.task-status - = issue.task_status - - .pull-right.issue-updated-at + .pull-right.issuable-updated-at.hidden-xs %span updated #{time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago')} diff --git a/app/views/projects/merge_requests/show/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml index 11793919ff7..11793919ff7 100644 --- a/app/views/projects/merge_requests/show/_commits.html.haml +++ b/app/views/projects/merge_requests/_commits.html.haml diff --git a/app/views/projects/merge_requests/show/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml index 766cb272bec..766cb272bec 100644 --- a/app/views/projects/merge_requests/show/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/_how_to_merge.html.haml diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index c13110deb16..3599f2271b5 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -4,58 +4,60 @@ = check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue" .issue-info-container - .merge-request-title.title - %span.merge-request-title-text - = link_to merge_request.title, merge_request_path(merge_request) + .issue-main-info + .merge-request-title.title + %span.merge-request-title-text + = link_to merge_request.title, merge_request_path(merge_request) + - if merge_request.tasks? + %span.task-status.hidden-xs + + = merge_request.task_status + + .issuable-info + %span.issuable-reference + #{issuable_reference(merge_request)} + %span.issuable-authored.hidden-xs + · + opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')} + by #{link_to_member(@project, merge_request.author, avatar: false)} + - if merge_request.milestone + %span.issuable-milestone.hidden-xs + + = link_to namespace_project_merge_requests_path(merge_request.project.namespace, merge_request.project, milestone_title: merge_request.milestone.title) do + = icon('clock-o') + = merge_request.milestone.title + - if merge_request.target_project.default_branch != merge_request.target_branch + %span.project-ref-path + + = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do + = icon('code-fork') + = merge_request.target_branch + - if merge_request.labels.any? + + - merge_request.labels.each do |label| + = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link') + + .issuable-meta %ul.controls - if merge_request.merged? - %li + %li.issuable-status.hidden-xs MERGED - elsif merge_request.closed? - %li + %li.issuable-status.hidden-xs = icon('ban') CLOSED - - if merge_request.head_pipeline - %li + %li.issuable-pipeline-status.hidden-xs = render_pipeline_status(merge_request.head_pipeline) - - if merge_request.open? && merge_request.broken? - %li + %li.issuable-pipeline-broken.hidden-xs = link_to merge_request_path(merge_request), class: "has-tooltip", title: "Cannot be merged automatically", data: { container: 'body' } do = icon('exclamation-triangle') - - if merge_request.assignee %li = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: "Assigned to :name") = render 'shared/issuable_meta_data', issuable: merge_request - .merge-request-info - #{issuable_reference(merge_request)} · - opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')} - by #{link_to_member(@project, merge_request.author, avatar: false)} - - if merge_request.target_project.default_branch != merge_request.target_branch - - = link_to project_ref_path(merge_request.project, merge_request.target_branch), class: 'ref-name' do - = icon('code-fork') - = merge_request.target_branch - - - if merge_request.milestone - - = link_to namespace_project_merge_requests_path(merge_request.project.namespace, merge_request.project, milestone_title: merge_request.milestone.title) do - = icon('clock-o') - = merge_request.milestone.title - - - if merge_request.labels.any? - - - merge_request.labels.each do |label| - = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link') - - - if merge_request.tasks? - - %span.task-status - = merge_request.task_status - - .pull-right.hidden-xs + .pull-right.issuable-updated-at.hidden-xs %span updated #{time_ago_with_tooltip(merge_request.updated_at, placement: 'bottom', html_class: 'merge_request_updated_ago')} diff --git a/app/views/projects/merge_requests/show/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml index 8a390cf8700..8a390cf8700 100644 --- a/app/views/projects/merge_requests/show/_mr_box.html.haml +++ b/app/views/projects/merge_requests/_mr_box.html.haml diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index d9428b8562e..d9428b8562e 100644 --- a/app/views/projects/merge_requests/show/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/_pipelines.html.haml index 2f1dbe87619..2f1dbe87619 100644 --- a/app/views/projects/merge_requests/show/_pipelines.html.haml +++ b/app/views/projects/merge_requests/_pipelines.html.haml diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml deleted file mode 100644 index 75120409bb3..00000000000 --- a/app/views/projects/merge_requests/_show.html.haml +++ /dev/null @@ -1,97 +0,0 @@ -- @content_class = "limit-container-width" unless fluid_layout -- 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_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('diff_notes') - -.merge-request{ 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) } - = render "projects/merge_requests/show/mr_title" - - .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } - = render "projects/merge_requests/show/mr_box" - - - if @merge_request.source_branch_exists? - = render "projects/merge_requests/show/how_to_merge" - - :javascript - window.gl.mrWidgetData = #{serialize_issuable(@merge_request)} - - #js-vue-mr-widget.mr-widget - - - content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = webpack_bundle_tag 'vue_merge_request_widget' - - .content-block.content-block-small.emoji-list-container - = render 'award_emoji/awards_block', awardable: @merge_request, inline: true - - .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } - .merge-request-tabs-container - .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller - .fade-left= icon('angle-left') - .fade-right= icon('angle-right') - .nav-links.scrolling-tabs - %ul.merge-request-tabs - %li.notes-tab - = 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.related_notes.user.count - - if @merge_request.source_project - %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 - Commits - %span.badge= @commits_count - - if @pipelines.any? - %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= @pipelines.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 - Changes - %span.badge= @merge_request.diff_size - #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } - %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } - %div - .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 }} {{ resolvedCountText }} resolved - = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request - = render "discussions/jump_to_next" - - .tab-content#diff-notes-app - #notes.notes.tab-pane.voting_notes - .row - %section.col-md-12 - .issuable-discussion - = render "projects/merge_requests/discussion" - - #commits.commits.tab-pane - -# This tab is always loaded via AJAX - #pipelines.pipelines.tab-pane - - if @pipelines.any? - = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) - #diffs.diffs.tab-pane - -# This tab is always loaded via AJAX - - .mr-loading-status - = spinner - -= render 'shared/issuable/sidebar', issuable: @merge_request -- if @merge_request.can_be_reverted?(current_user) - = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title -- if @merge_request.can_be_cherry_picked? - = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title - -:javascript - $(function () { - window.mergeRequest = new MergeRequest({ - action: "#{controller.action_name}" - }); - }); diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml index 51d59280be8..f016b9c13b3 100644 --- a/app/views/projects/merge_requests/conflicts.html.haml +++ b/app/views/projects/merge_requests/conflicts.html.haml @@ -3,10 +3,10 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('merge_conflicts') = page_specific_javascript_tag('lib/ace.js') -= render "projects/merge_requests/show/mr_title" += render "projects/merge_requests/mr_title" .merge-request-details.issuable-details - = render "projects/merge_requests/show/mr_box" + = render "projects/merge_requests/mr_box" = render 'shared/issuable/sidebar', issuable: @merge_request diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml new file mode 100644 index 00000000000..f016b9c13b3 --- /dev/null +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -0,0 +1,38 @@ +- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('merge_conflicts') + = page_specific_javascript_tag('lib/ace.js') += render "projects/merge_requests/mr_title" + +.merge-request-details.issuable-details + = render "projects/merge_requests/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" } + .files + .diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" } + .js-file-title.file-title + %i.fa.fa-fw{ ":class" => "file.iconClass" } + %strong {{file.filePath}} + = render partial: 'projects/merge_requests/conflicts/file_actions' + .diff-content.diff-wrap-lines + .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } + = render partial: "projects/merge_requests/conflicts/components/inline_conflict_lines" + .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } + %parallel-conflict-lines{ ":file" => "file" } + %div{ "v-show" => "file.resolveMode === 'edit' || file.type === 'text-editor'" } + = render partial: "projects/merge_requests/conflicts/components/diff_file_editor" + + = render partial: "projects/merge_requests/conflicts/submit_form" diff --git a/app/views/projects/merge_requests/_new_diffs.html.haml b/app/views/projects/merge_requests/creations/_diffs.html.haml index 627fc4e9671..627fc4e9671 100644 --- a/app/views/projects/merge_requests/_new_diffs.html.haml +++ b/app/views/projects/merge_requests/creations/_diffs.html.haml diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 0f37abb579c..7cda326afef 100644 --- a/app/views/projects/merge_requests/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -1,7 +1,7 @@ %h3.page-title New Merge Request -= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: new_namespace_project_merge_request_path(@project.namespace, @project), method: :get, html: { class: "merge-request-form form-inline js-requires-input" } do |f| += form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: namespace_project_new_merge_request_path(@project.namespace, @project), method: :get, html: { class: "merge-request-form form-inline js-requires-input" } do |f| .hide.alert.alert-danger.mr-compare-errors .merge-request-branches.row .col-md-6 @@ -69,7 +69,7 @@ :javascript new Compare({ - targetProjectUrl: "#{update_branches_namespace_project_merge_requests_path(@source_project.namespace, @source_project)}", - sourceBranchUrl: "#{branch_from_namespace_project_merge_requests_path(@source_project.namespace, @source_project)}", - targetBranchUrl: "#{branch_to_namespace_project_merge_requests_path(@source_project.namespace, @source_project)}" + targetProjectUrl: "#{namespace_project_new_merge_request_update_branches_path(@source_project.namespace, @source_project)}", + sourceBranchUrl: "#{namespace_project_new_merge_request_branch_from_path(@source_project.namespace, @source_project)}", + targetBranchUrl: "#{namespace_project_new_merge_request_branch_to_path(@source_project.namespace, @source_project)}" }); diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index e3ecbee5490..c72dd1d8e29 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -31,28 +31,27 @@ %span.badge= @commits.size - if @pipelines.any? %li.builds-tab - = link_to url_for(params), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do + = link_to url_for(params.merge(action: 'pipelines')), data: {target: 'div#pipelines', action: 'pipelines', toggle: 'tab'} do Pipelines %span.badge= @pipelines.size %li.diffs-tab - = link_to url_for(params.merge(action: 'new_diffs')), data: {target: 'div#diffs', action: 'new/diffs', toggle: 'tab'} do + = link_to url_for(params.merge(action: 'diffs')), data: {target: 'div#diffs', action: 'diffs', toggle: 'tab'} do Changes %span.badge= @merge_request.diff_size .tab-content #commits.commits.tab-pane.active - = render "projects/merge_requests/show/commits" + = render "projects/merge_requests/commits" #diffs.diffs.tab-pane -# This tab is always loaded via AJAX - if @pipelines.any? #pipelines.pipelines.tab-pane - = render 'projects/merge_requests/show/pipelines', endpoint: url_for(params.merge(format: :json)), disable_initialization: true + = render 'projects/merge_requests/pipelines', endpoint: url_for(params.merge(action: 'pipelines', format: :json)), disable_initialization: true .mr-loading-status = spinner :javascript var merge_request = new MergeRequest({ - action: "#{(@show_changes_tab ? 'new/diffs' : 'new')}", - setUrl: false, + action: "#{j params[:tab].presence || 'new'}", }); diff --git a/app/views/projects/merge_requests/branch_from.html.haml b/app/views/projects/merge_requests/creations/branch_from.html.haml index 3837c4b388d..3837c4b388d 100644 --- a/app/views/projects/merge_requests/branch_from.html.haml +++ b/app/views/projects/merge_requests/creations/branch_from.html.haml diff --git a/app/views/projects/merge_requests/branch_to.html.haml b/app/views/projects/merge_requests/creations/branch_to.html.haml index d69b71790a0..d69b71790a0 100644 --- a/app/views/projects/merge_requests/branch_to.html.haml +++ b/app/views/projects/merge_requests/creations/branch_to.html.haml diff --git a/app/views/projects/merge_requests/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml index 2e798ce780a..2e798ce780a 100644 --- a/app/views/projects/merge_requests/new.html.haml +++ b/app/views/projects/merge_requests/creations/new.html.haml diff --git a/app/views/projects/merge_requests/update_branches.html.haml b/app/views/projects/merge_requests/creations/update_branches.html.haml index 64482973a89..64482973a89 100644 --- a/app/views/projects/merge_requests/update_branches.html.haml +++ b/app/views/projects/merge_requests/creations/update_branches.html.haml diff --git a/app/views/projects/merge_requests/diffs.html.haml b/app/views/projects/merge_requests/diffs.html.haml deleted file mode 100644 index 2a5b8b1441e..00000000000 --- a/app/views/projects/merge_requests/diffs.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render "show" diff --git a/app/views/projects/merge_requests/show/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml index 7f0913ea516..fb31e2fef00 100644 --- a/app/views/projects/merge_requests/show/_diffs.html.haml +++ b/app/views/projects/merge_requests/diffs/_diffs.html.haml @@ -1,5 +1,5 @@ - if @merge_request_diff.collected? || @merge_request_diff.overflow? - = render 'projects/merge_requests/show/versions' + = render 'projects/merge_requests/diffs/versions' = render "projects/diffs/diffs", diffs: @diffs, environment: @environment - elsif @merge_request_diff.empty? .nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch} diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/diffs/_versions.html.haml index 0999b95c9c9..0999b95c9c9 100644 --- a/app/views/projects/merge_requests/show/_versions.html.haml +++ b/app/views/projects/merge_requests/diffs/_versions.html.haml diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 6d75a9f34a3..86996e488a1 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -22,7 +22,7 @@ = button_tag "Edit Merge Requests", class: "btn js-bulk-update-toggle" - merge_project = can?(current_user, :create_merge_request, @project) ? @project : (current_user && current_user.fork_of(@project)) - if merge_project - = link_to new_namespace_project_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New merge request" do + = link_to namespace_project_new_merge_request_path(merge_project.namespace, merge_project), class: "btn btn-new", title: "New merge request" do New merge request = render 'shared/issuable/search_bar', type: :merge_requests @@ -33,4 +33,4 @@ .merge-requests-holder = render 'merge_requests' - else - = render 'shared/empty_states/merge_requests', button_path: new_namespace_project_merge_request_path(@project.namespace, @project) + = render 'shared/empty_states/merge_requests', button_path: namespace_project_new_merge_request_path(@project.namespace, @project) diff --git a/app/views/projects/merge_requests/invalid.html.haml b/app/views/projects/merge_requests/invalid.html.haml index a00d3128ffe..6df19d6438b 100644 --- a/app/views/projects/merge_requests/invalid.html.haml +++ b/app/views/projects/merge_requests/invalid.html.haml @@ -1,8 +1,8 @@ - page_title "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" .merge-request - = render "projects/merge_requests/show/mr_title" - = render "projects/merge_requests/show/mr_box" + = render "projects/merge_requests/mr_title" + = render "projects/merge_requests/mr_box" .alert.alert-danger %p diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 2a5b8b1441e..dbbf1bde088 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -1 +1,97 @@ -= render "show" +- @content_class = "limit-container-width" unless fluid_layout +- 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_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('diff_notes') + +.merge-request{ 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) } + = render "projects/merge_requests/mr_title" + + .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } + = render "projects/merge_requests/mr_box" + + - if @merge_request.source_branch_exists? + = render "projects/merge_requests/how_to_merge" + + :javascript + window.gl.mrWidgetData = #{serialize_issuable(@merge_request)} + + #js-vue-mr-widget.mr-widget + + - content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'vue_merge_request_widget' + + .content-block.content-block-small.emoji-list-container + = render 'award_emoji/awards_block', awardable: @merge_request, inline: true + + .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } + .merge-request-tabs-container + .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller + .fade-left= icon('angle-left') + .fade-right= icon('angle-right') + .nav-links.scrolling-tabs + %ul.merge-request-tabs + %li.notes-tab + = link_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), data: { target: 'div#notes', action: 'show', toggle: 'tab' } do + Discussion + %span.badge= @merge_request.related_notes.user.count + - if @merge_request.source_project + %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 + Commits + %span.badge= @commits_count + - if @pipelines.any? + %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= @pipelines.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 + Changes + %span.badge= @merge_request.diff_size + #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } + %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } + %div + .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 }} {{ resolvedCountText }} resolved + = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request + = render "discussions/jump_to_next" + + .tab-content#diff-notes-app + #notes.notes.tab-pane.voting_notes + .row + %section.col-md-12 + .issuable-discussion + = render "projects/merge_requests/discussion" + + #commits.commits.tab-pane + -# This tab is always loaded via AJAX + #pipelines.pipelines.tab-pane + - if @pipelines.any? + = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + #diffs.diffs.tab-pane + -# This tab is always loaded via AJAX + + .mr-loading-status + = spinner + += render 'shared/issuable/sidebar', issuable: @merge_request +- if @merge_request.can_be_reverted?(current_user) + = render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title +- if @merge_request.can_be_cherry_picked? + = render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit, title: @merge_request.title + +:javascript + $(function () { + window.mergeRequest = new MergeRequest({ + action: "#{j params[:tab].presence || 'show'}", + }); + }); diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 7447197ed89..e1e70a53709 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -21,7 +21,7 @@ %li = link_to namespace_project_commits_path(@project.namespace, @project, current_ref) do #{n_('Commit', 'Commits', @project.statistics.commit_count)} (#{number_with_delimiter(@project.statistics.commit_count)}) - %l + %li = link_to namespace_project_branches_path(@project.namespace, @project) do #{n_('Branch', 'Branches', @repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)}) %li diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index 1d4fd71522d..435acbc634c 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -5,21 +5,21 @@ - issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count - if issuable_mr > 0 - %li + %li.issuable-mr.hidden-xs = image_tag('icon-merge-request-unmerged.svg', class: 'icon-merge-request-unmerged') = issuable_mr - if upvotes > 0 - %li + %li.issuable-upvotes.hidden-xs = icon('thumbs-up') = upvotes - if downvotes > 0 - %li + %li.issuable-downvotes.hidden-xs = icon('thumbs-down') = downvotes -%li +%li.issuable-comments.hidden-xs = link_to issuable_url, class: ('no-comments' if note_count.zero?) do = icon('comments') = note_count diff --git a/app/views/shared/_sort_dropdown.html.haml b/app/views/shared/_sort_dropdown.html.haml index a212c714826..785a500e44e 100644 --- a/app/views/shared/_sort_dropdown.html.haml +++ b/app/views/shared/_sort_dropdown.html.haml @@ -1,3 +1,5 @@ +- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues' + .dropdown.inline.prepend-left-10 %button.dropdown-toggle{ type: 'button', data: {toggle: 'dropdown' } } - if @sort.present? @@ -23,7 +25,7 @@ = sort_title_milestone_soon = link_to page_filter_path(sort: sort_value_milestone_later, label: true) do = sort_title_milestone_later - - if controller.controller_name == 'issues' || controller.action_name == 'issues' + - if viewing_issues = link_to page_filter_path(sort: sort_value_due_date_soon, label: true) do = sort_title_due_date_soon = link_to page_filter_path(sort: sort_value_due_date_later, label: true) do diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index aaffc0927eb..7ed6c622558 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -13,7 +13,7 @@ - if projects.any? %ul.projects-list - projects.each_with_index do |project, i| - - css_class = (i >= projects_limit) ? 'hide' : nil + - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil = render "shared/projects/project", project: project, skip_namespace: skip_namespace, avatar: avatar, stars: stars, css_class: css_class, ci: ci, use_creator_avatar: use_creator_avatar, forks: forks, show_last_commit_as_description: show_last_commit_as_description diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index d760f5b140f..92e622285de 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -46,7 +46,7 @@ class ExpirePipelineCacheWorker end def new_merge_request_pipelines_path(project) - Gitlab::Routing.url_helpers.new_namespace_project_merge_request_path( + Gitlab::Routing.url_helpers.namespace_project_new_merge_request_path( project.namespace, project, format: :json) diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 89286595ca6..b8f8d3750d9 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -2,11 +2,11 @@ class PostReceive include Sidekiq::Worker include DedicatedSidekiqQueue - def perform(project_identifier, identifier, changes) - project, is_wiki = parse_project_identifier(project_identifier) + def perform(gl_repository, identifier, changes) + project, is_wiki = Gitlab::GlRepository.parse(gl_repository) if project.nil? - log("Triggered hook for non-existing project with identifier \"#{project_identifier}\"") + log("Triggered hook for non-existing project with gl_repository \"#{gl_repository}\"") return false end @@ -59,21 +59,6 @@ class PostReceive # Nothing defined here yet. end - # To maintain backwards compatibility, we accept both gl_repository or - # repository paths as project identifiers. Our plan is to migrate to - # gl_repository only with the following plan: - # 9.2: Handle both possible values. Keep Gitlab-Shell sending only repo paths - # 9.3 (or patch release): Make GitLab Shell pass gl_repository if present - # 9.4 (or patch release): Make GitLab Shell always pass gl_repository - # 9.5 (or patch release): Handle only gl_repository as project identifier on this method - def parse_project_identifier(project_identifier) - if project_identifier.start_with?('/') - Gitlab::RepoPath.parse(project_identifier) - else - Gitlab::GlRepository.parse(project_identifier) - end - end - def log(message) Gitlab::GitLogger.error("POST-RECEIVE: #{message}") end diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index ae8c980c9e4..8b0cfcc8af8 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -45,7 +45,7 @@ class StuckCiJobsWorker def search(status, timeout) builds = Ci::Build.where(status: status).where('ci_builds.updated_at < ?', timeout.ago) - builds.joins(:project).includes(:tags, :runner, project: :namespace).find_each(batch_size: 50).each do |build| + builds.joins(:project).merge(Project.without_deleted).includes(:tags, :runner, project: :namespace).find_each(batch_size: 50).each do |build| yield(build) end end diff --git a/changelogs/unreleased/26125-match-username-on-search.yml b/changelogs/unreleased/26125-match-username-on-search.yml new file mode 100644 index 00000000000..74e918bec16 --- /dev/null +++ b/changelogs/unreleased/26125-match-username-on-search.yml @@ -0,0 +1,5 @@ +--- +title: Inserts exact matches of name, username and email to the top of the search + list +merge_request: 12525 +author: diff --git a/changelogs/unreleased/30708-stop-using-deleted-at-to-filter-namespaces.yml b/changelogs/unreleased/30708-stop-using-deleted-at-to-filter-namespaces.yml new file mode 100644 index 00000000000..83ce3fb4d0a --- /dev/null +++ b/changelogs/unreleased/30708-stop-using-deleted-at-to-filter-namespaces.yml @@ -0,0 +1,4 @@ +--- +title: Removes deleted_at and pending_delete occurrences in Project related queries +merge_request: 12091 +author: diff --git a/changelogs/unreleased/33082-use-update_pipeline_schedule-for-edit-and-take_ownership-in-pipelineschedulescontroller.yml b/changelogs/unreleased/33082-use-update_pipeline_schedule-for-edit-and-take_ownership-in-pipelineschedulescontroller.yml new file mode 100644 index 00000000000..d3172c405c3 --- /dev/null +++ b/changelogs/unreleased/33082-use-update_pipeline_schedule-for-edit-and-take_ownership-in-pipelineschedulescontroller.yml @@ -0,0 +1,4 @@ +--- +title: Use authorize_update_pipeline_schedule in PipelineSchedulesController +merge_request: 11846 +author: diff --git a/changelogs/unreleased/33443-supplement_traditional_chinese_in_taiwan_translation_of_i18n.yml b/changelogs/unreleased/33443-supplement_traditional_chinese_in_taiwan_translation_of_i18n.yml new file mode 100644 index 00000000000..d6b1b2524c6 --- /dev/null +++ b/changelogs/unreleased/33443-supplement_traditional_chinese_in_taiwan_translation_of_i18n.yml @@ -0,0 +1,4 @@ +--- +title: Supplement Traditional Chinese in Taiwan translation of Project Page & Repository Page +merge_request: 12514 +author: Huang Tao diff --git a/changelogs/unreleased/34078-allow-to-enable-feature-flags-with-more-granularity.yml b/changelogs/unreleased/34078-allow-to-enable-feature-flags-with-more-granularity.yml new file mode 100644 index 00000000000..69d5d34b072 --- /dev/null +++ b/changelogs/unreleased/34078-allow-to-enable-feature-flags-with-more-granularity.yml @@ -0,0 +1,4 @@ +--- +title: Allow the feature flags to be enabled/disabled with more granularity +merge_request: 12357 +author: diff --git a/changelogs/unreleased/34403-issue-dropdown-persists-when-adding-issue-number-to-issue-description.yml b/changelogs/unreleased/34403-issue-dropdown-persists-when-adding-issue-number-to-issue-description.yml new file mode 100644 index 00000000000..4911315d018 --- /dev/null +++ b/changelogs/unreleased/34403-issue-dropdown-persists-when-adding-issue-number-to-issue-description.yml @@ -0,0 +1,4 @@ +--- +title: Closes any open Autocomplete of the markdown editor when the form is closed +merge_request: 12521 +author: diff --git a/changelogs/unreleased/add-group-members-counting-and-plan-related-data-on-namespaces-api.yml b/changelogs/unreleased/add-group-members-counting-and-plan-related-data-on-namespaces-api.yml new file mode 100644 index 00000000000..f2591042e98 --- /dev/null +++ b/changelogs/unreleased/add-group-members-counting-and-plan-related-data-on-namespaces-api.yml @@ -0,0 +1,4 @@ +--- +title: Add group members counting and plan related data on namespaces API +merge_request: +author: diff --git a/changelogs/unreleased/bvl-rename-all-reserved-paths.yml b/changelogs/unreleased/bvl-rename-all-reserved-paths.yml new file mode 100644 index 00000000000..f37f2fa94ae --- /dev/null +++ b/changelogs/unreleased/bvl-rename-all-reserved-paths.yml @@ -0,0 +1,4 @@ +--- +title: Rename all reserved paths that could have been created +merge_request: 11713 +author: diff --git a/changelogs/unreleased/dm-dependency-linker-newlines.yml b/changelogs/unreleased/dm-dependency-linker-newlines.yml new file mode 100644 index 00000000000..5631095fcb7 --- /dev/null +++ b/changelogs/unreleased/dm-dependency-linker-newlines.yml @@ -0,0 +1,5 @@ +--- +title: Fix diff of requirements.txt file by not matching newlines as part of package + names +merge_request: +author: diff --git a/changelogs/unreleased/dm-page-image-size.yml b/changelogs/unreleased/dm-page-image-size.yml new file mode 100644 index 00000000000..b18c00470fc --- /dev/null +++ b/changelogs/unreleased/dm-page-image-size.yml @@ -0,0 +1,4 @@ +--- +title: Limit OpenGraph image size to 64x64 +merge_request: +author: diff --git a/changelogs/unreleased/dm-relative-submodule-url-trailing-whitespace.yml b/changelogs/unreleased/dm-relative-submodule-url-trailing-whitespace.yml new file mode 100644 index 00000000000..616241dd941 --- /dev/null +++ b/changelogs/unreleased/dm-relative-submodule-url-trailing-whitespace.yml @@ -0,0 +1,4 @@ +--- +title: Strip trailing whitespace in relative submodule URL +merge_request: +author: diff --git a/changelogs/unreleased/fix-34417.yml b/changelogs/unreleased/fix-34417.yml new file mode 100644 index 00000000000..5f012ad0c81 --- /dev/null +++ b/changelogs/unreleased/fix-34417.yml @@ -0,0 +1,4 @@ +--- +title: Perform housekeeping only when an import of a fresh project is completed +merge_request: +author: diff --git a/changelogs/unreleased/hb-fix-abuse-report-on-stale-user-profile.yml b/changelogs/unreleased/hb-fix-abuse-report-on-stale-user-profile.yml new file mode 100644 index 00000000000..ec2f4f9c3d8 --- /dev/null +++ b/changelogs/unreleased/hb-fix-abuse-report-on-stale-user-profile.yml @@ -0,0 +1,4 @@ +--- +title: Fix errors caused by attempts to report already blocked or deleted users +merge_request: 12502 +author: Horacio Bertorello diff --git a/changelogs/unreleased/highest-return-on-diff-investment.yml b/changelogs/unreleased/highest-return-on-diff-investment.yml new file mode 100644 index 00000000000..c8be1e0ff8f --- /dev/null +++ b/changelogs/unreleased/highest-return-on-diff-investment.yml @@ -0,0 +1,4 @@ +--- +title: Bring back branches badge to main project page +merge_request: 12548 +author: diff --git a/changelogs/unreleased/issue-boards-closed-list-all.yml b/changelogs/unreleased/issue-boards-closed-list-all.yml new file mode 100644 index 00000000000..7643864150d --- /dev/null +++ b/changelogs/unreleased/issue-boards-closed-list-all.yml @@ -0,0 +1,4 @@ +--- +title: Fixed issue boards closed list not showing all closed issues +merge_request: +author: diff --git a/changelogs/unreleased/issueable-list-cleanup.yml b/changelogs/unreleased/issueable-list-cleanup.yml new file mode 100644 index 00000000000..d3d67d04574 --- /dev/null +++ b/changelogs/unreleased/issueable-list-cleanup.yml @@ -0,0 +1,4 @@ +--- +title: Clean up UI of issuable lists and make more responsive +merge_request: +author: diff --git a/changelogs/unreleased/sh-fix-project-destroy-in-namespace.yml b/changelogs/unreleased/sh-fix-project-destroy-in-namespace.yml new file mode 100644 index 00000000000..9309f961345 --- /dev/null +++ b/changelogs/unreleased/sh-fix-project-destroy-in-namespace.yml @@ -0,0 +1,4 @@ +--- +title: Defer project destroys within a namespace in Groups::DestroyService#async_execute +merge_request: +author: diff --git a/changelogs/unreleased/stop-notification-recipient-service-modifying-participants.yml b/changelogs/unreleased/stop-notification-recipient-service-modifying-participants.yml new file mode 100644 index 00000000000..7e66ea4ca8b --- /dev/null +++ b/changelogs/unreleased/stop-notification-recipient-service-modifying-participants.yml @@ -0,0 +1,5 @@ +--- +title: Ensure participants for issues, merge requests, etc. are calculated correctly + when sending notifications +merge_request: +author: diff --git a/changelogs/unreleased/zj-usage-ping-only-gl-pipelines.yml b/changelogs/unreleased/zj-usage-ping-only-gl-pipelines.yml new file mode 100644 index 00000000000..0ace7b99657 --- /dev/null +++ b/changelogs/unreleased/zj-usage-ping-only-gl-pipelines.yml @@ -0,0 +1,4 @@ +--- +title: Split pipelines as internal and external in the usage data +merge_request: 12277 +author: diff --git a/config/application.rb b/config/application.rb index 12242c3b0f5..3f39170a123 100644 --- a/config/application.rb +++ b/config/application.rb @@ -110,6 +110,7 @@ module Gitlab config.assets.precompile << "vendor/assets/fonts/*" config.assets.precompile << "test.css" config.assets.precompile << "new_nav.css" + config.assets.precompile << "new_sidebar.css" # Version of your assets, change this if you want to expire all your assets config.assets.version = '1.0' diff --git a/config/initializers/5_backend.rb b/config/initializers/5_backend.rb index 2bd159ca7f1..482613dacc9 100644 --- a/config/initializers/5_backend.rb +++ b/config/initializers/5_backend.rb @@ -1,6 +1,8 @@ -required_version = Gitlab::VersionInfo.parse(Gitlab::Shell.version_required) -current_version = Gitlab::VersionInfo.parse(Gitlab::Shell.new.version) +unless Rails.env.test? + required_version = Gitlab::VersionInfo.parse(Gitlab::Shell.version_required) + current_version = Gitlab::VersionInfo.parse(Gitlab::Shell.new.version) -unless current_version.valid? && required_version <= current_version - warn "WARNING: This version of GitLab depends on gitlab-shell #{required_version}, but you're running #{current_version}. Please update gitlab-shell." + unless current_version.valid? && required_version <= current_version + warn "WARNING: This version of GitLab depends on gitlab-shell #{required_version}, but you're running #{current_version}. Please update gitlab-shell." + end end diff --git a/config/routes/project.rb b/config/routes/project.rb index 19e18c733b1..0d0a8dff25e 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -87,13 +87,8 @@ constraints(ProjectUrlConstrainer.new) do resources :forks, only: [:index, :new, :create] resource :import, only: [:new, :create, :show] - resources :merge_requests, concerns: :awardable, constraints: { id: /\d+/ } do + resources :merge_requests, concerns: :awardable, except: [:new, :create], constraints: { id: /\d+/ } do member do - get :commits - get :diffs - get :conflicts - get :conflict_for_path - get :pipelines get :commit_change_content post :merge post :cancel_merge_when_pipeline_succeeds @@ -101,18 +96,32 @@ constraints(ProjectUrlConstrainer.new) do get :ci_environments_status post :toggle_subscription post :remove_wip - get :diff_for_path - post :resolve_conflicts post :assign_related_issues + + scope constraints: { format: nil }, action: :show do + get :commits, defaults: { tab: 'commits' } + get :pipelines, defaults: { tab: 'pipelines' } + get :diffs, defaults: { tab: 'diffs' } + end + + scope constraints: { format: 'json' }, as: :json do + get :commits + get :pipelines + get :diffs, to: 'merge_requests/diffs#show' + end + + get :diff_for_path, controller: 'merge_requests/diffs' + + scope controller: 'merge_requests/conflicts' do + get :conflicts, action: :show + get :conflict_for_path + post :resolve_conflicts + end end collection do - get :branch_from - get :branch_to - get :update_branches get :diff_for_path post :bulk_update - get :new_diffs, path: 'new/diffs' end resources :discussions, only: [], constraints: { id: /\h{40}/ } do @@ -123,6 +132,29 @@ constraints(ProjectUrlConstrainer.new) do end end + controller 'merge_requests/creations', path: 'merge_requests' do + post '', action: :create, as: nil + + scope path: 'new', as: :new_merge_request do + get '', action: :new + + scope constraints: { format: nil }, action: :new do + get :diffs, defaults: { tab: 'diffs' } + get :pipelines, defaults: { tab: 'pipelines' } + end + + scope constraints: { format: 'json' }, as: :json do + get :diffs + get :pipelines + end + + get :diff_for_path + get :update_branches + get :branch_from + get :branch_to + end + end + resources :variables, only: [:index, :show, :update, :create, :destroy] resources :triggers, only: [:index, :create, :edit, :update, :destroy] do member do diff --git a/config/webpack.config.js b/config/webpack.config.js index 2e8c94655c1..90ef6a5448b 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -163,6 +163,7 @@ var config = { 'issue_show', 'job_details', 'merge_conflicts', + 'monitoring', 'notebook_viewer', 'pdf_viewer', 'pipelines', diff --git a/db/post_migrate/20170525140254_rename_all_reserved_paths_again.rb b/db/post_migrate/20170525140254_rename_all_reserved_paths_again.rb new file mode 100644 index 00000000000..9441b236c8d --- /dev/null +++ b/db/post_migrate/20170525140254_rename_all_reserved_paths_again.rb @@ -0,0 +1,113 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RenameAllReservedPathsAgain < ActiveRecord::Migration + include Gitlab::Database::RenameReservedPathsMigration::V1 + + DOWNTIME = false + + disable_ddl_transaction! + + TOP_LEVEL_ROUTES = %w[ + - + .well-known + abuse_reports + admin + all + api + assets + autocomplete + ci + dashboard + explore + files + groups + health_check + help + hooks + import + invites + issues + jwt + koding + member + merge_requests + new + notes + notification_settings + oauth + profile + projects + public + repository + robots.txt + s + search + sent_notifications + services + snippets + teams + u + unicorn_test + unsubscribes + uploads + users + ].freeze + + PROJECT_WILDCARD_ROUTES = %w[ + badges + blame + blob + builds + commits + create + create_dir + edit + environments/folders + files + find_file + gitlab-lfs/objects + info/lfs/objects + new + preview + raw + refs + tree + update + wikis + ].freeze + + GROUP_ROUTES = %w[ + activity + analytics + audit_events + avatar + edit + group_members + hooks + issues + labels + ldap + ldap_group_links + merge_requests + milestones + notification_setting + pipeline_quota + projects + subgroups + ].freeze + + def up + disable_statement_timeout + + TOP_LEVEL_ROUTES.each { |route| rename_root_paths(route) } + PROJECT_WILDCARD_ROUTES.each { |route| rename_wildcard_paths(route) } + GROUP_ROUTES.each { |route| rename_child_paths(route) } + end + + def down + disable_statement_timeout + + revert_renames + end +end diff --git a/doc/README.md b/doc/README.md index ab8ea192a26..fa755852304 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1,12 +1,24 @@ -# GitLab Community Edition +# GitLab Documentation -[GitLab](https://about.gitlab.com/) is a Git-based fully featured platform -for software development. +Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured +platform for software development! -**GitLab Community Edition (CE)** is an opensource product, self-hosted, free to use. -All [GitLab products](https://about.gitlab.com/products/) contain the features -available in GitLab CE. Premium features are available in -[GitLab Enterprise Edition (EE)](https://about.gitlab.com/gitlab-ee/). +We offer four different products for you and your company: + +- **GitLab Community Edition (CE)** is an [opensource product](https://gitlab.com/gitlab-org/gitlab-ce/), +self-hosted, free to use. Every feature available in GitLab CE is also available on GitLab Enterprise Edition (Starter and Premium) and GitLab.com. +- **GitLab Enterprise Edition (EE)** is an [opencore product](https://gitlab.com/gitlab-org/gitlab-ee/), +self-hosted, fully featured solution of GitLab, available under distinct [subscriptions](https://about.gitlab.com/products/): **GitLab Enterprise Edition Starter (EES)** and **GitLab Enterprise Edition Premium (EEP)**. +- **GitLab.com**: SaaS GitLab solution, with [free and paid subscriptions](https://about.gitlab.com/gitlab-com/). GitLab.com is hosted by GitLab, Inc., and administrated by GitLab (users don't have access to admin settings). + +**GitLab EE** contains all features available in **GitLab CE**, +plus premium features available in each version: **Enterprise Edition Starter** +(**EES**) and **Enterprise Edition Premium** (**EEP**). Everything available in +**EES** is also available in **EEP**. + +**Note:** _We are unifying the documentation for CE and EE. To check if certain feature is +available in CE or EE, look for a note right below the page title containing the GitLab +version which introduced that feature._ ---- @@ -125,7 +137,7 @@ have access to GitLab administration tools and settings. - [Access restrictions](user/admin_area/settings/visibility_and_access_controls.md#enabled-git-access-protocols): Define which Git access protocols can be used to talk to GitLab - [Authentication/Authorization](topics/authentication/index.md#gitlab-administrators): Enforce 2FA, configure external authentication with LDAP, SAML, CAS and additional Omniauth providers. -### GitLab admins' superpowers +### Features - [Container Registry](administration/container_registry.md): Configure Docker Registry with GitLab. - [Custom Git hooks](administration/custom_hooks.md): Custom Git hooks (on the filesystem) for when webhooks aren't enough. diff --git a/doc/api/features.md b/doc/api/features.md index 89b8d3ac948..558869255cc 100644 --- a/doc/api/features.md +++ b/doc/api/features.md @@ -58,6 +58,10 @@ POST /features/:name | --------- | ---- | -------- | ----------- | | `name` | string | yes | Name of the feature to create or update | | `value` | integer/string | yes | `true` or `false` to enable/disable, or an integer for percentage of time | +| `feature_group` | string | no | A Feature group name | +| `user` | string | no | A GitLab username | + +Note that `feature_group` and `user` are mutually exclusive. ```bash curl --data "value=30" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features/new_library diff --git a/doc/api/namespaces.md b/doc/api/namespaces.md index 4ad6071a0ed..8133251dffe 100644 --- a/doc/api/namespaces.md +++ b/doc/api/namespaces.md @@ -29,22 +29,30 @@ Example response: { "id": 1, "path": "user1", - "kind": "user" + "kind": "user", + "full_path": "user1" }, { "id": 2, "path": "group1", - "kind": "group" + "kind": "group", + "full_path": "group1", + "parent_id": "null", + "members_count_with_descendants": 2 }, { "id": 3, "path": "bar", "kind": "group", "full_path": "foo/bar", + "parent_id": "9", + "members_count_with_descendants": 5 } ] ``` +**Note**: `members_count_with_descendants` are presented only for group masters/owners. + ## Search for namespace Get all namespaces that match a string in their name or path. @@ -72,6 +80,8 @@ Example response: "path": "twitter", "kind": "group", "full_path": "twitter", + "parent_id": "null", + "members_count_with_descendants": 2 } ] ``` diff --git a/doc/ci/ssh_keys/README.md b/doc/ci/ssh_keys/README.md index befaa06e918..cf25a8b618f 100644 --- a/doc/ci/ssh_keys/README.md +++ b/doc/ci/ssh_keys/README.md @@ -34,9 +34,9 @@ instructions to [generate an SSH key](../../ssh/README.md). Do not add a passphrase to the SSH key, or the `before_script` will prompt for it. Then, create a new **Secret Variable** in your project settings on GitLab -following **Settings > Variables**. As **Key** add the name `SSH_PRIVATE_KEY` -and in the **Value** field paste the content of your _private_ key that you -created earlier. +following **Settings > Pipelines** and look for the "Secret Variables" section. +As **Key** add the name `SSH_PRIVATE_KEY` and in the **Value** field paste the +content of your _private_ key that you created earlier. It is also good practice to check the server's own public key to make sure you are not being targeted by a man-in-the-middle attack. To do this, add another diff --git a/doc/development/README.md b/doc/development/README.md index 9496a87d84d..a2a07c37ced 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -54,6 +54,7 @@ - [Polymorphic Associations](polymorphic_associations.md) - [Single Table Inheritance](single_table_inheritance.md) - [Background Migrations](background_migrations.md) +- [Storing SHA1 Hashes As Binary](sha1_as_binary.md) ## i18n diff --git a/doc/development/policies.md b/doc/development/policies.md new file mode 100644 index 00000000000..62141356f59 --- /dev/null +++ b/doc/development/policies.md @@ -0,0 +1,116 @@ +# `DeclarativePolicy` framework + +The DeclarativePolicy framework is designed to assist in performance of policy checks, and to enable ease of extension for EE. The DSL code in `app/policies` is what `Ability.allowed?` uses to check whether a particular action is allowed on a subject. + +The policy used is based on the subject's class name - so `Ability.allowed?(user, :some_ability, project)` will create a `ProjectPolicy` and check permissions on that. + +## Managing Permission Rules + +Permissions are broken into two parts: `conditions` and `rules`. Conditions are boolean expressions that can access the database and the environment, while rules are statically configured combinations of expressions and other rules that enable or prevent certain abilities. For an ability to be allowed, it must be enabled by at least one rule, and not prevented by any. + + +### Conditions + +Conditions are defined by the `condition` method, and are given a name and a block. The block will be executed in the context of the policy object - so it can access `@user` and `@subject`, as well as call any methods defined on the policy. Note that `@user` may be nil (in the anonymous case), but `@subject` is guaranteed to be a real instance of the subject class. + +``` ruby +class FooPolicy < BasePolicy + condition(:is_public) do + # @subject guaranteed to be an instance of Foo + @subject.public? + end + + # instance methods can be called from the condition as well + condition(:thing) { check_thing } + + def check_thing + # ... + end +end +``` + +When you define a condition, a predicate method is defined on the policy to check whether that condition passes - so in the above example, an instance of `FooPolicy` will also respond to `#is_public?` and `#thing?`. + +Conditions are cached according to their scope. Scope and ordering will be covered later. + +### Rules + +A `rule` is a logical combination of conditions and other rules, that are configured to enable or prevent certain abilities. It is important to note that the rule configuration is static - a rule's logic cannot touch the database or know about `@user` or `@subject`. This allows us to cache only at the condition level. Rules are specified through the `rule` method, which takes a block of DSL configuration, and returns an object that responds to `#enable` or `#prevent`: + +``` ruby +class FooPolicy < BasePolicy + # ... + + rule { is_public }.enable :read + rule { thing }.prevent :read + + # equivalently, + rule { is_public }.policy do + enable :read + end + + rule { ~thing }.policy do + prevent :read + end +end +``` + +Within the rule DSL, you can use: + +* A regular word mentions a condition by name - a rule that is in effect when that condition is truthy. +* `~` indicates negation +* `&` and `|` are logical combinations, also available as `all?(...)` and `any?(...)` +* `can?(:other_ability)` delegates to the rules that apply to `:other_ability`. Note that this is distinct from the instance method `can?`, which can check dynamically - this only configures a delegation to another ability. + +## Scores, Order, Performance + +To see how the rules get evaluated into a judgment, it is useful in a console to use `policy.debug(:some_ability)`. This will print the rules in the order they are evaluated. + +When a policy is asked whether a particular ability is allowed (`policy.allowed?(:some_ability)`), it does not necessarily have to compute all the conditions on the policy. First, only the rules relevant to that particular ability are selected. Then, the execution model takes advantage of short-circuiting, and attempts to sort rules based on a heuristic of how expensive they will be to calculate. The sorting is dynamic and cache-aware, so that previously calculated conditions will be considered first, before computing other conditions. + +## Scope + +Sometimes, a condition will only use data from `@user` or only from `@subject`. In this case, we want to change the scope of the caching, so that we don't recalculate conditions unnecessarily. For example, given: + +``` ruby +class FooPolicy < BasePolicy + condition(:expensive_condition) { @subject.expensive_query? } + + rule { expensive_condition }.enable :some_ability +end +``` + +Naively, if we call `Ability.can?(user1, :some_ability, foo)` and `Ability.can?(user2, :some_ability, foo)`, we would have to calculate the condition twice - since they are for different users. But if we use the `scope: :subject` option: + +``` ruby + condition(:expensive_condition, scope: :subject) { @subject.expensive_query? } +``` + +then the result of the condition will be cached globally only based on the subject - so it will not be calculated repeatedly for different users. Similarly, `scope: :user` will cache only based on the user. + +**DANGER**: If you use a `:scope` option when the condition actually uses data from +both user and subject (including a simple anonymous check!) your result will be cached at too global of a scope and will result in cache bugs. + +Sometimes we are checking permissions for a lot of users for one subject, or a lot of subjects for one user. In this case, we want to set a *preferred scope* - i.e. tell the system that we prefer rules that can be cached on the repeated parameter. For example, in `Ability.users_that_can_read_project`: + +``` ruby +def users_that_can_read_project(users, project) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :read_project, project) } + end +end +``` + +This will, for example, prefer checking `project.public?` to checking `user.admin?`. + +## Delegation + +Delegation is the inclusion of rules from another policy, on a different subject. For example, + +``` ruby +class FooPolicy < BasePolicy + delegate { @subject.project } +end +``` + +will include all rules from `ProjectPolicy`. The delegated conditions will be evaluated with the correct delegated subject, and will be sorted along with the regular rules in the policy. Note that only the relevant rules for a particular ability will actually be considered. diff --git a/doc/development/sha1_as_binary.md b/doc/development/sha1_as_binary.md new file mode 100644 index 00000000000..3151cc29bbc --- /dev/null +++ b/doc/development/sha1_as_binary.md @@ -0,0 +1,36 @@ +# Storing SHA1 Hashes As Binary + +Storing SHA1 hashes as strings is not very space efficient. A SHA1 as a string +requires at least 40 bytes, an additional byte to store the encoding, and +perhaps more space depending on the internals of PostgreSQL and MySQL. + +On the other hand, if one were to store a SHA1 as binary one would only need 20 +bytes for the actual SHA1, and 1 or 4 bytes of additional space (again depending +on database internals). This means that in the best case scenario we can reduce +the space usage by 50%. + +To make this easier to work with you can include the concern `ShaAttribute` into +a model and define a SHA attribute using the `sha_attribute` class method. For +example: + +```ruby +class Commit < ActiveRecord::Base + include ShaAttribute + + sha_attribute :sha +end +``` + +This allows you to use the value of the `sha` attribute as if it were a string, +while storing it as binary. This means that you can do something like this, +without having to worry about converting data to the right binary format: + +```ruby +commit = Commit.find_by(sha: '88c60307bd1f215095834f09a1a5cb18701ac8ad') +commit.sha = '971604de4cfa324d91c41650fabc129420c8d1cc' +commit.save +``` + +There is however one requirement: the column used to store the SHA has _must_ be +a binary type. For Rails this means you need to use the `:binary` type instead +of `:text` or `:string`. diff --git a/doc/install/installation.md b/doc/install/installation.md index 84af6432889..992ff162efb 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -294,9 +294,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-2-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 9-3-stable gitlab -**Note:** You can change `9-2-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `9-3-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It diff --git a/doc/update/9.2-to-9.3.md b/doc/update/9.2-to-9.3.md index 0c32e4db53f..8fbcc892fd5 100644 --- a/doc/update/9.2-to-9.3.md +++ b/doc/update/9.2-to-9.3.md @@ -117,7 +117,7 @@ cd /home/git/gitlab sudo -u git -H git checkout 9-3-stable-ee ``` -### 5. Update gitlab-shell +### 7. Update gitlab-shell ```bash cd /home/git/gitlab-shell @@ -127,7 +127,7 @@ sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) sudo -u git -H bin/compile ``` -### 6. Update gitlab-workhorse +### 8. Update gitlab-workhorse Install and compile gitlab-workhorse. This requires [Go 1.5](https://golang.org/dl) which should already be on your system from @@ -143,7 +143,7 @@ sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION) sudo -u git -H make ``` -### 7. Update Gitaly +### 9. Update Gitaly If you have not yet set up Gitaly then follow [Gitaly section of the installation guide](../install/installation.md#install-gitaly). diff --git a/doc/user/profile/personal_access_tokens.md b/doc/user/profile/personal_access_tokens.md index 9488ce1ef30..f28c034e74c 100644 --- a/doc/user/profile/personal_access_tokens.md +++ b/doc/user/profile/personal_access_tokens.md @@ -14,6 +14,9 @@ accepted method of authentication when you have Once you have your token, [pass it to the API][usage] using either the `private_token` parameter or the `PRIVATE-TOKEN` header. +The expiration of personal access tokens happens on the date you define, +at midnight UTC. + ## Creating a personal access token You can create as many personal access tokens as you like from your GitLab diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index 71c69a4fdea..3c06b188f3e 100644 --- a/features/steps/dashboard/dashboard.rb +++ b/features/steps/dashboard/dashboard.rb @@ -27,7 +27,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps step 'I see prefilled new Merge Request page' do expect(page).to have_selector('.merge-request-form') - expect(current_path).to eq new_namespace_project_merge_request_path(@project.namespace, @project) + expect(current_path).to eq namespace_project_new_merge_request_path(@project.namespace, @project) expect(find("#merge_request_target_project_id").value).to eq @project.id.to_s expect(find("input#merge_request_source_branch").value).to eq "fix" expect(find("input#merge_request_target_branch").value).to eq "master" diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 69f5d0f8410..dceeed5aafe 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -65,7 +65,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I should not see "master" branch' do - expect(find('.merge-request-info')).not_to have_content "master" + expect(find('.issuable-info')).not_to have_content "master" end step 'I should see "feature_conflict" branch' do diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 9ed4f8ea1f9..02434319a08 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -266,12 +266,12 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I am redirected to the new merge request page' do - expect(current_path).to eq(new_namespace_project_merge_request_path(@project.namespace, @project)) + expect(current_path).to eq(namespace_project_new_merge_request_path(@project.namespace, @project)) end step "I am redirected to the fork's new merge request page" do fork = @user.fork_of(@project) - expect(current_path).to eq(new_namespace_project_merge_request_path(fork.namespace, fork)) + expect(current_path).to eq(namespace_project_new_merge_request_path(fork.namespace, fork)) end step 'I am redirected to the root directory' do diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 980b391c155..4ab264a3b98 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -445,7 +445,15 @@ module API end class Namespace < Grape::Entity - expose :id, :name, :path, :kind, :full_path + expose :id, :name, :path, :kind, :full_path, :parent_id + + expose :members_count_with_descendants, if: -> (namespace, opts) { expose_members_count_with_descendants?(namespace, opts) } do |namespace, _| + namespace.users_with_descendants.count + end + + def expose_members_count_with_descendants?(namespace, opts) + namespace.kind == 'group' && Ability.allowed?(opts[:current_user], :admin_group, namespace) + end end class MemberAccess < Grape::Entity diff --git a/lib/api/features.rb b/lib/api/features.rb index cff0ba2ddff..21745916463 100644 --- a/lib/api/features.rb +++ b/lib/api/features.rb @@ -2,6 +2,29 @@ module API class Features < Grape::API before { authenticated_as_admin! } + helpers do + def gate_value(params) + case params[:value] + when 'true' + true + when '0', 'false' + false + else + params[:value].to_i + end + end + + def gate_target(params) + if params[:feature_group] + Feature.group(params[:feature_group]) + elsif params[:user] + User.find_by_username(params[:user]) + else + gate_value(params) + end + end + end + resource :features do desc 'Get a list of all features' do success Entities::Feature @@ -17,16 +40,22 @@ module API end params do requires :value, type: String, desc: '`true` or `false` to enable/disable, an integer for percentage of time' + optional :feature_group, type: String, desc: 'A Feature group name' + optional :user, type: String, desc: 'A GitLab username' + mutually_exclusive :feature_group, :user end post ':name' do feature = Feature.get(params[:name]) + target = gate_target(params) + value = gate_value(params) - if %w(0 false).include?(params[:value]) - feature.disable - elsif params[:value] == 'true' - feature.enable + case value + when true + feature.enable(target) + when false + feature.disable(target) else - feature.enable_percentage_of_time(params[:value].to_i) + feature.enable_percentage_of_time(value) end present feature, with: Entities::Feature, current_user: current_user diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb index 1369b021ea4..f8645e364ce 100644 --- a/lib/api/helpers/runner.rb +++ b/lib/api/helpers/runner.rb @@ -46,7 +46,8 @@ module API yield if block_given? - forbidden!('Project has been deleted!') unless job.project + project = job.project + forbidden!('Project has been deleted!') if project.nil? || project.pending_delete? forbidden!('Job has been erased!') if job.erased? end diff --git a/lib/api/namespaces.rb b/lib/api/namespaces.rb index 30761cb9b55..f1eaff6b0eb 100644 --- a/lib/api/namespaces.rb +++ b/lib/api/namespaces.rb @@ -17,7 +17,7 @@ module API namespaces = namespaces.search(params[:search]) if params[:search].present? - present paginate(namespaces), with: Entities::Namespace + present paginate(namespaces), with: Entities::Namespace, current_user: current_user end end end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 20707e97e53..a781869fc19 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -1,3 +1,5 @@ +require_dependency 'declarative_policy' + module API # Projects API class Projects < Grape::API @@ -397,7 +399,7 @@ module API use :pagination end get ':id/users' do - users = user_project.team.users + users = DeclarativePolicy.subject_scope { user_project.team.users } users = users.search(params[:search]) if params[:search].present? present paginate(users), with: Entities::UserBasic diff --git a/lib/ci/api/helpers.rb b/lib/ci/api/helpers.rb index 5109dc9670f..a40b6ab6c9f 100644 --- a/lib/ci/api/helpers.rb +++ b/lib/ci/api/helpers.rb @@ -28,7 +28,8 @@ module Ci yield if block_given? - forbidden!('Project has been deleted!') unless build.project + project = build.project + forbidden!('Project has been deleted!') if project.nil? || project.pending_delete? forbidden!('Build has been erased!') if build.erased? end diff --git a/lib/declarative_policy.rb b/lib/declarative_policy.rb new file mode 100644 index 00000000000..d9959bc1aff --- /dev/null +++ b/lib/declarative_policy.rb @@ -0,0 +1,58 @@ +require_dependency 'declarative_policy/cache' +require_dependency 'declarative_policy/condition' +require_dependency 'declarative_policy/dsl' +require_dependency 'declarative_policy/preferred_scope' +require_dependency 'declarative_policy/rule' +require_dependency 'declarative_policy/runner' +require_dependency 'declarative_policy/step' + +require_dependency 'declarative_policy/base' + +module DeclarativePolicy + class << self + def policy_for(user, subject, opts = {}) + cache = opts[:cache] || {} + key = Cache.policy_key(user, subject) + + cache[key] ||= class_for(subject).new(user, subject, opts) + end + + def class_for(subject) + return GlobalPolicy if subject == :global + return NilPolicy if subject.nil? + + subject = find_delegate(subject) + + subject.class.ancestors.each do |klass| + next unless klass.name + + begin + policy_class = "#{klass.name}Policy".constantize + + # NOTE: the < operator here tests whether policy_class + # inherits from Base. We can't use #is_a? because that + # tests for *instances*, not *subclasses*. + return policy_class if policy_class < Base + rescue NameError + nil + end + end + + raise "no policy for #{subject.class.name}" + end + + private + + def find_delegate(subject) + seen = Set.new + + while subject.respond_to?(:declarative_policy_delegate) + raise ArgumentError, "circular delegations" if seen.include?(subject.object_id) + seen << subject.object_id + subject = subject.declarative_policy_delegate + end + + subject + end + end +end diff --git a/lib/declarative_policy/base.rb b/lib/declarative_policy/base.rb new file mode 100644 index 00000000000..df94cafb6a1 --- /dev/null +++ b/lib/declarative_policy/base.rb @@ -0,0 +1,329 @@ +module DeclarativePolicy + class Base + # A map of ability => list of rules together with :enable + # or :prevent actions. Used to look up which rules apply to + # a given ability. See Base.ability_map + class AbilityMap + attr_reader :map + def initialize(map = {}) + @map = map + end + + # This merge behavior is different than regular hashes - if both + # share a key, the values at that key are concatenated, rather than + # overridden. + def merge(other) + conflict_proc = proc { |key, my_val, other_val| my_val + other_val } + AbilityMap.new(@map.merge(other.map, &conflict_proc)) + end + + def actions(key) + @map[key] ||= [] + end + + def enable(key, rule) + actions(key) << [:enable, rule] + end + + def prevent(key, rule) + actions(key) << [:prevent, rule] + end + end + + class << self + # The `own_ability_map` vs `ability_map` distinction is used so that + # the data structure is properly inherited - with subclasses recursively + # merging their parent class. + # + # This pattern is also used for conditions, global_actions, and delegations. + def ability_map + if self == Base + own_ability_map + else + superclass.ability_map.merge(own_ability_map) + end + end + + def own_ability_map + @own_ability_map ||= AbilityMap.new + end + + # an inheritable map of conditions, by name + def conditions + if self == Base + own_conditions + else + superclass.conditions.merge(own_conditions) + end + end + + def own_conditions + @own_conditions ||= {} + end + + # a list of global actions, generated by `prevent_all`. these aren't + # stored in `ability_map` because they aren't indexed by a particular + # ability. + def global_actions + if self == Base + own_global_actions + else + superclass.global_actions + own_global_actions + end + end + + def own_global_actions + @own_global_actions ||= [] + end + + # an inheritable map of delegations, indexed by name (which may be + # autogenerated) + def delegations + if self == Base + own_delegations + else + superclass.delegations.merge(own_delegations) + end + end + + def own_delegations + @own_delegations ||= {} + end + + # all the [rule, action] pairs that apply to a particular ability. + # we combine the specific ones looked up in ability_map with the global + # ones. + def configuration_for(ability) + ability_map.actions(ability) + global_actions + end + + ### declaration methods ### + + def delegate(name = nil, &delegation_block) + if name.nil? + @delegate_name_counter ||= 0 + @delegate_name_counter += 1 + name = :"anonymous_#{@delegate_name_counter}" + end + + name = name.to_sym + + if delegation_block.nil? + delegation_block = proc { @subject.__send__(name) } + end + + own_delegations[name] = delegation_block + end + + # Declares a rule, constructed using RuleDsl, and returns + # a PolicyDsl which is used for registering the rule with + # this class. PolicyDsl will call back into Base.enable_when, + # Base.prevent_when, and Base.prevent_all_when. + def rule(&b) + rule = RuleDsl.new(self).instance_eval(&b) + PolicyDsl.new(self, rule) + end + + # A hash in which to store calls to `desc` and `with_scope`, etc. + def last_options + @last_options ||= {}.with_indifferent_access + end + + # retrieve and zero out the previously set options (used in .condition) + def last_options! + last_options.tap { @last_options = nil } + end + + # Declare a description for the following condition. Currently unused, + # but opens the potential for explaining to users why they were or were + # not able to do something. + def desc(description) + last_options[:description] = description + end + + def with_options(opts = {}) + last_options.merge!(opts) + end + + def with_scope(scope) + with_options scope: scope + end + + def with_score(score) + with_options score: score + end + + # Declares a condition. It gets stored in `own_conditions`, and generates + # a query method based on the condition's name. + def condition(name, opts = {}, &value) + name = name.to_sym + + opts = last_options!.merge(opts) + opts[:context_key] ||= self.name + + condition = Condition.new(name, opts, &value) + + self.own_conditions[name] = condition + + define_method(:"#{name}?") { condition(name).pass? } + end + + # These next three methods are mainly called from PolicyDsl, + # and are responsible for "inverting" the relationship between + # an ability and a rule. We store in `ability_map` a map of + # abilities to rules that affect them, together with a + # symbol indicating :prevent or :enable. + def enable_when(abilities, rule) + abilities.each { |a| own_ability_map.enable(a, rule) } + end + + def prevent_when(abilities, rule) + abilities.each { |a| own_ability_map.prevent(a, rule) } + end + + # we store global prevents (from `prevent_all`) separately, + # so that they can be combined into every decision made. + def prevent_all_when(rule) + own_global_actions << [:prevent, rule] + end + end + + # A policy object contains a specific user and subject on which + # to compute abilities. For this reason it's sometimes called + # "context" within the framework. + # + # It also stores a reference to the cache, so it can be used + # to cache computations by e.g. ManifestCondition. + attr_reader :user, :subject, :cache + def initialize(user, subject, opts = {}) + @user = user + @subject = subject + @cache = opts[:cache] || {} + end + + # helper for checking abilities on this and other subjects + # for the current user. + def can?(ability, new_subject = :_self) + return allowed?(ability) if new_subject == :_self + + policy_for(new_subject).allowed?(ability) + end + + # This is the main entry point for permission checks. It constructs + # or looks up a Runner for the given ability and asks it if it passes. + def allowed?(*abilities) + abilities.all? { |a| runner(a).pass? } + end + + # The inverse of #allowed?, used mainly in specs. + def disallowed?(*abilities) + abilities.all? { |a| !runner(a).pass? } + end + + # computes the given ability and prints a helpful debugging output + # showing which + def debug(ability, *a) + runner(ability).debug(*a) + end + + desc "Unknown user" + condition(:anonymous, scope: :user, score: 0) { @user.nil? } + + desc "By default" + condition(:default, scope: :global, score: 0) { true } + + def repr + subject_repr = + if @subject.respond_to?(:id) + "#{@subject.class.name}/#{@subject.id}" + else + @subject.inspect + end + + user_repr = + if @user + @user.to_reference + else + "<anonymous>" + end + + "(#{user_repr} : #{subject_repr})" + end + + def inspect + "#<#{self.class.name} #{repr}>" + end + + # returns a Runner for the given ability, capable of computing whether + # the ability is allowed. Runners are cached on the policy (which itself + # is cached on @cache), and caches its result. This is how we perform caching + # at the ability level. + def runner(ability) + ability = ability.to_sym + @runners ||= {} + @runners[ability] ||= + begin + delegated_runners = delegated_policies.values.compact.map { |p| p.runner(ability) } + own_runner = Runner.new(own_steps(ability)) + delegated_runners.inject(own_runner, &:merge_runner) + end + end + + # Helpers for caching. Used by ManifestCondition in performing condition + # computation. + # + # NOTE we can't use ||= here because the value might be the + # boolean `false` + def cache(key, &b) + return @cache[key] if cached?(key) + @cache[key] = yield + end + + def cached?(key) + !@cache[key].nil? + end + + # returns a ManifestCondition capable of computing itself. The computation + # will use our own @cache. + def condition(name) + name = name.to_sym + @_conditions ||= {} + @_conditions[name] ||= + begin + raise "invalid condition #{name}" unless self.class.conditions.key?(name) + ManifestCondition.new(self.class.conditions[name], self) + end + end + + # used in specs - returns true if there is no possible way for any action + # to be allowed, determined only by the global :prevent_all rules. + def banned? + global_steps = self.class.global_actions.map { |(action, rule)| Step.new(self, rule, action) } + !Runner.new(global_steps).pass? + end + + # A list of other policies that we've delegated to (see `Base.delegate`) + def delegated_policies + @delegated_policies ||= self.class.delegations.transform_values do |block| + new_subject = instance_eval(&block) + + # never delegate to nil, as that would immediately prevent_all + next if new_subject.nil? + + policy_for(new_subject) + end + end + + def policy_for(other_subject) + DeclarativePolicy.policy_for(@user, other_subject, cache: @cache) + end + + protected + + # constructs steps that come from this policy and not from any delegations + def own_steps(ability) + rules = self.class.configuration_for(ability) + rules.map { |(action, rule)| Step.new(self, rule, action) } + end + end +end diff --git a/lib/declarative_policy/cache.rb b/lib/declarative_policy/cache.rb new file mode 100644 index 00000000000..b8cc60074c7 --- /dev/null +++ b/lib/declarative_policy/cache.rb @@ -0,0 +1,32 @@ +module DeclarativePolicy + module Cache + class << self + def user_key(user) + return '<anonymous>' if user.nil? + id_for(user) + end + + def policy_key(user, subject) + u = user_key(user) + s = subject_key(subject) + "/dp/policy/#{u}/#{s}" + end + + def subject_key(subject) + return '<nil>' if subject.nil? + return subject.inspect if subject.is_a?(Symbol) + "#{subject.class.name}:#{id_for(subject)}" + end + + private + + def id_for(obj) + if obj.respond_to?(:id) && obj.id + obj.id.to_s + else + "##{obj.object_id}" + end + end + end + end +end diff --git a/lib/declarative_policy/condition.rb b/lib/declarative_policy/condition.rb new file mode 100644 index 00000000000..9d7cf6b9726 --- /dev/null +++ b/lib/declarative_policy/condition.rb @@ -0,0 +1,102 @@ +module DeclarativePolicy + # A Condition is the data structure that is created by the + # `condition` declaration on DeclarativePolicy::Base. It is + # more or less just a struct of the data passed to that + # declaration. It holds on to the block to be instance_eval'd + # on a context (instance of Base) later, via #compute. + class Condition + attr_reader :name, :description, :scope + attr_reader :manual_score + attr_reader :context_key + def initialize(name, opts = {}, &compute) + @name = name + @compute = compute + @scope = opts.fetch(:scope, :normal) + @description = opts.delete(:description) + @context_key = opts[:context_key] + @manual_score = opts.fetch(:score, nil) + end + + def compute(context) + !!context.instance_eval(&@compute) + end + + def key + "#{@context_key}/#{@name}" + end + end + + # In contrast to a Condition, a ManifestCondition contains + # a Condition and a context object, and is capable of calculating + # a result itself. This is the return value of Base#condition. + class ManifestCondition + def initialize(condition, context) + @condition = condition + @context = context + end + + # The main entry point - does this condition pass? We reach into + # the context's cache here so that we can share in the global + # cache (often RequestStore or similar). + def pass? + @context.cache(cache_key) { @condition.compute(@context) } + end + + # Whether we've already computed this condition. + def cached? + @context.cached?(cache_key) + end + + # This is used to score Rule::Condition. See Rule::Condition#score + # and Runner#steps_by_score for how scores are used. + # + # The number here is intended to represent, abstractly, how + # expensive it would be to calculate this condition. + # + # See #cache_key for info about @condition.scope. + def score + # If we've been cached, no computation is necessary. + return 0 if cached? + + # Use the override from condition(score: ...) if present + return @condition.manual_score if @condition.manual_score + + # Global scope rules are cheap due to max cache sharing + return 2 if @condition.scope == :global + + # "Normal" rules can't share caches with any other policies + return 16 if @condition.scope == :normal + + # otherwise, we're :user or :subject scope, so it's 4 if + # the caller has declared a preference + return 4 if @condition.scope == DeclarativePolicy.preferred_scope + + # and 8 for all other :user or :subject scope conditions. + 8 + end + + private + + # This method controls the caching for the condition. This is where + # the condition(scope: ...) option comes into play. Notice that + # depending on the scope, we may cache only by the user or only by + # the subject, resulting in sharing across different policy objects. + def cache_key + case @condition.scope + when :normal then "/dp/condition/#{@condition.key}/#{user_key},#{subject_key}" + when :user then "/dp/condition/#{@condition.key}/#{user_key}" + when :subject then "/dp/condition/#{@condition.key}/#{subject_key}" + when :global then "/dp/condition/#{@condition.key}" + else raise 'invalid scope' + end + end + + def user_key + Cache.user_key(@context.user) + end + + def subject_key + Cache.subject_key(@context.subject) + end + end +end diff --git a/lib/declarative_policy/dsl.rb b/lib/declarative_policy/dsl.rb new file mode 100644 index 00000000000..b26807a7622 --- /dev/null +++ b/lib/declarative_policy/dsl.rb @@ -0,0 +1,103 @@ +module DeclarativePolicy + # The DSL evaluation context inside rule { ... } blocks. + # Responsible for creating and combining Rule objects. + # + # See Base.rule + class RuleDsl + def initialize(context_class) + @context_class = context_class + end + + def can?(ability) + Rule::Ability.new(ability) + end + + def all?(*rules) + Rule::And.make(rules) + end + + def any?(*rules) + Rule::Or.make(rules) + end + + def none?(*rules) + ~Rule::Or.new(rules) + end + + def cond(condition) + Rule::Condition.new(condition) + end + + def delegate(delegate_name, condition) + Rule::DelegatedCondition.new(delegate_name, condition) + end + + def method_missing(m, *a, &b) + return super unless a.size == 0 && !block_given? + + if @context_class.delegations.key?(m) + DelegateDsl.new(self, m) + else + cond(m.to_sym) + end + end + end + + # Used when the name of a delegate is mentioned in + # the rule DSL. + class DelegateDsl + def initialize(rule_dsl, delegate_name) + @rule_dsl = rule_dsl + @delegate_name = delegate_name + end + + def method_missing(m, *a, &b) + return super unless a.size == 0 && !block_given? + + @rule_dsl.delegate(@delegate_name, m) + end + end + + # The return value of a rule { ... } declaration. + # Can call back to register rules with the containing + # Policy class (context_class here). See Base.rule + # + # Note that the #policy method just performs an #instance_eval, + # which is useful for multiple #enable or #prevent callse. + # + # Also provides a #method_missing proxy to the context + # class's class methods, so that helper methods can be + # defined and used in a #policy { ... } block. + class PolicyDsl + def initialize(context_class, rule) + @context_class = context_class + @rule = rule + end + + def policy(&b) + instance_eval(&b) + end + + def enable(*abilities) + @context_class.enable_when(abilities, @rule) + end + + def prevent(*abilities) + @context_class.prevent_when(abilities, @rule) + end + + def prevent_all + @context_class.prevent_all_when(@rule) + end + + def method_missing(m, *a, &b) + return super unless @context_class.respond_to?(m) + + @context_class.__send__(m, *a, &b) + end + + def respond_to_missing?(m) + @context_class.respond_to?(m) || super + end + end +end diff --git a/lib/declarative_policy/preferred_scope.rb b/lib/declarative_policy/preferred_scope.rb new file mode 100644 index 00000000000..b0754098149 --- /dev/null +++ b/lib/declarative_policy/preferred_scope.rb @@ -0,0 +1,28 @@ +module DeclarativePolicy + PREFERRED_SCOPE_KEY = :"DeclarativePolicy.preferred_scope" + + class << self + def with_preferred_scope(scope, &b) + Thread.current[PREFERRED_SCOPE_KEY], old_scope = scope, Thread.current[PREFERRED_SCOPE_KEY] + yield + ensure + Thread.current[PREFERRED_SCOPE_KEY] = old_scope + end + + def preferred_scope + Thread.current[PREFERRED_SCOPE_KEY] + end + + def user_scope(&b) + with_preferred_scope(:user, &b) + end + + def subject_scope(&b) + with_preferred_scope(:subject, &b) + end + + def preferred_scope=(scope) + Thread.current[PREFERRED_SCOPE_KEY] = scope + end + end +end diff --git a/lib/declarative_policy/rule.rb b/lib/declarative_policy/rule.rb new file mode 100644 index 00000000000..bfcec241489 --- /dev/null +++ b/lib/declarative_policy/rule.rb @@ -0,0 +1,301 @@ +module DeclarativePolicy + module Rule + # A Rule is the object that results from the `rule` declaration, + # usually built using the DSL in `RuleDsl`. It is a basic logical + # combination of building blocks, and is capable of deciding, + # given a context (instance of DeclarativePolicy::Base) whether it + # passes or not. Note that this decision doesn't by itself know + # how that affects the actual ability decision - for that, a + # `Step` is used. + class Base + def self.make(*a) + new(*a).simplify + end + + # true or false whether this rule passes. + # `context` is a policy - an instance of + # DeclarativePolicy::Base. + def pass?(context) + raise 'abstract' + end + + # same as #pass? except refuses to do any I/O, + # returning nil if the result is not yet cached. + # used for accurately scoring And/Or + def cached_pass?(context) + raise 'abstract' + end + + # abstractly, how long would it take to compute + # this rule? lower-scored rules are tried first. + def score(context) + raise 'abstract' + end + + # unwrap double negatives and nested and/or + def simplify + self + end + + # convenience combination methods + def or(other) + Or.make([self, other]) + end + + def and(other) + And.make([self, other]) + end + + def negate + Not.make(self) + end + + alias_method :|, :or + alias_method :&, :and + alias_method :~@, :negate + + def inspect + "#<Rule #{repr}>" + end + end + + # A rule that checks a condition. This is the + # type of rule that results from a basic bareword + # in the rule dsl (see RuleDsl#method_missing). + class Condition < Base + def initialize(name) + @name = name + end + + # we delegate scoring to the condition. See + # ManifestCondition#score. + def score(context) + context.condition(@name).score + end + + # Let the ManifestCondition from the context + # decide whether we pass. + def pass?(context) + context.condition(@name).pass? + end + + # returns nil unless it's already cached + def cached_pass?(context) + condition = context.condition(@name) + return nil unless condition.cached? + condition.pass? + end + + def description(context) + context.class.conditions[@name].description + end + + def repr + @name.to_s + end + end + + # A rule constructed from DelegateDsl - using a condition from a + # delegated policy. + class DelegatedCondition < Base + # Internal use only - this is rescued each time it's raised. + MissingDelegate = Class.new(StandardError) + + def initialize(delegate_name, name) + @delegate_name = delegate_name + @name = name + end + + def delegated_context(context) + policy = context.delegated_policies[@delegate_name] + raise MissingDelegate if policy.nil? + policy + end + + def score(context) + delegated_context(context).condition(@name).score + rescue MissingDelegate + 0 + end + + def cached_pass?(context) + condition = delegated_context(context).condition(@name) + return nil unless condition.cached? + condition.pass? + rescue MissingDelegate + false + end + + def pass?(context) + delegated_context(context).condition(@name).pass? + rescue MissingDelegate + false + end + + def repr + "#{@delegate_name}.#{@name}" + end + end + + # A rule constructed from RuleDsl#can?. Computes a different ability + # on the same subject. + class Ability < Base + attr_reader :ability + def initialize(ability) + @ability = ability + end + + # We ask the ability's runner for a score + def score(context) + context.runner(@ability).score + end + + def pass?(context) + context.allowed?(@ability) + end + + def cached_pass?(context) + runner = context.runner(@ability) + return nil unless runner.cached? + runner.pass? + end + + def description(context) + "User can #{@ability.inspect}" + end + + def repr + "can?(#{@ability.inspect})" + end + end + + # Logical `and`, containing a list of rules. Only passes + # if all of them do. + class And < Base + attr_reader :rules + def initialize(rules) + @rules = rules + end + + def simplify + simplified_rules = @rules.flat_map do |rule| + simplified = rule.simplify + case simplified + when And then simplified.rules + else [simplified] + end + end + + And.new(simplified_rules) + end + + def score(context) + return 0 unless cached_pass?(context).nil? + + # note that cached rules will have score 0 anyways. + @rules.map { |r| r.score(context) }.inject(0, :+) + end + + def pass?(context) + # try to find a cached answer before + # checking in order + cached = cached_pass?(context) + return cached unless cached.nil? + + @rules.all? { |r| r.pass?(context) } + end + + def cached_pass?(context) + passes = @rules.map { |r| r.cached_pass?(context) } + return false if passes.any? { |p| p == false } + return true if passes.all? { |p| p == true } + + nil + end + + def repr + "all?(#{rules.map(&:repr).join(', ')})" + end + end + + # Logical `or`. Mirrors And. + class Or < Base + attr_reader :rules + def initialize(rules) + @rules = rules + end + + def pass?(context) + cached = cached_pass?(context) + return cached unless cached.nil? + + @rules.any? { |r| r.pass?(context) } + end + + def simplify + simplified_rules = @rules.flat_map do |rule| + simplified = rule.simplify + case simplified + when Or then simplified.rules + else [simplified] + end + end + + Or.new(simplified_rules) + end + + def cached_pass?(context) + passes = @rules.map { |r| r.cached_pass?(context) } + return true if passes.any? { |p| p == true } + return false if passes.all? { |p| p == false } + + nil + end + + def score(context) + return 0 unless cached_pass?(context).nil? + @rules.map { |r| r.score(context) }.inject(0, :+) + end + + def repr + "any?(#{@rules.map(&:repr).join(', ')})" + end + end + + class Not < Base + attr_reader :rule + def initialize(rule) + @rule = rule + end + + def simplify + case @rule + when And then Or.new(@rule.rules.map(&:negate)).simplify + when Or then And.new(@rule.rules.map(&:negate)).simplify + when Not then @rule.rule.simplify + else Not.new(@rule.simplify) + end + end + + def pass?(context) + !@rule.pass?(context) + end + + def cached_pass?(context) + case @rule.cached_pass?(context) + when nil then nil + when true then false + when false then true + end + end + + def score(context) + @rule.score(context) + end + + def repr + "~#{@rule.repr}" + end + end + end +end diff --git a/lib/declarative_policy/runner.rb b/lib/declarative_policy/runner.rb new file mode 100644 index 00000000000..b5c615da4e3 --- /dev/null +++ b/lib/declarative_policy/runner.rb @@ -0,0 +1,181 @@ +module DeclarativePolicy + class Runner + class State + def initialize + @enabled = false + @prevented = false + end + + def enable! + @enabled = true + end + + def enabled? + @enabled + end + + def prevent! + @prevented = true + end + + def prevented? + @prevented + end + + def pass? + !prevented? && enabled? + end + end + + # a Runner contains a list of Steps to be run. + attr_reader :steps + def initialize(steps) + @steps = steps + end + + # We make sure only to run any given Runner once, + # and just continue to use the resulting @state + # that's left behind. + def cached? + !!@state + end + + # used by Rule::Ability. See #steps_by_score + def score + return 0 if cached? + steps.map(&:score).inject(0, :+) + end + + def merge_runner(other) + Runner.new(@steps + other.steps) + end + + # The main entry point, called for making an ability decision. + # See #run and DeclarativePolicy::Base#can? + def pass? + run unless cached? + + @state.pass? + end + + # see DeclarativePolicy::Base#debug + def debug(out = $stderr) + run(out) + end + + private + + def flatten_steps! + @steps = @steps.flat_map { |s| s.flattened(@steps) } + end + + # This method implements the semantic of "one enable and no prevents". + # It relies on #steps_by_score for the main loop, and updates @state + # with the result of the step. + def run(debug = nil) + @state = State.new + + steps_by_score do |step, score| + passed = nil + case step.action + when :enable then + # we only check :enable actions if they have a chance of + # changing the outcome - if no other rule has enabled or + # prevented. + unless @state.enabled? || @state.prevented? + passed = step.pass? + @state.enable! if passed + end + + debug << inspect_step(step, score, passed) if debug + when :prevent then + # we only check :prevent actions if the state hasn't already + # been prevented. + unless @state.prevented? + passed = step.pass? + if passed + @state.prevent! + return unless debug + end + end + + debug << inspect_step(step, score, passed) if debug + else raise "invalid action #{step.action.inspect}" + end + end + + @state + end + + # This is the core spot where all those `#score` methods matter. + # It is critcal for performance to run steps in the correct order, + # so that we don't compute expensive conditions (potentially n times + # if we're called on, say, a large list of users). + # + # In order to determine the cheapest step to run next, we rely on + # Step#score, which returns a numerical rating of how expensive + # it would be to calculate - the lower the better. It would be + # easy enough to statically sort by these scores, but we can do + # a little better - the scores are cache-aware (conditions that + # are already in the cache have score 0), which means that running + # a step can actually change the scores of other steps. + # + # So! The way we sort here involves re-scoring at every step. This + # is by necessity quadratic, but most of the time the number of steps + # will be low. But just in case, if the number of steps exceeds 50, + # we print a warning and fall back to a static sort. + # + # For each step, we yield the step object along with the computed score + # for debugging purposes. + def steps_by_score(&b) + flatten_steps! + + if @steps.size > 50 + warn "DeclarativePolicy: large number of steps (#{steps.size}), falling back to static sort" + + @steps.map { |s| [s.score, s] }.sort_by { |(score, _)| score }.each do |(score, step)| + yield step, score + end + + return + end + + steps = Set.new(@steps) + + loop do + return if steps.empty? + + # if the permission hasn't yet been enabled and we only have + # prevent steps left, we short-circuit the state here + @state.prevent! if !@state.enabled? && steps.all?(&:prevent?) + + lowest_score = Float::INFINITY + next_step = nil + + steps.each do |step| + score = step.score + if score < lowest_score + next_step = step + lowest_score = score + end + end + + steps.delete(next_step) + + yield next_step, lowest_score + end + end + + # Formatter for debugging output. + def inspect_step(step, original_score, passed) + symbol = + case passed + when true then '+' + when false then '-' + when nil then ' ' + end + + "#{symbol} [#{original_score.to_i}] #{step.repr}\n" + end + end +end diff --git a/lib/declarative_policy/step.rb b/lib/declarative_policy/step.rb new file mode 100644 index 00000000000..3469fe9f991 --- /dev/null +++ b/lib/declarative_policy/step.rb @@ -0,0 +1,86 @@ +module DeclarativePolicy + # This object represents one step in the runtime decision of whether + # an ability is allowed. It contains a Rule and a context (instance + # of DeclarativePolicy::Base), which contains the user, the subject, + # and the cache. It also contains an "action", which is the symbol + # :prevent or :enable. + class Step + attr_reader :context, :rule, :action + def initialize(context, rule, action) + @context = context + @rule = rule + @action = action + end + + # In the flattening process, duplicate steps may be generated in the + # same rule. This allows us to eliminate those (see Runner#steps_by_score + # and note its use of a Set) + def ==(other) + @context == other.context && @rule == other.rule && @action == other.action + end + + # In the runner, steps are sorted dynamically by score, so that + # we are sure to compute them in close to the optimal order. + # + # See also Rule#score, ManifestCondition#score, and Runner#steps_by_score. + def score + # we slightly prefer the preventative actions + # since they are more likely to short-circuit + case @action + when :prevent + @rule.score(@context) * (7.0 / 8) + when :enable + @rule.score(@context) + end + end + + def with_action(action) + Step.new(@context, @rule, action) + end + + def enable? + @action == :enable + end + + def prevent? + @action == :prevent + end + + # This rather complex method allows us to split rules into parts so that + # they can be sorted independently for better optimization + def flattened(roots) + case @rule + when Rule::Or + # A single `Or` step is the same as each of its elements as separate steps + @rule.rules.flat_map { |r| Step.new(@context, r, @action).flattened(roots) } + when Rule::Ability + # This looks like a weird micro-optimization but it buys us quite a lot + # in some cases. If we depend on an Ability (i.e. a `can?(...)` rule), + # and that ability *only* has :enable actions (modulo some actions that + # we already have taken care of), then its rules can be safely inlined. + steps = @context.runner(@rule.ability).steps.reject { |s| roots.include?(s) } + + if steps.all?(&:enable?) + # in the case that we are a :prevent step, each inlined step becomes + # an independent :prevent, even though it was an :enable in its initial + # context. + steps.map! { |s| s.with_action(:prevent) } if prevent? + + steps.flat_map { |s| s.flattened(roots) } + else + [self] + end + else + [self] + end + end + + def pass? + @rule.pass?(@context) + end + + def repr + "#{@action} when #{@rule.repr} (#{@context.repr})" + end + end +end diff --git a/lib/feature.rb b/lib/feature.rb index d3d972564af..363f66ba60e 100644 --- a/lib/feature.rb +++ b/lib/feature.rb @@ -12,6 +12,8 @@ class Feature end class << self + delegate :group, to: :flipper + def all flipper.features.to_a end @@ -27,16 +29,24 @@ class Feature all.map(&:name).include?(feature.name) end - def enabled?(key) - get(key).enabled? + def enabled?(key, thing = nil) + get(key).enabled?(thing) + end + + def enable(key, thing = true) + get(key).enable(thing) + end + + def disable(key, thing = false) + get(key).disable(thing) end - def enable(key) - get(key).enable + def enable_group(key, group) + get(key).enable_group(group) end - def disable(key) - get(key).disable + def disable_group(key, group) + get(key).disable_group(group) end def flipper diff --git a/lib/gitlab/allowable.rb b/lib/gitlab/allowable.rb index e4f7cad2b79..45c2b01dd8f 100644 --- a/lib/gitlab/allowable.rb +++ b/lib/gitlab/allowable.rb @@ -1,7 +1,7 @@ module Gitlab module Allowable - def can?(user, action, subject = :global) - Ability.allowed?(user, action, subject) + def can?(*args) + Ability.allowed?(*args) end end end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1.rb index 89530082cd2..f333ff22300 100644 --- a/lib/gitlab/database/rename_reserved_paths_migration/v1.rb +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1.rb @@ -29,6 +29,11 @@ module Gitlab paths = Array(paths) RenameNamespaces.new(paths, self).rename_namespaces(type: :top_level) end + + def revert_renames + RenameProjects.new([], self).revert_renames + RenameNamespaces.new([], self).revert_renames + end end end end diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb index d8163d7da11..33f8939bc61 100644 --- a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base.rb @@ -6,7 +6,10 @@ module Gitlab attr_reader :paths, :migration delegate :update_column_in_batches, + :execute, :replace_sql, + :quote_string, + :say, to: :migration def initialize(paths, migration) @@ -26,24 +29,45 @@ module Gitlab new_path = rename_path(namespace_path, old_path) new_full_path = join_routable_path(namespace_path, new_path) + perform_rename(routable, old_full_path, new_full_path) + + [old_full_path, new_full_path] + end + + def perform_rename(routable, old_full_path, new_full_path) # skips callbacks & validations + new_path = new_full_path.split('/').last routable.class.where(id: routable) .update_all(path: new_path) rename_routes(old_full_path, new_full_path) - - [old_full_path, new_full_path] end def rename_routes(old_full_path, new_full_path) + routes = Route.arel_table + + quoted_old_full_path = quote_string(old_full_path) + quoted_old_wildcard_path = quote_string("#{old_full_path}/%") + + filter = if Database.mysql? + "lower(routes.path) = lower('#{quoted_old_full_path}') "\ + "OR routes.path LIKE '#{quoted_old_wildcard_path}'" + else + "routes.id IN "\ + "( SELECT routes.id FROM routes WHERE lower(routes.path) = lower('#{quoted_old_full_path}') "\ + "UNION SELECT routes.id FROM routes WHERE routes.path ILIKE '#{quoted_old_wildcard_path}' )" + end + replace_statement = replace_sql(Route.arel_table[:path], old_full_path, new_full_path) - update_column_in_batches(:routes, :path, replace_statement) do |table, query| - path_or_children = table[:path].matches_any([old_full_path, "#{old_full_path}/%"]) - query.where(path_or_children) - end + update = Arel::UpdateManager.new(ActiveRecord::Base) + .table(routes) + .set([[routes[:path], replace_statement]]) + .where(Arel::Nodes::SqlLiteral.new(filter)) + + execute(update.to_sql) end def rename_path(namespace_path, path_was) @@ -86,32 +110,74 @@ module Gitlab def move_folders(directory, old_relative_path, new_relative_path) old_path = File.join(directory, old_relative_path) - return unless File.directory?(old_path) + unless File.directory?(old_path) + say "#{old_path} doesn't exist, skipping" + return + end new_path = File.join(directory, new_relative_path) FileUtils.mv(old_path, new_path) end def remove_cached_html_for_projects(project_ids) - update_column_in_batches(:projects, :description_html, nil) do |table, query| - query.where(table[:id].in(project_ids)) - end - - update_column_in_batches(:issues, :description_html, nil) do |table, query| - query.where(table[:project_id].in(project_ids)) + project_ids.each do |project_id| + update_column_in_batches(:projects, :description_html, nil) do |table, query| + query.where(table[:id].eq(project_id)) + end + + update_column_in_batches(:issues, :description_html, nil) do |table, query| + query.where(table[:project_id].eq(project_id)) + end + + update_column_in_batches(:merge_requests, :description_html, nil) do |table, query| + query.where(table[:target_project_id].eq(project_id)) + end + + update_column_in_batches(:notes, :note_html, nil) do |table, query| + query.where(table[:project_id].eq(project_id)) + end + + update_column_in_batches(:milestones, :description_html, nil) do |table, query| + query.where(table[:project_id].eq(project_id)) + end end + end - update_column_in_batches(:merge_requests, :description_html, nil) do |table, query| - query.where(table[:target_project_id].in(project_ids)) + def track_rename(type, old_path, new_path) + key = redis_key_for_type(type) + Gitlab::Redis.with do |redis| + redis.lpush(key, [old_path, new_path].to_json) + redis.expire(key, 2.weeks.to_i) end + say "tracked rename: #{key}: #{old_path} -> #{new_path}" + end - update_column_in_batches(:notes, :note_html, nil) do |table, query| - query.where(table[:project_id].in(project_ids)) + def reverts_for_type(type) + key = redis_key_for_type(type) + + Gitlab::Redis.with do |redis| + failed_reverts = [] + + while rename_info = redis.lpop(key) + path_before_rename, path_after_rename = JSON.parse(rename_info) + say "renaming #{type} from #{path_after_rename} back to #{path_before_rename}" + begin + yield(path_before_rename, path_after_rename) + rescue StandardError => e + failed_reverts << rename_info + say "Renaming #{type} from #{path_after_rename} back to "\ + "#{path_before_rename} failed. Review the error and try "\ + "again by running the `down` action. \n"\ + "#{e.message}: \n #{e.backtrace.join("\n")}" + end + end + + failed_reverts.each { |rename_info| redis.lpush(key, rename_info) } end + end - update_column_in_batches(:milestones, :description_html, nil) do |table, query| - query.where(table[:project_id].in(project_ids)) - end + def redis_key_for_type(type) + "rename:#{migration.name}:#{type}" end def file_storage? diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb index da7e2cb2e85..05b86f32ce2 100644 --- a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces.rb @@ -26,6 +26,12 @@ module Gitlab def rename_namespace(namespace) old_full_path, new_full_path = rename_path_for_routable(namespace) + track_rename('namespace', old_full_path, new_full_path) + + rename_namespace_dependencies(namespace, old_full_path, new_full_path) + end + + def rename_namespace_dependencies(namespace, old_full_path, new_full_path) move_repositories(namespace, old_full_path, new_full_path) move_uploads(old_full_path, new_full_path) move_pages(old_full_path, new_full_path) @@ -33,6 +39,23 @@ module Gitlab remove_cached_html_for_projects(projects_for_namespace(namespace).map(&:id)) end + def revert_renames + reverts_for_type('namespace') do |path_before_rename, current_path| + matches_path = MigrationClasses::Route.arel_table[:path].matches(current_path) + namespace = MigrationClasses::Namespace.joins(:route) + .where(matches_path).first&.becomes(MigrationClasses::Namespace) + + if namespace + perform_rename(namespace, current_path, path_before_rename) + + rename_namespace_dependencies(namespace, current_path, path_before_rename) + else + say "Couldn't rename namespace from #{current_path} back to #{path_before_rename}, "\ + "namespace was renamed, or no longer exists at the expected path" + end + end + end + def rename_user(old_username, new_username) MigrationClasses::User.where(username: old_username) .update_all(username: new_username) diff --git a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb index 448717eb744..75a75f61953 100644 --- a/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb +++ b/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects.rb @@ -16,12 +16,37 @@ module Gitlab def rename_project(project) old_full_path, new_full_path = rename_path_for_routable(project) + track_rename('project', old_full_path, new_full_path) + + move_project_folders(project, old_full_path, new_full_path) + end + + def move_project_folders(project, old_full_path, new_full_path) move_repository(project, old_full_path, new_full_path) move_repository(project, "#{old_full_path}.wiki", "#{new_full_path}.wiki") move_uploads(old_full_path, new_full_path) move_pages(old_full_path, new_full_path) end + def revert_renames + reverts_for_type('project') do |path_before_rename, current_path| + matches_path = MigrationClasses::Route.arel_table[:path].matches(current_path) + project = MigrationClasses::Project.joins(:route) + .where(matches_path).first + + if project + perform_rename(project, current_path, path_before_rename) + + move_project_folders(project, current_path, path_before_rename) + else + say "Couldn't rename project from #{current_path} back to "\ + "#{path_before_rename}, project was renamed or no longer "\ + "exists at the expected path." + + end + end + end + def move_repository(project, old_path, new_path) unless gitlab_shell.mv_repository(project.repository_storage_path, old_path, diff --git a/lib/gitlab/database/sha_attribute.rb b/lib/gitlab/database/sha_attribute.rb new file mode 100644 index 00000000000..d9400e04b83 --- /dev/null +++ b/lib/gitlab/database/sha_attribute.rb @@ -0,0 +1,34 @@ +module Gitlab + module Database + BINARY_TYPE = if Gitlab::Database.postgresql? + # PostgreSQL defines its own class with slightly different + # behaviour from the default Binary type. + ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Bytea + else + ActiveRecord::Type::Binary + end + + # Class for casting binary data to hexadecimal SHA1 hashes (and vice-versa). + # + # Using ShaAttribute allows you to store SHA1 values as binary while still + # using them as if they were stored as string values. This gives you the + # ease of use of string values, but without the storage overhead. + class ShaAttribute < BINARY_TYPE + PACK_FORMAT = 'H*'.freeze + + # Casts binary data to a SHA1 in hexadecimal. + def type_cast_from_database(value) + value = super + + value ? value.unpack(PACK_FORMAT)[0] : nil + end + + # Casts a SHA1 in hexadecimal to the proper binary format. + def type_cast_for_database(value) + arg = value ? [value].pack(PACK_FORMAT) : nil + + super(arg) + end + end + end +end diff --git a/lib/gitlab/dependency_linker/base_linker.rb b/lib/gitlab/dependency_linker/base_linker.rb index 7bbd154eb03..d2360583741 100644 --- a/lib/gitlab/dependency_linker/base_linker.rb +++ b/lib/gitlab/dependency_linker/base_linker.rb @@ -52,7 +52,7 @@ module Gitlab # # Will link `user/repo` in `github: "user/repo"` or `:github => "user/repo"` def link_regex(regex, &url_proc) highlighted_lines.map!.with_index do |rich_line, i| - marker = StringRegexMarker.new(plain_lines[i], rich_line.html_safe) + marker = StringRegexMarker.new(plain_lines[i].chomp, rich_line.html_safe) marker.mark(regex, group: :name) do |text, left:, right:| url = yield(text) diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb index 936606152e9..4175746be39 100644 --- a/lib/gitlab/git.rb +++ b/lib/gitlab/git.rb @@ -7,8 +7,10 @@ module Gitlab CommandError = Class.new(StandardError) class << self + include Gitlab::EncodingHelper + def ref_name(ref) - ref.sub(/\Arefs\/(tags|heads)\//, '') + encode! ref.sub(/\Arefs\/(tags|heads)\//, '') end def branch_name(ref) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 0a0c6f76cd3..23d0c8a9bdb 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -113,9 +113,7 @@ module Gitlab def local_branches(sort_by: nil) gitaly_migrate(:local_branches) do |is_enabled| if is_enabled - gitaly_ref_client.local_branches(sort_by: sort_by).map do |gitaly_branch| - Gitlab::Git::Branch.new(self, gitaly_branch.name, gitaly_branch) - end + gitaly_ref_client.local_branches(sort_by: sort_by) else branches(filter: :local, sort_by: sort_by) end @@ -549,32 +547,20 @@ module Gitlab rugged.rev_parse(oid_or_ref_name) end - # Return hash with submodules info for this repository + # Returns url for submodule # # Ex. - # { - # "current_path/rack" => { - # "name" => "original_path/rack", - # "id" => "c67be4624545b4263184c4a0e8f887efd0a66320", - # "url" => "git://github.com/chneukirchen/rack.git" - # }, - # "encoding" => { - # "id" => .... - # } - # } + # @repository.submodule_url_for('master', 'rack') + # # => git@localhost:rack.git # - def submodules(ref) - commit = rev_parse_target(ref) - return {} unless commit + def submodule_url_for(ref, path) + if submodules(ref).any? + submodule = submodules(ref)[path] - begin - content = blob_content(commit, ".gitmodules") - rescue InvalidBlobName - return {} + if submodule + submodule['url'] + end end - - parser = GitmodulesParser.new(content) - fill_submodule_ids(commit, parser.parse) end # Return total commits count accessible from passed ref @@ -912,6 +898,23 @@ module Gitlab private + # We are trying to deprecate this method because it does a lot of work + # but it seems to be used only to look up submodule URL's. + # https://gitlab.com/gitlab-org/gitaly/issues/329 + def submodules(ref) + commit = rev_parse_target(ref) + return {} unless commit + + begin + content = blob_content(commit, ".gitmodules") + rescue InvalidBlobName + return {} + end + + parser = GitmodulesParser.new(content) + fill_submodule_ids(commit, parser.parse) + end + def alternate_object_directories Gitlab::Git::Env.all.values_at(*ALLOWED_OBJECT_DIRECTORIES_VARIABLES).compact end diff --git a/lib/gitlab/gitaly_client/ref.rb b/lib/gitlab/gitaly_client/ref.rb index 6d5f54dd959..2d61992f595 100644 --- a/lib/gitlab/gitaly_client/ref.rb +++ b/lib/gitlab/gitaly_client/ref.rb @@ -1,8 +1,11 @@ module Gitlab module GitalyClient class Ref + include Gitlab::EncodingHelper + # 'repository' is a Gitlab::Git::Repository def initialize(repository) + @repository = repository @gitaly_repo = repository.gitaly_repository @storage = repository.storage end @@ -16,13 +19,13 @@ module Gitlab def branch_names request = Gitaly::FindAllBranchNamesRequest.new(repository: @gitaly_repo) response = GitalyClient.call(@storage, :ref, :find_all_branch_names, request) - consume_refs_response(response, prefix: 'refs/heads/') + consume_refs_response(response) { |name| Gitlab::Git.branch_name(name) } end def tag_names request = Gitaly::FindAllTagNamesRequest.new(repository: @gitaly_repo) response = GitalyClient.call(@storage, :ref, :find_all_tag_names, request) - consume_refs_response(response, prefix: 'refs/tags/') + consume_refs_response(response) { |name| Gitlab::Git.tag_name(name) } end def find_ref_name(commit_id, ref_prefix) @@ -51,20 +54,28 @@ module Gitlab private - def consume_refs_response(response, prefix:) - response.flat_map do |r| - r.names.map { |name| name.sub(/\A#{Regexp.escape(prefix)}/, '') } - end + def consume_refs_response(response) + response.flat_map { |message| message.names.map { |name| yield(name) } } end def sort_by_param(sort_by) + sort_by = 'name' if sort_by == 'name_asc' + enum_value = Gitaly::FindLocalBranchesRequest::SortBy.resolve(sort_by.upcase.to_sym) raise ArgumentError, "Invalid sort_by key `#{sort_by}`" unless enum_value enum_value end def consume_branches_response(response) - response.flat_map { |r| r.branches } + response.flat_map do |message| + message.branches.map do |gitaly_branch| + Gitlab::Git::Branch.new( + @repository, + encode!(gitaly_branch.name.dup), + gitaly_branch.commit_id + ) + end + end end end end diff --git a/lib/gitlab/ldap/access.rb b/lib/gitlab/ldap/access.rb index 8779577258b..fb68627dedf 100644 --- a/lib/gitlab/ldap/access.rb +++ b/lib/gitlab/ldap/access.rb @@ -16,7 +16,7 @@ module Gitlab def self.allowed?(user) self.open(user) do |access| if access.allowed? - Users::UpdateService.new(user, last_credential_check_a: Time.now).execute + Users::UpdateService.new(user, last_credential_check_at: Time.now).execute true else diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 38dc82493cf..f19b325a126 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -20,7 +20,8 @@ module Gitlab counts: { boards: Board.count, ci_builds: ::Ci::Build.count, - ci_pipelines: ::Ci::Pipeline.count, + ci_internal_pipelines: ::Ci::Pipeline.internal.count, + ci_external_pipelines: ::Ci::Pipeline.external.count, ci_runners: ::Ci::Runner.count, ci_triggers: ::Ci::Trigger.count, ci_pipeline_schedules: ::Ci::PipelineSchedule.count, diff --git a/lib/gitlab/view/presenter/base.rb b/lib/gitlab/view/presenter/base.rb index dbfe0941e4d..841fb681435 100644 --- a/lib/gitlab/view/presenter/base.rb +++ b/lib/gitlab/view/presenter/base.rb @@ -15,6 +15,11 @@ module Gitlab super(user, action, overriden_subject || subject) end + # delegate all #can? queries to the subject + def declarative_policy_delegate + subject + end + class_methods do def presenter? true diff --git a/locale/bg/gitlab.po b/locale/bg/gitlab.po index 370aca1f1d9..dd1430700f8 100644 --- a/locale/bg/gitlab.po +++ b/locale/bg/gitlab.po @@ -1,13 +1,14 @@ +# Huang Tao <htve@outlook.com>, 2017. #zanata # Lyubomir Vasilev <lyubomirv@abv.bg>, 2017. #zanata msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-06-12 19:29-0500\n" +"POT-Creation-Date: 2017-06-15 21:59-0500\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"PO-Revision-Date: 2017-06-13 04:23-0400\n" +"PO-Revision-Date: 2017-06-20 06:26-0400\n" "Last-Translator: Lyubomir Vasilev <lyubomirv@abv.bg>\n" "Language-Team: Bulgarian (https://translate.zanata.org/project/view/GitLab)\n" "Language: bg\n" @@ -90,11 +91,8 @@ msgstr "Отмяна в клона" msgid "ChangeTypeAction|Cherry-pick" msgstr "Подбиране" -msgid "ChangeType|commit" -msgstr "подаване" - -msgid "ChangeType|merge request" -msgstr "заявка за сливане" +msgid "ChangeTypeAction|Revert" +msgstr "Отмяна" msgid "Changelog" msgstr "Списък с промени" @@ -105,7 +103,7 @@ msgstr "Графики" msgid "Cherry-pick this commit" msgstr "Подбиране на това подаване" -msgid "Cherry-pick this merge-request" +msgid "Cherry-pick this merge request" msgstr "Подбиране на тази заявка за сливане" msgid "CiStatusLabel|canceled" @@ -170,6 +168,9 @@ msgstr[1] "Подавания" msgid "Commit message" msgstr "Съобщение за подаването" +msgid "CommitBoxTitle|Commit" +msgstr "Подаване" + msgid "CommitMessage|Add %{file_name}" msgstr "Добавяне на „%{file_name}“" @@ -224,9 +225,6 @@ msgstr "Часова зона за „Cron“" msgid "Cron syntax" msgstr "Синтаксис на „Cron“" -msgid "Custom" -msgstr "Персонализиран" - msgid "Custom notification events" msgstr "Персонализирани събития за известяване" @@ -412,6 +410,9 @@ msgstr "Последно подаване" msgid "Learn more in the" msgstr "Научете повече в" +msgid "Learn more in the|pipeline schedules documentation" +msgstr "документацията относно планирането на схеми" + msgid "Leave group" msgstr "Напускане на групата" @@ -578,6 +579,15 @@ msgstr "Поемане на собствеността" msgid "PipelineSchedules|Target" msgstr "Цел" +msgid "PipelineSheduleIntervalPattern|Custom" +msgstr "собствен" + +msgid "Pipeline|with stage" +msgstr "с етап" + +msgid "Pipeline|with stages" +msgstr "с етапи" + msgid "Project '%{project_name}' queued for deletion." msgstr "Проектът „%{project_name}“ е добавен в опашката за изтриване." @@ -677,7 +687,7 @@ msgstr "Заявка за достъп" msgid "Revert this commit" msgstr "Отмяна на това подаване" -msgid "Revert this merge-request" +msgid "Revert this merge request" msgstr "Отмяна на тази заявка за сливане" msgid "Save pipeline schedule" @@ -690,7 +700,7 @@ msgid "Scheduling Pipelines" msgstr "Планиране на схемите" msgid "Search branches and tags" -msgstr "Търсене в клоновете и етикетите" +msgstr "Търсете в клоновете и етикетите" msgid "Select Archive Format" msgstr "Изберете формата на архива" @@ -729,8 +739,8 @@ msgstr "Изходен код" msgid "StarProject|Star" msgstr "Звезда" -msgid "Start a <strong>new merge request</strong> with these changes" -msgstr "Създайте <strong>нова заявка за сливане</strong> с тези промени" +msgid "Start a %{new_merge_request} with these changes" +msgstr "Създайте %{new_merge_request} с тези промени" msgid "Switch branch/tag" msgstr "Преминаване към клон/етикет" @@ -779,7 +789,7 @@ msgid "" "specific branches or tags. Those scheduled pipelines will inherit limited " "project access based on their associated user." msgstr "" -"Този план за схема ще изпълнява схемите в бъдеще, периодично, за определени " +"Планът за схемата ще изпълнява схемите в бъдеще, периодично, за определени " "клонове или етикети. Тези планирани схеми ще наследят ограниченията на " "достъпа до проекта на свързания с тях потребител." @@ -1069,6 +1079,9 @@ msgstr "" msgid "You can only add files when you are on a branch" msgstr "Можете да добавяте файлове само когато се намирате в клон" +msgid "You have reached your project limit" +msgstr "Не можете да създавате повече проекти" + msgid "You must sign in to star a project" msgstr "Трябва да се впишете, за да отбележите проект със звезда" @@ -1115,6 +1128,9 @@ msgid_plural "days" msgstr[0] "ден" msgstr[1] "дни" +msgid "new merge request" +msgstr "нова заявка за сливане" + msgid "notification emails" msgstr "известия по е-поща" @@ -1123,11 +1139,3 @@ msgid_plural "parents" msgstr[0] "родител" msgstr[1] "родители" -msgid "pipeline schedules documentation" -msgstr "документацията за планирането на схеми" - -msgid "with stage" -msgid_plural "with stages" -msgstr[0] "с етап" -msgstr[1] "с етапи" - diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po index 5130572d7ed..bb2b84c67b0 100644 --- a/locale/zh_TW/gitlab.po +++ b/locale/zh_TW/gitlab.po @@ -1,128 +1,460 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the gitlab package. -# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. -# +# Huang Tao <htve@outlook.com>, 2017. #zanata +# Lin Jen-Shin <anonymous@domain.com>, 2017. +# Hazel Yang <anonymous@domain.com>, 2017. +# TzeKei Lee <anonymous@domain.com>, 2017. +# Jerry Ho <a29988122@gmail.com>, 2017. msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2017-05-04 19:24-0500\n" -"Last-Translator: HuangTao <htve@outlook.com>, 2017\n" -"Language-Team: Chinese (Taiwan) (https://www.transifex.com/gitlab-zh/teams/751" -"77/zh_TW/)\n" +"POT-Creation-Date: 2017-06-15 21:59-0500\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: zh_TW\n" -"Plural-Forms: nplurals=1; plural=0;\n" +"PO-Revision-Date: 2017-06-28 11:13-0400\n" +"Last-Translator: Huang Tao <htve@outlook.com>\n" +"Language-Team: Chinese (Taiwan) (https://translate.zanata.org/project/view/GitLab)\n" +"Language: zh-TW\n" +"X-Generator: Zanata 3.9.6\n" +"Plural-Forms: nplurals=1; plural=0\n" + +msgid "%{commit_author_link} committed %{commit_timeago}" +msgstr "%{commit_author_link} 在 %{commit_timeago} 送交" + +msgid "About auto deploy" +msgstr "關於自動部署" + +msgid "Active" +msgstr "啟用" + +msgid "Activity" +msgstr "活動" + +msgid "Add Changelog" +msgstr "新增更新日誌" + +msgid "Add Contribution guide" +msgstr "新增協作指南" + +msgid "Add License" +msgstr "新增授權條款" + +msgid "Add an SSH key to your profile to pull or push via SSH." +msgstr "請先新增 SSH 金鑰到您的個人帳號,才能使用 SSH 來上傳 (push) 或下載 (pull) 。" + +msgid "Add new directory" +msgstr "新增目錄" + +msgid "Archived project! Repository is read-only" +msgstr "此專案已封存!檔案庫 (repository) 為唯讀狀態" msgid "Are you sure you want to delete this pipeline schedule?" +msgstr "確定要刪除此流水線 (pipeline) 排程嗎?" + +msgid "Attach a file by drag & drop or %{upload_link}" +msgstr "拖放檔案到此處或者 %{upload_link}" + +msgid "Branch" +msgid_plural "Branches" +msgstr[0] "分支 (branch) " + +msgid "" +"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, " +"choose a GitLab CI Yaml template and commit your changes. " +"%{link_to_autodeploy_doc}" msgstr "" +"已建立分支 (branch) <strong>%{branch_name}</strong> 。如要設定自動部署, 請選擇合適的 GitLab CI " +"Yaml 模板,然後記得要送交 (commit) 您的編輯內容。%{link_to_autodeploy_doc}\n" + +msgid "Branches" +msgstr "分支 (branch) " + +msgid "Browse files" +msgstr "瀏覽檔案" msgid "ByAuthor|by" -msgstr "作者:" +msgstr "作者:" + +msgid "CI configuration" +msgstr "CI 組態" msgid "Cancel" -msgstr "" +msgstr "取消" + +msgid "ChangeTypeActionLabel|Pick into branch" +msgstr "挑選到分支 (branch) " + +msgid "ChangeTypeActionLabel|Revert in branch" +msgstr "還原分支 (branch) " + +msgid "ChangeTypeAction|Cherry-pick" +msgstr "挑選" + +msgid "ChangeTypeAction|Revert" +msgstr "還原" + +msgid "Changelog" +msgstr "更新日誌" + +msgid "Charts" +msgstr "統計圖" + +msgid "Cherry-pick this commit" +msgstr "挑選此更動記錄 (commit) " + +msgid "Cherry-pick this merge request" +msgstr "挑選此合併請求 (merge request) " + +msgid "CiStatusLabel|canceled" +msgstr "已取消" + +msgid "CiStatusLabel|created" +msgstr "已建立" + +msgid "CiStatusLabel|failed" +msgstr "失敗" + +msgid "CiStatusLabel|manual action" +msgstr "手動操作" + +msgid "CiStatusLabel|passed" +msgstr "已通過" + +msgid "CiStatusLabel|passed with warnings" +msgstr "通過,但有警告訊息" + +msgid "CiStatusLabel|pending" +msgstr "等待中" + +msgid "CiStatusLabel|skipped" +msgstr "已跳過" + +msgid "CiStatusLabel|waiting for manual action" +msgstr "等待手動操作" + +msgid "CiStatusText|blocked" +msgstr "已阻擋" + +msgid "CiStatusText|canceled" +msgstr "已取消" + +msgid "CiStatusText|created" +msgstr "已建立" + +msgid "CiStatusText|failed" +msgstr "失敗" + +msgid "CiStatusText|manual" +msgstr "手動操作" + +msgid "CiStatusText|passed" +msgstr "已通過" + +msgid "CiStatusText|pending" +msgstr "等待中" + +msgid "CiStatusText|skipped" +msgstr "已跳過" + +msgid "CiStatus|running" +msgstr "執行中" msgid "Commit" msgid_plural "Commits" -msgstr[0] "送交" +msgstr[0] "更動記錄 (commit) " + +msgid "Commit message" +msgstr "更動說明 (commit) " + +msgid "CommitBoxTitle|Commit" +msgstr "送交" + +msgid "CommitMessage|Add %{file_name}" +msgstr "建立 %{file_name}" + +msgid "Commits" +msgstr "更動記錄 (commit) " + +msgid "Commits|History" +msgstr "過去更動 (commit) " + +msgid "Committed by" +msgstr "送交者為 " + +msgid "Compare" +msgstr "比較" + +msgid "Contribution guide" +msgstr "協作指南" + +msgid "Contributors" +msgstr "協作者" + +msgid "Copy URL to clipboard" +msgstr "複製網址到剪貼簿" + +msgid "Copy commit SHA to clipboard" +msgstr "複製更動記錄 (commit) 的 SHA 值到剪貼簿" + +msgid "Create New Directory" +msgstr "建立新目錄" + +msgid "Create directory" +msgstr "建立目錄" + +msgid "Create empty bare repository" +msgstr "建立一個新的 bare repository" + +msgid "Create merge request" +msgstr "發出合併請求 (merge request) " + +msgid "Create new..." +msgstr "建立..." + +msgid "CreateNewFork|Fork" +msgstr "分支 (fork) " + +msgid "CreateTag|Tag" +msgstr "建立標籤" msgid "Cron Timezone" +msgstr "Cron 時區" + +msgid "Cron syntax" +msgstr "Cron 語法" + +msgid "Custom notification events" +msgstr "自訂事件通知" + +msgid "" +"Custom notification levels are the same as participating levels. With custom " +"notification levels you will also receive notifications for select events. " +"To find out more, check out %{notification_link}." msgstr "" +"自訂通知層級相當於參與度設定。使用自訂通知層級,您可以只收到特定的事件通知。請參照 %{notification_link} 以獲得更多訊息。" -msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." -msgstr "週期分析概述了你的專案從想法到產品實現,各階段所需的時間。" +msgid "Cycle Analytics" +msgstr "週期分析" + +msgid "" +"Cycle Analytics gives an overview of how much time it takes to go from idea " +"to production in your project." +msgstr "週期分析讓您可以有效的釐清專案從發想到產品推出所花的時間長短。" msgid "CycleAnalyticsStage|Code" msgstr "程式開發" msgid "CycleAnalyticsStage|Issue" -msgstr "議題" +msgstr "議題 (issue) " msgid "CycleAnalyticsStage|Plan" msgstr "計劃" msgid "CycleAnalyticsStage|Production" -msgstr "上線" +msgstr "營運" msgid "CycleAnalyticsStage|Review" msgstr "複閱" msgid "CycleAnalyticsStage|Staging" -msgstr "預備" +msgstr "試營運" msgid "CycleAnalyticsStage|Test" msgstr "測試" +msgid "Define a custom pattern with cron syntax" +msgstr "使用 Cron 語法自訂排程" + msgid "Delete" -msgstr "" +msgstr "刪除" msgid "Deploy" msgid_plural "Deploys" msgstr[0] "部署" msgid "Description" -msgstr "" +msgstr "描述" + +msgid "Directory name" +msgstr "目錄名稱" + +msgid "Don't show again" +msgstr "不再顯示" + +msgid "Download" +msgstr "下載" + +msgid "Download tar" +msgstr "下載 tar" + +msgid "Download tar.bz2" +msgstr "下載 tar.bz2" + +msgid "Download tar.gz" +msgstr "下載 tar.gz" + +msgid "Download zip" +msgstr "下載 zip" + +msgid "DownloadArtifacts|Download" +msgstr "下載" + +msgid "DownloadCommit|Email Patches" +msgstr "電子郵件修補檔案 (patch)" + +msgid "DownloadCommit|Plain Diff" +msgstr "差異檔 (diff)" + +msgid "DownloadSource|Download" +msgstr "下載原始碼" msgid "Edit" -msgstr "" +msgstr "編輯" msgid "Edit Pipeline Schedule %{id}" -msgstr "" +msgstr "編輯 %{id} 流水線 (pipeline) 排程" + +msgid "Every day (at 4:00am)" +msgstr "每日執行(淩晨四點)" + +msgid "Every month (on the 1st at 4:00am)" +msgstr "每月執行(每月一日淩晨四點)" + +msgid "Every week (Sundays at 4:00am)" +msgstr "每週執行(週日淩晨 四點)" msgid "Failed to change the owner" -msgstr "" +msgstr "無法變更所有權" msgid "Failed to remove the pipeline schedule" -msgstr "" +msgstr "無法刪除流水線 (pipeline) 排程" -msgid "Filter" -msgstr "" +msgid "Files" +msgstr "檔案" + +msgid "Find by path" +msgstr "以路徑搜尋" + +msgid "Find file" +msgstr "搜尋檔案" msgid "FirstPushedBy|First" -msgstr "首次推送" +msgstr "首次推送 (push) " msgid "FirstPushedBy|pushed by" -msgstr "推送者:" +msgstr "推送者 (push) :" + +msgid "Fork" +msgid_plural "Forks" +msgstr[0] "分支 (fork) " + +msgid "ForkedFromProjectPath|Forked from" +msgstr "分支 (fork) 自" msgid "From issue creation until deploy to production" -msgstr "從議題建立至線上部署" +msgstr "從議題 (issue) 建立直到部署至營運環境" msgid "From merge request merge until deploy to production" -msgstr "從請求被合併後至線上部署" +msgstr "從請求被合併後 (merge request merged) 直到部署至營運環境" + +msgid "Go to your fork" +msgstr "前往您的分支 (fork) " + +msgid "GoToYourFork|Fork" +msgstr "前往您的分支 (fork) " + +msgid "Home" +msgstr "首頁" + +msgid "Housekeeping successfully started" +msgstr "已開始維護" + +msgid "Import repository" +msgstr "匯入檔案庫 (repository)" msgid "Interval Pattern" -msgstr "" +msgstr "循環週期" msgid "Introducing Cycle Analytics" msgstr "週期分析簡介" +msgid "LFSStatus|Disabled" +msgstr "停用" + +msgid "LFSStatus|Enabled" +msgstr "啟用" + msgid "Last %d day" msgid_plural "Last %d days" -msgstr[0] "最後 %d 天" +msgstr[0] "最近 %d 天" msgid "Last Pipeline" -msgstr "" +msgstr "最新流水線 (pipeline) " + +msgid "Last Update" +msgstr "最後更新" + +msgid "Last commit" +msgstr "最後更動記錄 (commit) " + +msgid "Learn more in the" +msgstr "了解更多" + +msgid "Learn more in the|pipeline schedules documentation" +msgstr "流水線 (pipeline) 排程說明文件" + +msgid "Leave group" +msgstr "退出群組" + +msgid "Leave project" +msgstr "退出專案" msgid "Limited to showing %d event at most" msgid_plural "Limited to showing %d events at most" -msgstr[0] "最多顯示 %d 個事件" +msgstr[0] "限制最多顯示 %d 個事件" msgid "Median" msgstr "中位數" +msgid "MissingSSHKeyWarningLink|add an SSH key" +msgstr "新增 SSH 金鑰" + msgid "New Issue" msgid_plural "New Issues" -msgstr[0] "新議題" +msgstr[0] "建立議題 (issue) " msgid "New Pipeline Schedule" -msgstr "" +msgstr "建立流水線 (pipeline) 排程" + +msgid "New branch" +msgstr "新分支 (branch) " + +msgid "New directory" +msgstr "新增目錄" + +msgid "New file" +msgstr "新增檔案" + +msgid "New issue" +msgstr "新增議題 (issue) " + +msgid "New merge request" +msgstr "新增合併請求 (merge request) " + +msgid "New schedule" +msgstr "新增排程" + +msgid "New snippet" +msgstr "新文字片段" + +msgid "New tag" +msgstr "新增標籤" + +msgid "No repository" +msgstr "找不到檔案庫 (repository)" msgid "No schedules" -msgstr "" +msgstr "沒有排程" msgid "Not available" msgstr "無法使用" @@ -130,135 +462,502 @@ msgstr "無法使用" msgid "Not enough data" msgstr "資料不足" +msgid "Notification events" +msgstr "事件通知" + +msgid "NotificationEvent|Close issue" +msgstr "關閉議題 (issue) " + +msgid "NotificationEvent|Close merge request" +msgstr "關閉合併請求 (merge request) " + +msgid "NotificationEvent|Failed pipeline" +msgstr "流水線 (pipeline) 失敗" + +msgid "NotificationEvent|Merge merge request" +msgstr "合併請求 (merge request) 被合併" + +msgid "NotificationEvent|New issue" +msgstr "新增議題 (issue) " + +msgid "NotificationEvent|New merge request" +msgstr "新增合併請求 (merge request) " + +msgid "NotificationEvent|New note" +msgstr "新增評論" + +msgid "NotificationEvent|Reassign issue" +msgstr "重新指派議題 (issue) " + +msgid "NotificationEvent|Reassign merge request" +msgstr "重新指派合併請求 (merge request) " + +msgid "NotificationEvent|Reopen issue" +msgstr "重啟議題 (issue)" + +msgid "NotificationEvent|Successful pipeline" +msgstr "流水線 (pipeline) 成功完成" + +msgid "NotificationLevel|Custom" +msgstr "自訂" + +msgid "NotificationLevel|Disabled" +msgstr "停用" + +msgid "NotificationLevel|Global" +msgstr "全域" + +msgid "NotificationLevel|On mention" +msgstr "提及" + +msgid "NotificationLevel|Participate" +msgstr "參與" + +msgid "NotificationLevel|Watch" +msgstr "關注" + +msgid "OfSearchInADropdown|Filter" +msgstr "篩選" + msgid "OpenedNDaysAgo|Opened" msgstr "開始於" +msgid "Options" +msgstr "選項" + msgid "Owner" -msgstr "" +msgstr "所有權" + +msgid "Pipeline" +msgstr "流水線 (pipeline) " msgid "Pipeline Health" -msgstr "流水線健康指標" +msgstr "流水線 (pipeline) 健康指數" msgid "Pipeline Schedule" -msgstr "" +msgstr "流水線 (pipeline) 排程" msgid "Pipeline Schedules" -msgstr "" +msgstr "流水線 (pipeline) 排程" msgid "PipelineSchedules|Activated" -msgstr "" +msgstr "是否啟用" msgid "PipelineSchedules|Active" -msgstr "" +msgstr "已啟用" msgid "PipelineSchedules|All" -msgstr "" +msgstr "所有" msgid "PipelineSchedules|Inactive" -msgstr "" +msgstr "未啟用" msgid "PipelineSchedules|Next Run" -msgstr "" +msgstr "下次執行時間" msgid "PipelineSchedules|None" -msgstr "" +msgstr "無" msgid "PipelineSchedules|Provide a short description for this pipeline" -msgstr "" +msgstr "請簡單說明此流水線 (pipeline) " msgid "PipelineSchedules|Take ownership" -msgstr "" +msgstr "取得所有權" msgid "PipelineSchedules|Target" -msgstr "" +msgstr "目標" + +msgid "PipelineSheduleIntervalPattern|Custom" +msgstr "自訂" + +msgid "Pipeline|with stage" +msgstr "於階段" + +msgid "Pipeline|with stages" +msgstr "於階段" + +msgid "Project '%{project_name}' queued for deletion." +msgstr "專案 '%{project_name}' 已加入刪除佇列。" + +msgid "Project '%{project_name}' was successfully created." +msgstr "專案 '%{project_name}' 建立完成。" + +msgid "Project '%{project_name}' was successfully updated." +msgstr "專案 '%{project_name}' 更新完成。" + +msgid "Project '%{project_name}' will be deleted." +msgstr "專案 '%{project_name}' 將被刪除。" + +msgid "Project access must be granted explicitly to each user." +msgstr "專案權限必須一一指派給每個使用者。" + +msgid "Project export could not be deleted." +msgstr "匯出的專案無法被刪除。" + +msgid "Project export has been deleted." +msgstr "匯出的專案已被刪除。" + +msgid "" +"Project export link has expired. Please generate a new export from your " +"project settings." +msgstr "專案的匯出連結已失效。請到專案設定中產生新的連結。" + +msgid "Project export started. A download link will be sent by email." +msgstr "專案導出已開始。完成後下載連結會送到您的信箱。" + +msgid "Project home" +msgstr "專案首頁" + +msgid "ProjectFeature|Disabled" +msgstr "停用" + +msgid "ProjectFeature|Everyone with access" +msgstr "任何人都可存取" + +msgid "ProjectFeature|Only team members" +msgstr "只有團隊成員可以存取" + +msgid "ProjectFileTree|Name" +msgstr "名稱" + +msgid "ProjectLastActivity|Never" +msgstr "從未" msgid "ProjectLifecycle|Stage" -msgstr "專案生命週期" +msgstr "階段" + +msgid "ProjectNetworkGraph|Graph" +msgstr "分支圖" msgid "Read more" -msgstr "了解更多" +msgstr "瞭解更多" + +msgid "Readme" +msgstr "說明檔" + +msgid "RefSwitcher|Branches" +msgstr "分支 (branch) " + +msgid "RefSwitcher|Tags" +msgstr "標籤" msgid "Related Commits" -msgstr "相關的送交" +msgstr "相關的更動記錄 (commit) " msgid "Related Deployed Jobs" msgstr "相關的部署作業" msgid "Related Issues" -msgstr "相關的議題" +msgstr "相關的議題 (issue) " msgid "Related Jobs" msgstr "相關的作業" msgid "Related Merge Requests" -msgstr "相關的合併請求" +msgstr "相關的合併請求 (merge request) " msgid "Related Merged Requests" msgstr "相關已合併的請求" +msgid "Remind later" +msgstr "稍後提醒" + +msgid "Remove project" +msgstr "刪除專案" + +msgid "Request Access" +msgstr "申請權限" + +msgid "Revert this commit" +msgstr "還原此更動記錄 (commit)" + +msgid "Revert this merge request" +msgstr "還原此合併請求 (merge request) " + msgid "Save pipeline schedule" -msgstr "" +msgstr "保存流水線 (pipeline) 排程" msgid "Schedule a new pipeline" -msgstr "" +msgstr "建立流水線 (pipeline) 排程" + +msgid "Scheduling Pipelines" +msgstr "流水線 (pipeline) 計劃" + +msgid "Search branches and tags" +msgstr "搜尋分支 (branch) 和標籤" + +msgid "Select Archive Format" +msgstr "選擇下載格式" msgid "Select a timezone" -msgstr "" +msgstr "選擇時區" msgid "Select target branch" -msgstr "" +msgstr "選擇目標分支 (branch) " + +msgid "Set a password on your account to pull or push via %{protocol}" +msgstr "請先設定密碼,才能使用 %{protocol} 來上傳 (push) 或下載 (pull) 。" + +msgid "Set up CI" +msgstr "設定 CI" + +msgid "Set up Koding" +msgstr "設定 Koding" + +msgid "Set up auto deploy" +msgstr "設定自動部署" + +msgid "SetPasswordToCloneLink|set a password" +msgstr "設定密碼" msgid "Showing %d event" msgid_plural "Showing %d events" msgstr[0] "顯示 %d 個事件" +msgid "Source code" +msgstr "原始碼" + +msgid "StarProject|Star" +msgstr "收藏" + +msgid "Start a %{new_merge_request} with these changes" +msgstr "以這些改動建立一個新的 %{new_merge_request} " + +msgid "Switch branch/tag" +msgstr "切換分支 (branch) 或標籤" + +msgid "Tag" +msgid_plural "Tags" +msgstr[0] "標籤" + +msgid "Tags" +msgstr "標籤" + msgid "Target Branch" -msgstr "" +msgstr "目標分支 (branch) " -msgid "The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request." -msgstr "程式開發階段顯示從第一次送交到建立合併請求的時間。建立第一個合併請求後,資料將自動填入。" +msgid "" +"The coding stage shows the time from the first commit to creating the merge " +"request. The data will automatically be added here once you create your " +"first merge request." +msgstr "" +"程式開發階段顯示從第一次更動記錄 (commit) 到建立合併請求 (merge request) 的時間。建立第一個合併請求後,資料將自動填入。" msgid "The collection of events added to the data gathered for that stage." -msgstr "與該階段相關的事件。" +msgstr "該階段中的相關事件集合。" + +msgid "The fork relationship has been removed." +msgstr "分支與主幹間的關聯 (fork relationship) 已被刪除。" -msgid "The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage." -msgstr "議題階段顯示從議題建立到設置里程碑、或將該議題加至議題看板的時間。建立第一個議題後,資料將自動填入。" +msgid "" +"The issue stage shows the time it takes from creating an issue to assigning " +"the issue to a milestone, or add the issue to a list on your Issue Board. " +"Begin creating issues to see data for this stage." +msgstr "" +"議題 (issue) 階段顯示從議題建立到設定里程碑所花的時間,或是議題被分類到議題看板 (issue board) " +"中所花的時間。建立第一個議題後,資料將自動填入。" msgid "The phase of the development lifecycle." -msgstr "專案開發生命週期的各個階段。" +msgstr "專案開發週期的各個階段。" -msgid "The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit." -msgstr "計劃階段所顯示的是議題被排程後至第一個送交被推送的時間。一旦完成(或執行)首次的推送,資料將自動填入。" +msgid "" +"The pipelines schedule runs pipelines in the future, repeatedly, for " +"specific branches or tags. Those scheduled pipelines will inherit limited " +"project access based on their associated user." +msgstr "" +"在指定了特定分支 (branch) 或標籤後,此處的流水線 (pipeline) 排程會不斷地重複執行。\n" +"流水線排程的存取權限與專案本身相同。" -msgid "The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle." -msgstr "上線階段顯示從建立一個議題到部署程式至線上的總時間。當完成從想法到產品實現的循環後,資料將自動填入。" +msgid "" +"The planning stage shows the time from the previous step to pushing your " +"first commit. This time will be added automatically once you push your first " +"commit." +msgstr "計劃階段顯示從更動記錄 (commit) 被排程至第一個推送的時間。第一次推送之後,資料將自動填入。" + +msgid "" +"The production stage shows the total time it takes between creating an issue " +"and deploying the code to production. The data will be automatically added " +"once you have completed the full idea to production cycle." +msgstr "營運階段顯示從建立議題 (issue) 到部署程式上線所花的時間。完成從發想到上線的完整開發週期後,資料將自動填入。" -msgid "The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request." -msgstr "複閱階段顯示從合併請求建立後至被合併的時間。當建立第一個合併請求後,資料將自動填入。" +msgid "The project can be accessed by any logged in user." +msgstr "本專案可讓任何已登入的使用者存取" + +msgid "The project can be accessed without any authentication." +msgstr "本專案可讓任何人存取" + +msgid "The repository for this project does not exist." +msgstr "本專案沒有檔案庫 (repository) " + +msgid "" +"The review stage shows the time from creating the merge request to merging " +"it. The data will automatically be added after you merge your first merge " +"request." +msgstr "" +"複閱階段顯示從合併請求 (merge request) 建立後至被合併的時間。當建立第一個合併請求 (merge request) 後,資料將自動填入。" -msgid "The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time." -msgstr "預備階段顯示從合併請求被合併後至部署上線的時間。當第一次部署上線後,資料將自動填入。" +msgid "" +"The staging stage shows the time between merging the MR and deploying code " +"to the production environment. The data will be automatically added once you " +"deploy to production for the first time." +msgstr "試營運段顯示從合併請求 (merge request) 被合併後至部署營運的時間。當第一次部署營運後,資料將自動填入" -msgid "The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running." -msgstr "測試階段顯示相關合併請求的流水線所花的時間。當第一個流水線運作完畢後,資料將自動填入。" +msgid "" +"The testing stage shows the time GitLab CI takes to run every pipeline for " +"the related merge request. The data will automatically be added after your " +"first pipeline finishes running." +msgstr "" +"測試階段顯示相關合併請求 (merge request) 的流水線 (pipeline) 所花的時間。當第一個流水線 (pipeline) " +"執行完畢後,資料將自動填入。" msgid "The time taken by each data entry gathered by that stage." -msgstr "每筆該階段相關資料所花的時間。" +msgstr "該階段中每一個資料項目所花的時間。" -msgid "The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6." +msgid "" +"The value lying at the midpoint of a series of observed values. E.g., " +"between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 =" +" 6." msgstr "中位數是一個數列中最中間的值。例如在 3、5、9 之間,中位數是 5。在 3、5、7、8 之間,中位數是 (5 + 7)/ 2 = 6。" +msgid "" +"This means you can not push code until you create an empty repository or " +"import existing one." +msgstr "這代表在您建立一個空的檔案庫 (repository) 或是匯入一個現存的檔案庫之前,您將無法上傳更新 (push) 。" + msgid "Time before an issue gets scheduled" -msgstr "議題等待排程的時間" +msgstr "議題 (issue) 被列入日程表的時間" msgid "Time before an issue starts implementation" -msgstr "議題等待開始實作的時間" +msgstr "議題 (issue) 等待開始實作的時間" msgid "Time between merge request creation and merge/close" -msgstr "合併請求被合併或是關閉的時間" +msgstr "合併請求 (merge request) 從建立到被合併或是關閉的時間" msgid "Time until first merge request" -msgstr "第一個合併請求被建立前的時間" +msgstr "第一個合併請求 (merge request) 被建立前的時間" + +msgid "Timeago|%s days ago" +msgstr " %s 天前" + +msgid "Timeago|%s days remaining" +msgstr "剩下 %s 天" + +msgid "Timeago|%s hours remaining" +msgstr "剩下 %s 小時" + +msgid "Timeago|%s minutes ago" +msgstr " %s 分鐘前" + +msgid "Timeago|%s minutes remaining" +msgstr "剩下 %s 分鐘" + +msgid "Timeago|%s months ago" +msgstr " %s 個月前" + +msgid "Timeago|%s months remaining" +msgstr "剩下 %s 月" + +msgid "Timeago|%s seconds remaining" +msgstr "剩下 %s 秒" + +msgid "Timeago|%s weeks ago" +msgstr " %s 週前" + +msgid "Timeago|%s weeks remaining" +msgstr "剩下 %s 週" + +msgid "Timeago|%s years ago" +msgstr " %s 年前" + +msgid "Timeago|%s years remaining" +msgstr "剩下 %s 年" + +msgid "Timeago|1 day remaining" +msgstr "剩下 1 天" + +msgid "Timeago|1 hour remaining" +msgstr "剩下 1 小時" + +msgid "Timeago|1 minute remaining" +msgstr "剩下 1 分鐘" + +msgid "Timeago|1 month remaining" +msgstr "剩下 1 個月" + +msgid "Timeago|1 week remaining" +msgstr "剩下 1 週" + +msgid "Timeago|1 year remaining" +msgstr "剩下 1 年" + +msgid "Timeago|Past due" +msgstr "逾期" + +msgid "Timeago|a day ago" +msgstr " 1 天前" + +msgid "Timeago|a month ago" +msgstr " 1 個月前" + +msgid "Timeago|a week ago" +msgstr " 1 週前" + +msgid "Timeago|a while" +msgstr "剛剛" + +msgid "Timeago|a year ago" +msgstr " 1 年前" + +msgid "Timeago|about %s hours ago" +msgstr "約 %s 小時前" + +msgid "Timeago|about a minute ago" +msgstr "約 1 分鐘前" + +msgid "Timeago|about an hour ago" +msgstr "約 1 小時前" + +msgid "Timeago|in %s days" +msgstr " %s 天後" + +msgid "Timeago|in %s hours" +msgstr " %s 小時後" + +msgid "Timeago|in %s minutes" +msgstr " %s 分鐘後" + +msgid "Timeago|in %s months" +msgstr " %s 個月後" + +msgid "Timeago|in %s seconds" +msgstr " %s 秒後" + +msgid "Timeago|in %s weeks" +msgstr " %s 週後" + +msgid "Timeago|in %s years" +msgstr " %s 年後" + +msgid "Timeago|in 1 day" +msgstr " 1 天後" + +msgid "Timeago|in 1 hour" +msgstr " 1 小時後" + +msgid "Timeago|in 1 minute" +msgstr " 1 分鐘後" + +msgid "Timeago|in 1 month" +msgstr " 1 個月後" + +msgid "Timeago|in 1 week" +msgstr " 1 週後" + +msgid "Timeago|in 1 year" +msgstr " 1 年後" + +msgid "Timeago|less than a minute ago" +msgstr "不到 1 分鐘前" msgid "Time|hr" msgid_plural "Time|hrs" @@ -275,7 +974,28 @@ msgid "Total Time" msgstr "總時間" msgid "Total test time for all commits/merges" -msgstr "所有送交和合併的總測試時間" +msgstr "合併 (merge) 與更動記錄 (commit) 的總測試時間" + +msgid "Unstar" +msgstr "取消收藏" + +msgid "Upload New File" +msgstr "上傳新檔案" + +msgid "Upload file" +msgstr "上傳檔案" + +msgid "Use your global notification setting" +msgstr "使用全域通知設定" + +msgid "VisibilityLevel|Internal" +msgstr "內部" + +msgid "VisibilityLevel|Private" +msgstr "私有" + +msgid "VisibilityLevel|Public" +msgstr "公開" msgid "Want to see the data? Please ask an administrator for access." msgstr "權限不足。如需查看相關資料,請向管理員申請權限。" @@ -283,12 +1003,85 @@ msgstr "權限不足。如需查看相關資料,請向管理員申請權限。 msgid "We don't have enough data to show this stage." msgstr "因該階段的資料不足而無法顯示相關資訊" -msgid "You have reached your project limit" +msgid "Withdraw Access Request" +msgstr "取消權限申請" + +msgid "" +"You are going to remove %{project_name_with_namespace}.\n" +"Removed project CANNOT be restored!\n" +"Are you ABSOLUTELY sure?" msgstr "" +"即將要刪除 %{project_name_with_namespace}。\n" +"被刪除的專案完全無法救回來喔!\n" +"真的「100%確定」要這麼做嗎?" + +msgid "" +"You are going to remove the fork relationship to source project " +"%{forked_from_project}. Are you ABSOLUTELY sure?" +msgstr "" +"將要刪除本分支專案與主幹的所有關聯 (fork relationship) 。 %{forked_from_project} " +"真的「100%確定」要這麼做嗎?" + +msgid "" +"You are going to transfer %{project_name_with_namespace} to another owner. " +"Are you ABSOLUTELY sure?" +msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?" + +msgid "You can only add files when you are on a branch" +msgstr "只能在分支 (branch) 上建立檔案" + +msgid "You have reached your project limit" +msgstr "您已達到專案數量限制" + +msgid "You must sign in to star a project" +msgstr "必須登入才能收藏專案" msgid "You need permission." -msgstr "您需要相關的權限。" +msgstr "需要權限才能這麼做。" + +msgid "You will not get any notifications via email" +msgstr "不會收到任何通知郵件" + +msgid "You will only receive notifications for the events you choose" +msgstr "只接收您選擇的事件通知" + +msgid "" +"You will only receive notifications for threads you have participated in" +msgstr "只接收參與主題的通知" + +msgid "You will receive notifications for any activity" +msgstr "接收所有活動的通知" + +msgid "" +"You will receive notifications only for comments in which you were " +"@mentioned" +msgstr "只接收評論中提及(@)您的通知" + +msgid "" +"You won't be able to pull or push project code via %{protocol} until you " +"%{set_password_link} on your account" +msgstr "" +"在帳號上 %{set_password_link} 之前, 將無法使用 %{protocol} 上傳 (push) 或下載 (pull) 程式碼。" + +msgid "" +"You won't be able to pull or push project code via SSH until you " +"%{add_ssh_key_link} to your profile" +msgstr "在個人帳號中 %{add_ssh_key_link} 之前, 將無法使用 SSH 上傳 (push) 或下載 (pull) 程式碼。" + +msgid "Your name" +msgstr "您的名字" msgid "day" msgid_plural "days" msgstr[0] "天" + +msgid "new merge request" +msgstr "建立合併請求" + +msgid "notification emails" +msgstr "通知信" + +msgid "parent" +msgid_plural "parents" +msgstr[0] "上層" + diff --git a/spec/controllers/abuse_reports_controller_spec.rb b/spec/controllers/abuse_reports_controller_spec.rb index 80a418feb3e..ada011e7595 100644 --- a/spec/controllers/abuse_reports_controller_spec.rb +++ b/spec/controllers/abuse_reports_controller_spec.rb @@ -13,6 +13,31 @@ describe AbuseReportsController do sign_in(reporter) end + describe 'GET new' do + context 'when the user has already been deleted' do + it 'redirects the reporter to root_path' do + user_id = user.id + user.destroy + + get :new, { user_id: user_id } + + expect(response).to redirect_to root_path + expect(flash[:alert]).to eq('Cannot create the abuse report. The user has been deleted.') + end + end + + context 'when the user has already been blocked' do + it 'redirects the reporter to the user\'s profile' do + user.block + + get :new, { user_id: user.id } + + expect(response).to redirect_to user + expect(flash[:alert]).to eq('Cannot create the abuse report. This user has been blocked.') + end + end + end + describe 'POST create' do context 'with valid attributes' do it 'saves the abuse report' do diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index c20cf6a4291..561bc219bb4 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -235,7 +235,7 @@ describe Projects::BlobController do put :update, default_params expect(response).to redirect_to( - new_namespace_project_merge_request_path( + namespace_project_new_merge_request_path( forked_project.namespace, forked_project, merge_request: { diff --git a/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb new file mode 100644 index 00000000000..9278ac8edd8 --- /dev/null +++ b/spec/controllers/projects/merge_requests/conflicts_controller_spec.rb @@ -0,0 +1,307 @@ +require 'spec_helper' + +describe Projects::MergeRequests::ConflictsController do + let(:project) { create(:project) } + let(:user) { project.owner } + let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } + let(:merge_request_with_conflicts) do + create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr| + mr.mark_as_unmergeable + end + end + + before do + sign_in(user) + end + + describe 'GET show' do + context 'when the conflicts cannot be resolved in the UI' do + before do + allow_any_instance_of(Gitlab::Conflict::Parser) + .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile) + + get :show, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project, + id: merge_request_with_conflicts.iid, + format: 'json' + end + + it 'returns a 200 status code' do + expect(response).to have_http_status(:ok) + end + + it 'returns JSON with a message' do + expect(json_response.keys).to contain_exactly('message', 'type') + end + end + + context 'with valid conflicts' do + before do + get :show, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project, + id: merge_request_with_conflicts.iid, + format: 'json' + end + + it 'matches the schema' do + expect(response).to match_response_schema('conflicts') + end + + it 'includes meta info about the MR' do + expect(json_response['commit_message']).to include('Merge branch') + expect(json_response['commit_sha']).to match(/\h{40}/) + expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch) + expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch) + end + + it 'includes each file that has conflicts' do + filenames = json_response['files'].map { |file| file['new_path'] } + + expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb') + end + + it 'splits files into sections with lines' do + json_response['files'].each do |file| + file['sections'].each do |section| + expect(section).to include('conflict', 'lines') + + section['lines'].each do |line| + if section['conflict'] + expect(line['type']).to be_in(%w(old new)) + expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer)) + else + if line['type'].nil? + expect(line['old_line']).not_to eq(nil) + expect(line['new_line']).not_to eq(nil) + else + expect(line['type']).to eq('match') + expect(line['old_line']).to eq(nil) + expect(line['new_line']).to eq(nil) + end + end + end + end + end + end + + it 'has unique section IDs across files' do + section_ids = json_response['files'].flat_map do |file| + file['sections'].map { |section| section['id'] }.compact + end + + expect(section_ids.uniq).to eq(section_ids) + end + end + end + + describe 'GET conflict_for_path' do + def conflict_for_path(path) + get :conflict_for_path, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project, + id: merge_request_with_conflicts.iid, + old_path: path, + new_path: path, + format: 'json' + end + + context 'when the conflicts cannot be resolved in the UI' do + before do + allow_any_instance_of(Gitlab::Conflict::Parser) + .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile) + + conflict_for_path('files/ruby/regex.rb') + end + + it 'returns a 404 status code' do + expect(response).to have_http_status(:not_found) + end + end + + context 'when the file does not exist cannot be resolved in the UI' do + before do + conflict_for_path('files/ruby/regexp.rb') + end + + it 'returns a 404 status code' do + expect(response).to have_http_status(:not_found) + end + end + + context 'with an existing file' do + let(:path) { 'files/ruby/regex.rb' } + + before do + conflict_for_path(path) + end + + it 'returns a 200 status code' do + expect(response).to have_http_status(:ok) + end + + it 'returns the file in JSON format' do + content = MergeRequests::Conflicts::ListService.new(merge_request_with_conflicts) + .file_for_path(path, path) + .content + + expect(json_response).to include('old_path' => path, + 'new_path' => path, + 'blob_icon' => 'file-text-o', + 'blob_path' => a_string_ending_with(path), + 'blob_ace_mode' => 'ruby', + 'content' => content) + end + end + end + + context 'POST resolve_conflicts' do + let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha } + + def resolve_conflicts(files) + post :resolve_conflicts, + namespace_id: merge_request_with_conflicts.project.namespace.to_param, + project_id: merge_request_with_conflicts.project, + id: merge_request_with_conflicts.iid, + format: 'json', + files: files, + commit_message: 'Commit message' + end + + context 'with valid params' do + before do + resolved_files = [ + { + 'new_path' => 'files/ruby/popen.rb', + 'old_path' => 'files/ruby/popen.rb', + 'sections' => { + '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head' + } + }, { + 'new_path' => 'files/ruby/regex.rb', + 'old_path' => 'files/ruby/regex.rb', + 'sections' => { + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin' + } + } + ] + + resolve_conflicts(resolved_files) + end + + it 'creates a new commit on the branch' do + expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha) + expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message') + end + + it 'returns an OK response' do + expect(response).to have_http_status(:ok) + end + end + + context 'when sections are missing' do + before do + resolved_files = [ + { + 'new_path' => 'files/ruby/popen.rb', + 'old_path' => 'files/ruby/popen.rb', + 'sections' => { + '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head' + } + }, { + 'new_path' => 'files/ruby/regex.rb', + 'old_path' => 'files/ruby/regex.rb', + 'sections' => { + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head' + } + } + ] + + resolve_conflicts(resolved_files) + end + + it 'returns a 400 error' do + expect(response).to have_http_status(:bad_request) + end + + it 'has a message with the name of the first missing section' do + expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21') + end + + it 'does not create a new commit' do + expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha) + end + end + + context 'when files are missing' do + before do + resolved_files = [ + { + 'new_path' => 'files/ruby/regex.rb', + 'old_path' => 'files/ruby/regex.rb', + 'sections' => { + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin' + } + } + ] + + resolve_conflicts(resolved_files) + end + + it 'returns a 400 error' do + expect(response).to have_http_status(:bad_request) + end + + it 'has a message with the name of the missing file' do + expect(json_response['message']).to include('files/ruby/popen.rb') + end + + it 'does not create a new commit' do + expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha) + end + end + + context 'when a file has identical content to the conflict' do + before do + content = MergeRequests::Conflicts::ListService.new(merge_request_with_conflicts) + .file_for_path('files/ruby/popen.rb', 'files/ruby/popen.rb') + .content + + resolved_files = [ + { + 'new_path' => 'files/ruby/popen.rb', + 'old_path' => 'files/ruby/popen.rb', + 'content' => content + }, { + 'new_path' => 'files/ruby/regex.rb', + 'old_path' => 'files/ruby/regex.rb', + 'sections' => { + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', + '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin' + } + } + ] + + resolve_conflicts(resolved_files) + end + + it 'returns a 400 error' do + expect(response).to have_http_status(:bad_request) + end + + it 'has a message with the path of the problem file' do + expect(json_response['message']).to include('files/ruby/popen.rb') + end + + it 'does not create a new commit' do + expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha) + end + end + end +end diff --git a/spec/controllers/projects/merge_requests/creations_controller_spec.rb b/spec/controllers/projects/merge_requests/creations_controller_spec.rb new file mode 100644 index 00000000000..f9d8f0f5fcf --- /dev/null +++ b/spec/controllers/projects/merge_requests/creations_controller_spec.rb @@ -0,0 +1,120 @@ +require 'spec_helper' + +describe Projects::MergeRequests::CreationsController do + let(:project) { create(:project) } + let(:user) { project.owner } + let(:fork_project) { create(:forked_project_with_submodules) } + + before do + fork_project.team << [user, :master] + + sign_in(user) + end + + describe 'GET new' do + context 'merge request that removes a submodule' do + render_views + + it 'renders new merge request widget template' do + get :new, + namespace_id: fork_project.namespace.to_param, + project_id: fork_project, + merge_request: { + source_branch: 'remove-submodule', + target_branch: 'master' + } + + expect(response).to be_success + end + end + end + + describe 'GET pipelines' do + before do + create(:ci_pipeline, sha: fork_project.commit('remove-submodule').id, + ref: 'remove-submodule', + project: fork_project) + end + + it 'renders JSON including serialized pipelines' do + get :pipelines, + namespace_id: fork_project.namespace.to_param, + project_id: fork_project, + merge_request: { + source_branch: 'remove-submodule', + target_branch: 'master' + }, + format: :json + + expect(response).to be_ok + expect(json_response).to have_key 'pipelines' + expect(json_response['pipelines']).not_to be_empty + end + end + + describe 'GET diff_for_path' do + def diff_for_path(extra_params = {}) + params = { + namespace_id: project.namespace.to_param, + project_id: project, + format: 'json' + } + + get :diff_for_path, params.merge(extra_params) + end + + let(:existing_path) { 'files/ruby/feature.rb' } + + context 'when both branches are in the same project' do + it 'disables diff notes' do + diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_branch: 'feature', target_branch: 'master' }) + + expect(assigns(:diff_notes_disabled)).to be_truthy + end + + it 'only renders the diffs for the path given' do + expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| + expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) + meth.call(diffs) + end + + diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_branch: 'feature', target_branch: 'master' }) + end + end + + context 'when the source branch is in a different project to the target' do + let(:other_project) { create(:project) } + + before do + other_project.team << [user, :master] + end + + context 'when the path exists in the diff' do + it 'disables diff notes' do + diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' }) + + expect(assigns(:diff_notes_disabled)).to be_truthy + end + + it 'only renders the diffs for the path given' do + expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| + expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) + meth.call(diffs) + end + + diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' }) + end + end + + context 'when the path does not exist in the diff' do + before do + diff_for_path(old_path: 'files/ruby/nopen.rb', new_path: 'files/ruby/nopen.rb', merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' }) + end + + it 'returns a 404' do + expect(response).to have_http_status(404) + end + end + end + end +end diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb new file mode 100644 index 00000000000..53fe2bdb189 --- /dev/null +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -0,0 +1,160 @@ +require 'spec_helper' + +describe Projects::MergeRequests::DiffsController do + let(:project) { create(:project) } + let(:user) { project.owner } + let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } + + before do + sign_in(user) + end + + describe 'GET show' do + def go(extra_params = {}) + params = { + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid, + format: 'json' + } + + get :show, params.merge(extra_params) + end + + context 'with default params' do + context 'for the same project' do + before do + go + end + + it 'renders the diffs template to a string' do + expect(response).to render_template('projects/merge_requests/diffs/_diffs') + expect(json_response).to have_key('html') + end + end + + context 'with forked projects with submodules' do + render_views + + let(:project) { create(:project) } + let(:fork_project) { create(:forked_project_with_submodules) } + let(:merge_request) { create(:merge_request_with_diffs, source_project: fork_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) } + + before do + fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id) + fork_project.save + merge_request.reload + go + end + + it 'renders' do + expect(response).to be_success + expect(response.body).to have_content('Subproject commit') + end + end + end + + context 'with ignore_whitespace_change' do + before do + go(w: 1) + end + + it 'renders the diffs template to a string' do + expect(response).to render_template('projects/merge_requests/diffs/_diffs') + expect(json_response).to have_key('html') + end + end + + context 'with view' do + before do + go(view: 'parallel') + end + + it 'saves the preferred diff view in a cookie' do + expect(response.cookies['diff_view']).to eq('parallel') + end + end + end + + describe 'GET diff_for_path' do + def diff_for_path(extra_params = {}) + params = { + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid, + format: 'json' + } + + get :diff_for_path, params.merge(extra_params) + end + + let(:existing_path) { 'files/ruby/popen.rb' } + + context 'when the merge request exists' do + context 'when the user can view the merge request' do + context 'when the path exists in the diff' do + it 'enables diff notes' do + diff_for_path(old_path: existing_path, new_path: existing_path) + + expect(assigns(:diff_notes_disabled)).to be_falsey + expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'MergeRequest', + noteable_id: merge_request.id) + end + + it 'only renders the diffs for the path given' do + expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| + expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) + meth.call(diffs) + end + + diff_for_path(old_path: existing_path, new_path: existing_path) + end + end + + context 'when the path does not exist in the diff' do + before do + diff_for_path(old_path: 'files/ruby/nopen.rb', new_path: 'files/ruby/nopen.rb') + end + + it 'returns a 404' do + expect(response).to have_http_status(404) + end + end + end + + context 'when the user cannot view the merge request' do + before do + project.team.truncate + diff_for_path(old_path: existing_path, new_path: existing_path) + end + + it 'returns a 404' do + expect(response).to have_http_status(404) + end + end + end + + context 'when the merge request does not exist' do + before do + diff_for_path(id: merge_request.iid.succ, old_path: existing_path, new_path: existing_path) + end + + it 'returns a 404' do + expect(response).to have_http_status(404) + end + end + + context 'when the merge request belongs to a different project' do + let(:other_project) { create(:empty_project) } + + before do + other_project.team << [user, :master] + diff_for_path(old_path: existing_path, new_path: existing_path, project_id: other_project) + end + + it 'returns a 404' do + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 6817c2652fd..6f9ce60cf75 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -14,53 +14,6 @@ describe Projects::MergeRequestsController do sign_in(user) end - describe 'GET new' do - context 'merge request that removes a submodule' do - render_views - - let(:fork_project) { create(:forked_project_with_submodules) } - - before do - fork_project.team << [user, :master] - end - - context 'when rendering HTML response' do - it 'renders new merge request widget template' do - submit_new_merge_request - - expect(response).to be_success - end - end - - context 'when rendering JSON response' do - before do - create(:ci_pipeline, sha: fork_project.commit('remove-submodule').id, - ref: 'remove-submodule', - project: fork_project) - end - - it 'renders JSON including serialized pipelines' do - submit_new_merge_request(format: :json) - - expect(response).to be_ok - expect(json_response).to have_key 'pipelines' - expect(json_response['pipelines']).not_to be_empty - end - end - end - - def submit_new_merge_request(format: :html) - get :new, - namespace_id: fork_project.namespace.to_param, - project_id: fork_project, - merge_request: { - source_branch: 'remove-submodule', - target_branch: 'master' - }, - format: format - end - end - describe 'GET commit_change_content' do it 'renders commit_change_content template' do get :commit_change_content, @@ -497,234 +450,6 @@ describe Projects::MergeRequestsController do end end - describe 'GET diffs' do - def go(extra_params = {}) - params = { - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid - } - - get :diffs, params.merge(extra_params) - end - - it_behaves_like "loads labels", :diffs - - context 'with default params' do - context 'as html' do - before do - go(format: 'html') - end - - it 'renders the diff template' do - expect(response).to render_template('diffs') - end - end - - context 'as json' do - before do - go(format: 'json') - end - - it 'renders the diffs template to a string' do - expect(response).to render_template('projects/merge_requests/show/_diffs') - expect(json_response).to have_key('html') - end - end - - context 'with forked projects with submodules' do - render_views - - let(:project) { create(:project) } - let(:fork_project) { create(:forked_project_with_submodules) } - let(:merge_request) { create(:merge_request_with_diffs, source_project: fork_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) } - - before do - fork_project.build_forked_project_link(forked_to_project_id: fork_project.id, forked_from_project_id: project.id) - fork_project.save - merge_request.reload - go(format: 'json') - end - - it 'renders' do - expect(response).to be_success - expect(response.body).to have_content('Subproject commit') - end - end - end - - context 'with ignore_whitespace_change' do - context 'as html' do - before do - go(format: 'html', w: 1) - end - - it 'renders the diff template' do - expect(response).to render_template('diffs') - end - end - - context 'as json' do - before do - go(format: 'json', w: 1) - end - - it 'renders the diffs template to a string' do - expect(response).to render_template('projects/merge_requests/show/_diffs') - expect(json_response).to have_key('html') - end - end - end - - context 'with view' do - before do - go(view: 'parallel') - end - - it 'saves the preferred diff view in a cookie' do - expect(response.cookies['diff_view']).to eq('parallel') - end - end - end - - describe 'GET diff_for_path' do - def diff_for_path(extra_params = {}) - params = { - namespace_id: project.namespace.to_param, - project_id: project - } - - get :diff_for_path, params.merge(extra_params) - end - - context 'when an ID param is passed' do - let(:existing_path) { 'files/ruby/popen.rb' } - - context 'when the merge request exists' do - context 'when the user can view the merge request' do - context 'when the path exists in the diff' do - it 'enables diff notes' do - diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path) - - expect(assigns(:diff_notes_disabled)).to be_falsey - expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'MergeRequest', - noteable_id: merge_request.id) - end - - it 'only renders the diffs for the path given' do - expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| - expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) - meth.call(diffs) - end - - diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path) - end - end - - context 'when the path does not exist in the diff' do - before do - diff_for_path(id: merge_request.iid, old_path: 'files/ruby/nopen.rb', new_path: 'files/ruby/nopen.rb') - end - - it 'returns a 404' do - expect(response).to have_http_status(404) - end - end - end - - context 'when the user cannot view the merge request' do - before do - project.team.truncate - diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path) - end - - it 'returns a 404' do - expect(response).to have_http_status(404) - end - end - end - - context 'when the merge request does not exist' do - before do - diff_for_path(id: merge_request.iid.succ, old_path: existing_path, new_path: existing_path) - end - - it 'returns a 404' do - expect(response).to have_http_status(404) - end - end - - context 'when the merge request belongs to a different project' do - let(:other_project) { create(:empty_project) } - - before do - other_project.team << [user, :master] - diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path, project_id: other_project) - end - - it 'returns a 404' do - expect(response).to have_http_status(404) - end - end - end - - context 'when source and target params are passed' do - let(:existing_path) { 'files/ruby/feature.rb' } - - context 'when both branches are in the same project' do - it 'disables diff notes' do - diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_branch: 'feature', target_branch: 'master' }) - - expect(assigns(:diff_notes_disabled)).to be_truthy - end - - it 'only renders the diffs for the path given' do - expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| - expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) - meth.call(diffs) - end - - diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_branch: 'feature', target_branch: 'master' }) - end - end - - context 'when the source branch is in a different project to the target' do - let(:other_project) { create(:project) } - - before do - other_project.team << [user, :master] - end - - context 'when the path exists in the diff' do - it 'disables diff notes' do - diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' }) - - expect(assigns(:diff_notes_disabled)).to be_truthy - end - - it 'only renders the diffs for the path given' do - expect(controller).to receive(:render_diff_for_path).and_wrap_original do |meth, diffs| - expect(diffs.diff_files.map(&:new_path)).to contain_exactly(existing_path) - meth.call(diffs) - end - - diff_for_path(old_path: existing_path, new_path: existing_path, merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' }) - end - end - - context 'when the path does not exist in the diff' do - before do - diff_for_path(old_path: 'files/ruby/nopen.rb', new_path: 'files/ruby/nopen.rb', merge_request: { source_project: other_project, source_branch: 'feature', target_branch: 'master' }) - end - - it 'returns a 404' do - expect(response).to have_http_status(404) - end - end - end - end - end - describe 'GET commits' do def go(format: 'html') get :commits, @@ -734,23 +459,11 @@ describe Projects::MergeRequestsController do format: format end - it_behaves_like "loads labels", :commits + it 'renders the commits template to a string' do + go format: 'json' - context 'as html' do - it 'renders the show template' do - go - - expect(response).to render_template('show') - end - end - - context 'as json' do - it 'renders the commits template to a string' do - go format: 'json' - - expect(response).to render_template('projects/merge_requests/show/_commits') - expect(json_response).to have_key('html') - end + expect(response).to render_template('projects/merge_requests/_commits') + expect(json_response).to have_key('html') end end @@ -759,106 +472,16 @@ describe Projects::MergeRequestsController do create(:ci_pipeline, project: merge_request.source_project, ref: merge_request.source_branch, sha: merge_request.diff_head_sha) - end - - context 'when using HTML format' do - it_behaves_like "loads labels", :pipelines - end - context 'when using JSON format' do - before do - get :pipelines, - namespace_id: project.namespace.to_param, - project_id: project, - id: merge_request.iid, - format: :json - end - - it 'responds with serialized pipelines' do - expect(json_response).not_to be_empty - end - end - end - - describe 'GET conflicts' do - context 'when the conflicts cannot be resolved in the UI' do - before do - allow_any_instance_of(Gitlab::Conflict::Parser) - .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile) - - get :conflicts, - namespace_id: merge_request_with_conflicts.project.namespace.to_param, - project_id: merge_request_with_conflicts.project, - id: merge_request_with_conflicts.iid, - format: 'json' - end - - it 'returns a 200 status code' do - expect(response).to have_http_status(:ok) - end - - it 'returns JSON with a message' do - expect(json_response.keys).to contain_exactly('message', 'type') - end + get :pipelines, + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid, + format: :json end - context 'with valid conflicts' do - before do - get :conflicts, - namespace_id: merge_request_with_conflicts.project.namespace.to_param, - project_id: merge_request_with_conflicts.project, - id: merge_request_with_conflicts.iid, - format: 'json' - end - - it 'matches the schema' do - expect(response).to match_response_schema('conflicts') - end - - it 'includes meta info about the MR' do - expect(json_response['commit_message']).to include('Merge branch') - expect(json_response['commit_sha']).to match(/\h{40}/) - expect(json_response['source_branch']).to eq(merge_request_with_conflicts.source_branch) - expect(json_response['target_branch']).to eq(merge_request_with_conflicts.target_branch) - end - - it 'includes each file that has conflicts' do - filenames = json_response['files'].map { |file| file['new_path'] } - - expect(filenames).to contain_exactly('files/ruby/popen.rb', 'files/ruby/regex.rb') - end - - it 'splits files into sections with lines' do - json_response['files'].each do |file| - file['sections'].each do |section| - expect(section).to include('conflict', 'lines') - - section['lines'].each do |line| - if section['conflict'] - expect(line['type']).to be_in(%w(old new)) - expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer)) - else - if line['type'].nil? - expect(line['old_line']).not_to eq(nil) - expect(line['new_line']).not_to eq(nil) - else - expect(line['type']).to eq('match') - expect(line['old_line']).to eq(nil) - expect(line['new_line']).to eq(nil) - end - end - end - end - end - end - - it 'has unique section IDs across files' do - section_ids = json_response['files'].flat_map do |file| - file['sections'].map { |section| section['id'] }.compact - end - - expect(section_ids.uniq).to eq(section_ids) - end + it 'responds with serialized pipelines' do + expect(json_response).not_to be_empty end end @@ -913,215 +536,6 @@ describe Projects::MergeRequestsController do end end - describe 'GET conflict_for_path' do - def conflict_for_path(path) - get :conflict_for_path, - namespace_id: merge_request_with_conflicts.project.namespace.to_param, - project_id: merge_request_with_conflicts.project, - id: merge_request_with_conflicts.iid, - old_path: path, - new_path: path, - format: 'json' - end - - context 'when the conflicts cannot be resolved in the UI' do - before do - allow_any_instance_of(Gitlab::Conflict::Parser) - .to receive(:parse).and_raise(Gitlab::Conflict::Parser::UnmergeableFile) - - conflict_for_path('files/ruby/regex.rb') - end - - it 'returns a 404 status code' do - expect(response).to have_http_status(:not_found) - end - end - - context 'when the file does not exist cannot be resolved in the UI' do - before do - conflict_for_path('files/ruby/regexp.rb') - end - - it 'returns a 404 status code' do - expect(response).to have_http_status(:not_found) - end - end - - context 'with an existing file' do - let(:path) { 'files/ruby/regex.rb' } - - before do - conflict_for_path(path) - end - - it 'returns a 200 status code' do - expect(response).to have_http_status(:ok) - end - - it 'returns the file in JSON format' do - content = MergeRequests::Conflicts::ListService.new(merge_request_with_conflicts) - .file_for_path(path, path) - .content - - expect(json_response).to include('old_path' => path, - 'new_path' => path, - 'blob_icon' => 'file-text-o', - 'blob_path' => a_string_ending_with(path), - 'blob_ace_mode' => 'ruby', - 'content' => content) - end - end - end - - context 'POST resolve_conflicts' do - let!(:original_head_sha) { merge_request_with_conflicts.diff_head_sha } - - def resolve_conflicts(files) - post :resolve_conflicts, - namespace_id: merge_request_with_conflicts.project.namespace.to_param, - project_id: merge_request_with_conflicts.project, - id: merge_request_with_conflicts.iid, - format: 'json', - files: files, - commit_message: 'Commit message' - end - - context 'with valid params' do - before do - resolved_files = [ - { - 'new_path' => 'files/ruby/popen.rb', - 'old_path' => 'files/ruby/popen.rb', - 'sections' => { - '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head' - } - }, { - 'new_path' => 'files/ruby/regex.rb', - 'old_path' => 'files/ruby/regex.rb', - 'sections' => { - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin' - } - } - ] - - resolve_conflicts(resolved_files) - end - - it 'creates a new commit on the branch' do - expect(original_head_sha).not_to eq(merge_request_with_conflicts.source_branch_head.sha) - expect(merge_request_with_conflicts.source_branch_head.message).to include('Commit message') - end - - it 'returns an OK response' do - expect(response).to have_http_status(:ok) - end - end - - context 'when sections are missing' do - before do - resolved_files = [ - { - 'new_path' => 'files/ruby/popen.rb', - 'old_path' => 'files/ruby/popen.rb', - 'sections' => { - '2f6fcd96b88b36ce98c38da085c795a27d92a3dd_14_14' => 'head' - } - }, { - 'new_path' => 'files/ruby/regex.rb', - 'old_path' => 'files/ruby/regex.rb', - 'sections' => { - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head' - } - } - ] - - resolve_conflicts(resolved_files) - end - - it 'returns a 400 error' do - expect(response).to have_http_status(:bad_request) - end - - it 'has a message with the name of the first missing section' do - expect(json_response['message']).to include('6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21') - end - - it 'does not create a new commit' do - expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha) - end - end - - context 'when files are missing' do - before do - resolved_files = [ - { - 'new_path' => 'files/ruby/regex.rb', - 'old_path' => 'files/ruby/regex.rb', - 'sections' => { - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin' - } - } - ] - - resolve_conflicts(resolved_files) - end - - it 'returns a 400 error' do - expect(response).to have_http_status(:bad_request) - end - - it 'has a message with the name of the missing file' do - expect(json_response['message']).to include('files/ruby/popen.rb') - end - - it 'does not create a new commit' do - expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha) - end - end - - context 'when a file has identical content to the conflict' do - before do - content = MergeRequests::Conflicts::ListService.new(merge_request_with_conflicts) - .file_for_path('files/ruby/popen.rb', 'files/ruby/popen.rb') - .content - - resolved_files = [ - { - 'new_path' => 'files/ruby/popen.rb', - 'old_path' => 'files/ruby/popen.rb', - 'content' => content - }, { - 'new_path' => 'files/ruby/regex.rb', - 'old_path' => 'files/ruby/regex.rb', - 'sections' => { - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_9_9' => 'head', - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_21_21' => 'origin', - '6eb14e00385d2fb284765eb1cd8d420d33d63fc9_49_49' => 'origin' - } - } - ] - - resolve_conflicts(resolved_files) - end - - it 'returns a 400 error' do - expect(response).to have_http_status(:bad_request) - end - - it 'has a message with the path of the problem file' do - expect(json_response['message']).to include('files/ruby/popen.rb') - end - - it 'does not create a new commit' do - expect(original_head_sha).to eq(merge_request_with_conflicts.source_branch_head.sha) - end - end - end - describe 'POST assign_related_issues' do let(:issue1) { create(:issue, project: project) } let(:issue2) { create(:issue, project: project) } diff --git a/spec/controllers/projects/pipeline_schedules_controller_spec.rb b/spec/controllers/projects/pipeline_schedules_controller_spec.rb index f8f95dd9bc8..a8c44d5c313 100644 --- a/spec/controllers/projects/pipeline_schedules_controller_spec.rb +++ b/spec/controllers/projects/pipeline_schedules_controller_spec.rb @@ -84,4 +84,57 @@ describe Projects::PipelineSchedulesController do end end end + + describe 'security' do + include AccessMatchersForController + + describe 'GET edit' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_allowed_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + it { expect { go }.to be_denied_for(:visitor) } + + def go + get :edit, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + end + end + + describe 'GET take_ownership' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_allowed_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + it { expect { go }.to be_denied_for(:visitor) } + + def go + post :take_ownership, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id + end + end + + describe 'PUT update' do + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_allowed_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + it { expect { go }.to be_denied_for(:visitor) } + + def go + put :update, namespace_id: project.namespace.to_param, project_id: project, id: pipeline_schedule.id, + schedule: { description: 'a' } + end + end + end end diff --git a/spec/features/abuse_report_spec.rb b/spec/features/abuse_report_spec.rb index 5e6cd64c5c1..b88e801c3d7 100644 --- a/spec/features/abuse_report_spec.rb +++ b/spec/features/abuse_report_spec.rb @@ -12,7 +12,7 @@ feature 'Abuse reports', feature: true do click_link 'Report abuse' - fill_in 'abuse_report_message', with: 'This user send spam' + fill_in 'abuse_report_message', with: 'This user sends spam' click_button 'Send report' expect(page).to have_content 'Thank you for your report' diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb index 24da5db305f..7fa4d198e00 100644 --- a/spec/features/dashboard/todos/todos_spec.rb +++ b/spec/features/dashboard/todos/todos_spec.rb @@ -317,23 +317,6 @@ feature 'Dashboard Todos' do end end - context 'User has a Todo in a project pending deletion' do - before do - deleted_project = create(:project, :public, pending_delete: true) - create(:todo, :mentioned, user: user, project: deleted_project, target: issue, author: author) - create(:todo, :mentioned, user: user, project: deleted_project, target: issue, author: author, state: :done) - sign_in(user) - visit dashboard_todos_path - end - - it 'shows "All done" message' do - within('.todos-count') { expect(page).to have_content '0' } - expect(page).to have_content 'To do 0' - expect(page).to have_content 'Done 0' - expect(page).to have_selector('.todos-all-done', count: 1) - end - end - context 'User has a Build Failed todo' do let!(:todo) { create(:todo, :build_failed, user: user, project: project, author: author) } diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 863f8f75cd8..4cb728cc82b 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -459,7 +459,7 @@ describe 'Filter issues', js: true, feature: true do context 'issue label clicked' do before do - find('.issues-list .issue .issue-info a .label', text: multiple_words_label.title).click + find('.issues-list .issue .issue-main-info .issuable-info a .label', text: multiple_words_label.title).click end it 'filters' do diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index 8f7adbccaaa..6a08e50bf5e 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -65,7 +65,7 @@ feature 'Create New Merge Request', feature: true, js: true do it 'does not leak the private project name & namespace' do private_project = create(:project, :private) - visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_project_id: private_project.id }) + visit namespace_project_new_merge_request_path(project.namespace, project, merge_request: { target_project_id: private_project.id }) expect(page).not_to have_content private_project.path_with_namespace expect(page).to have_content project.path_with_namespace @@ -76,7 +76,7 @@ feature 'Create New Merge Request', feature: true, js: true do it 'does not leak the private project name & namespace' do private_project = create(:project, :private) - visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { source_project_id: private_project.id }) + visit namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_project_id: private_project.id }) expect(page).not_to have_content private_project.path_with_namespace expect(page).to have_content project.path_with_namespace @@ -84,13 +84,13 @@ feature 'Create New Merge Request', feature: true, js: true do end it 'populates source branch button' do - visit new_namespace_project_merge_request_path(project.namespace, project, change_branches: true, merge_request: { target_branch: 'master', source_branch: 'fix' }) + visit namespace_project_new_merge_request_path(project.namespace, project, change_branches: true, merge_request: { target_branch: 'master', source_branch: 'fix' }) expect(find('.js-source-branch')).to have_content('fix') end it 'allows to change the diff view' do - visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_branch: 'master', source_branch: 'fix' }) + visit namespace_project_new_merge_request_path(project.namespace, project, merge_request: { target_branch: 'master', source_branch: 'fix' }) click_link 'Changes' @@ -106,7 +106,7 @@ feature 'Create New Merge Request', feature: true, js: true do end it 'does not allow non-existing branches' do - visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_branch: 'non-exist-target', source_branch: 'non-exist-source' }) + visit namespace_project_new_merge_request_path(project.namespace, project, merge_request: { target_branch: 'non-exist-target', source_branch: 'non-exist-source' }) expect(page).to have_content('The form contains the following errors') expect(page).to have_content('Source branch "non-exist-source" does not exist') @@ -115,7 +115,7 @@ feature 'Create New Merge Request', feature: true, js: true do context 'when a branch contains commits that both delete and add the same image' do it 'renders the diff successfully' do - visit new_namespace_project_merge_request_path(project.namespace, project, merge_request: { target_branch: 'master', source_branch: 'deleted-image-test' }) + visit namespace_project_new_merge_request_path(project.namespace, project, merge_request: { target_branch: 'master', source_branch: 'deleted-image-test' }) click_link "Changes" @@ -125,7 +125,7 @@ feature 'Create New Merge Request', feature: true, js: true do # Isolates a regression (see #24627) it 'does not show error messages on initial form' do - visit new_namespace_project_merge_request_path(project.namespace, project) + visit namespace_project_new_merge_request_path(project.namespace, project) expect(page).not_to have_selector('#error_explanation') expect(page).not_to have_content('The form contains the following error') end @@ -138,7 +138,7 @@ feature 'Create New Merge Request', feature: true, js: true do end it 'shows pipelines for a new merge request' do - visit new_namespace_project_merge_request_path( + visit namespace_project_new_merge_request_path( project.namespace, project, merge_request: { target_branch: 'master', source_branch: 'fix' }) diff --git a/spec/features/merge_requests/form_spec.rb b/spec/features/merge_requests/form_spec.rb index 1996c2fa09a..d03d498ce21 100644 --- a/spec/features/merge_requests/form_spec.rb +++ b/spec/features/merge_requests/form_spec.rb @@ -23,7 +23,7 @@ describe 'New/edit merge request', feature: true, js: true do context 'new merge request' do before do - visit new_namespace_project_merge_request_path( + visit namespace_project_new_merge_request_path( project.namespace, project, merge_request: { @@ -182,7 +182,7 @@ describe 'New/edit merge request', feature: true, js: true do context 'new merge request' do before do - visit new_namespace_project_merge_request_path( + visit namespace_project_new_merge_request_path( fork_project.namespace, fork_project, merge_request: { diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb index 71aa71e380e..a1f123f15ec 100644 --- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -131,7 +131,7 @@ feature 'Merge Requests > User uses quick actions', feature: true, js: true do end it 'changes target_branch in new merge_request' do - visit new_namespace_project_merge_request_path(another_project.namespace, another_project, new_url_opts) + visit namespace_project_new_merge_request_path(another_project.namespace, another_project, new_url_opts) fill_in "merge_request_title", with: 'My brand new feature' fill_in "merge_request_description", with: "le feature \n/target_branch fix\nFeature description:" diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index 3ac1f603de6..d8e9b949204 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -12,7 +12,7 @@ describe 'Merge request', :feature, :js do context 'new merge request' do before do - visit new_namespace_project_merge_request_path( + visit namespace_project_new_merge_request_path( project.namespace, project, merge_request: { diff --git a/spec/features/merge_requests/wip_message_spec.rb b/spec/features/merge_requests/wip_message_spec.rb index 72d001bf408..0e304ba50af 100644 --- a/spec/features/merge_requests/wip_message_spec.rb +++ b/spec/features/merge_requests/wip_message_spec.rb @@ -11,7 +11,7 @@ feature 'Work In Progress help message', feature: true do context 'with WIP commits' do it 'shows a specific WIP hint' do - visit new_namespace_project_merge_request_path( + visit namespace_project_new_merge_request_path( project.namespace, project, merge_request: { @@ -32,7 +32,7 @@ feature 'Work In Progress help message', feature: true do context 'without WIP commits' do it 'shows the regular WIP message' do - visit new_namespace_project_merge_request_path( + visit namespace_project_new_merge_request_path( project.namespace, project, merge_request: { diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb index 8694366de35..0050864d305 100644 --- a/spec/features/projects/branches_spec.rb +++ b/spec/features/projects/branches_spec.rb @@ -21,10 +21,48 @@ describe 'Branches', feature: true do it 'shows all the branches' do visit namespace_project_branches_path(project.namespace, project) - repository.branches { |branch| expect(page).to have_content("#{branch.name}") } + repository.branches_sorted_by(:name).first(20).each do |branch| + expect(page).to have_content("#{branch.name}") + end expect(page).to have_content("Protected branches can be managed in project settings") end + it 'sorts the branches by name' do + visit namespace_project_branches_path(project.namespace, project) + + click_button "Name" # Open sorting dropdown + click_link "Name" + + sorted = repository.branches_sorted_by(:name).first(20).map do |branch| + Regexp.escape(branch.name) + end + expect(page).to have_content(/#{sorted.join(".*")}/) + end + + it 'sorts the branches by last updated' do + visit namespace_project_branches_path(project.namespace, project) + + click_button "Name" # Open sorting dropdown + click_link "Last updated" + + sorted = repository.branches_sorted_by(:updated_desc).first(20).map do |branch| + Regexp.escape(branch.name) + end + expect(page).to have_content(/#{sorted.join(".*")}/) + end + + it 'sorts the branches by oldest updated' do + visit namespace_project_branches_path(project.namespace, project) + + click_button "Name" # Open sorting dropdown + click_link "Oldest updated" + + sorted = repository.branches_sorted_by(:updated_asc).first(20).map do |branch| + Regexp.escape(branch.name) + end + expect(page).to have_content(/#{sorted.join(".*")}/) + end + it 'avoids a N+1 query in branches index' do control_count = ActiveRecord::QueryRecorder.new { visit namespace_project_branches_path(project.namespace, project) }.count diff --git a/spec/features/projects/environments/environment_metrics_spec.rb b/spec/features/projects/environments/environment_metrics_spec.rb index b48dcf6c774..a98a69a0fd6 100644 --- a/spec/features/projects/environments/environment_metrics_spec.rb +++ b/spec/features/projects/environments/environment_metrics_spec.rb @@ -27,7 +27,7 @@ feature 'Environment > Metrics', :feature do scenario 'shows metrics' do click_link('See metrics') - expect(page).to have_css('svg.prometheus-graph') + expect(page).to have_css('div#prometheus-graphs') end end diff --git a/spec/features/projects/merge_request_button_spec.rb b/spec/features/projects/merge_request_button_spec.rb index 6de8855016d..58054bbbbed 100644 --- a/spec/features/projects/merge_request_button_spec.rb +++ b/spec/features/projects/merge_request_button_spec.rb @@ -23,7 +23,7 @@ feature 'Merge Request button', feature: true do end it 'shows Create merge request button' do - href = new_namespace_project_merge_request_path(project.namespace, + href = namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: 'feature', target_branch: 'master' }) @@ -67,7 +67,7 @@ feature 'Merge Request button', feature: true do let(:user) { forked_project.owner } it 'shows Create merge request button' do - href = new_namespace_project_merge_request_path(forked_project.namespace, + href = namespace_project_new_merge_request_path(forked_project.namespace, forked_project, merge_request: { source_branch: 'feature', target_branch: 'master' }) diff --git a/spec/features/projects/merge_requests/list_spec.rb b/spec/features/projects/merge_requests/list_spec.rb index f2a2fd0311f..7ce3156215a 100644 --- a/spec/features/projects/merge_requests/list_spec.rb +++ b/spec/features/projects/merge_requests/list_spec.rb @@ -27,7 +27,7 @@ feature 'Merge Requests List' do it 'empty state should have a create merge request button' do visit namespace_project_merge_requests_path(project.namespace, project) - expect(page).to have_link 'New merge request', href: new_namespace_project_merge_request_path(project.namespace, project) + expect(page).to have_link 'New merge request', href: namespace_project_new_merge_request_path(project.namespace, project) end context 'if there are merge requests' do diff --git a/spec/features/projects/user_create_dir_spec.rb b/spec/features/projects/user_create_dir_spec.rb index f375e1215db..5d0acad3832 100644 --- a/spec/features/projects/user_create_dir_spec.rb +++ b/spec/features/projects/user_create_dir_spec.rb @@ -51,7 +51,7 @@ feature 'New directory creation', feature: true, js: true do expect(page).to have_content 'New Merge Request' expect(page).to have_content "From #{new_branch_name} into master" expect(page).to have_content 'Add new directory' - expect(current_path).to eq(new_namespace_project_merge_request_path(project.namespace, project)) + expect(current_path).to eq(namespace_project_new_merge_request_path(project.namespace, project)) end end end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index f33406a40a7..5e26b8bbed6 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -239,7 +239,7 @@ describe "Internal Project Access", feature: true do end describe "GET /:project_path/merge_requests/new" do - subject { new_namespace_project_merge_request_path(project.namespace, project) } + subject { namespace_project_new_merge_request_path(project.namespace, project) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 16a1331b2f3..59655b0c31a 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -452,7 +452,7 @@ describe "Public Project Access", feature: true do end describe "GET /:project_path/merge_requests/new" do - subject { new_namespace_project_merge_request_path(project.namespace, project) } + subject { namespace_project_new_merge_request_path(project.namespace, project) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb index cb727430117..9e561d0f191 100644 --- a/spec/helpers/submodule_helper_spec.rb +++ b/spec/helpers/submodule_helper_spec.rb @@ -170,6 +170,11 @@ describe SubmoduleHelper do expect(result).to eq(["/#{group.path}/test", "/#{group.path}/test/tree/#{commit_id}"]) end + it 'with trailing whitespace' do + result = relative_self_links('../test.git ', commit_id) + expect(result).to eq(["/#{group.path}/test", "/#{group.path}/test/tree/#{commit_id}"]) + end + it 'two levels down' do result = relative_self_links('../../test.git', commit_id) expect(result).to eq(["/#{group.path}/test", "/#{group.path}/test/tree/#{commit_id}"]) diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js b/spec/javascripts/filtered_search/dropdown_user_spec.js index f7708301b6e..0132f4b7c93 100644 --- a/spec/javascripts/filtered_search/dropdown_user_spec.js +++ b/spec/javascripts/filtered_search/dropdown_user_spec.js @@ -66,4 +66,38 @@ describe('Dropdown User', () => { window.gon = {}; }); }); + + describe('hideCurrentUser', () => { + const fixtureTemplate = 'issues/issue_list.html.raw'; + preloadFixtures(fixtureTemplate); + + let dropdown; + let authorFilterDropdownElement; + + beforeEach(() => { + loadFixtures(fixtureTemplate); + authorFilterDropdownElement = document.querySelector('#js-dropdown-author'); + const dummyInput = document.createElement('div'); + dropdown = new gl.DropdownUser(null, authorFilterDropdownElement, dummyInput); + }); + + const findCurrentUserElement = () => authorFilterDropdownElement.querySelector('.js-current-user'); + + it('hides the current user from dropdown', () => { + const currentUserElement = findCurrentUserElement(); + expect(currentUserElement).not.toBe(null); + + dropdown.hideCurrentUser(); + + expect(currentUserElement.classList).toContain('hidden'); + }); + + it('does nothing if no user is logged in', () => { + const currentUserElement = findCurrentUserElement(); + currentUserElement.parentNode.removeChild(currentUserElement); + expect(findCurrentUserElement()).toBe(null); + + dropdown.hideCurrentUser(); + }); + }); }); diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb index daaddd8f390..7e2f364ffa4 100644 --- a/spec/javascripts/fixtures/merge_requests.rb +++ b/spec/javascripts/fixtures/merge_requests.rb @@ -55,27 +55,14 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont render_merge_request(example.description, merge_request) end - it 'merge_requests/inline_changes_tab_with_comments.json' do |example| - create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) - create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) - render_merge_request(example.description, merge_request, action: :diffs, format: :json) - end - - it 'merge_requests/parallel_changes_tab_with_comments.json' do |example| - create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) - create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) - render_merge_request(example.description, merge_request, action: :diffs, format: :json, view: 'parallel') - end - private - def render_merge_request(fixture_file_name, merge_request, action: :show, format: :html, view: 'inline') - get action, + def render_merge_request(fixture_file_name, merge_request) + get :show, namespace_id: project.namespace.to_param, project_id: project, id: merge_request.to_param, - format: format, - view: view + format: :html expect(response).to be_success store_frontend_fixture(response, fixture_file_name) diff --git a/spec/javascripts/fixtures/merge_requests_diffs.rb b/spec/javascripts/fixtures/merge_requests_diffs.rb new file mode 100644 index 00000000000..ac5b06ace6d --- /dev/null +++ b/spec/javascripts/fixtures/merge_requests_diffs.rb @@ -0,0 +1,57 @@ + +require 'spec_helper' + +describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, namespace: namespace, path: 'merge-requests-project') } + let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') } + let(:path) { "files/ruby/popen.rb" } + let(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + old_line: nil, + new_line: 14, + diff_refs: merge_request.diff_refs + ) + end + + render_views + + before(:all) do + clean_frontend_fixtures('merge_request_diffs/') + end + + before(:each) do + sign_in(admin) + end + + it 'merge_request_diffs/inline_changes_tab_with_comments.json' do |example| + create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) + render_merge_request(example.description, merge_request) + end + + it 'merge_request_diffs/parallel_changes_tab_with_comments.json' do |example| + create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + create(:note_on_merge_request, author: admin, project: project, noteable: merge_request) + render_merge_request(example.description, merge_request, view: 'parallel') + end + + private + + def render_merge_request(fixture_file_name, merge_request, view: 'inline') + get :show, + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.to_param, + format: :json, + view: view + + expect(response).to be_success + store_frontend_fixture(response, fixture_file_name) + end +end diff --git a/spec/javascripts/lib/utils/dom_utils_spec.js b/spec/javascripts/lib/utils/dom_utils_spec.js new file mode 100644 index 00000000000..867bf5912d1 --- /dev/null +++ b/spec/javascripts/lib/utils/dom_utils_spec.js @@ -0,0 +1,35 @@ +import { addClassIfElementExists } from '~/lib/utils/dom_utils'; + +describe('DOM Utils', () => { + describe('addClassIfElementExists', () => { + const className = 'biology'; + const fixture = ` + <div class="parent"> + <div class="child"></div> + </div> + `; + + let parentElement; + + beforeEach(() => { + setFixtures(fixture); + parentElement = document.querySelector('.parent'); + }); + + it('adds class if element exists', () => { + const childElement = parentElement.querySelector('.child'); + expect(childElement).not.toBe(null); + + addClassIfElementExists(childElement, className); + + expect(childElement.classList).toContain(className); + }); + + it('does not throw if element does not exist', () => { + const childElement = parentElement.querySelector('.other-child'); + expect(childElement).toBe(null); + + addClassIfElementExists(childElement, className); + }); + }); +}); diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js index 9e9eb17d439..395dc560671 100644 --- a/spec/javascripts/merge_request_notes_spec.js +++ b/spec/javascripts/merge_request_notes_spec.js @@ -15,7 +15,7 @@ describe('Merge request notes', () => { gl.utils = gl.utils || {}; const discussionTabFixture = 'merge_requests/diff_comment.html.raw'; - const changesTabJsonFixture = 'merge_requests/inline_changes_tab_with_comments.json'; + const changesTabJsonFixture = 'merge_request_diffs/inline_changes_tab_with_comments.json'; preloadFixtures(discussionTabFixture, changesTabJsonFixture); describe('Discussion tab with diff comments', () => { diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index bb6b5d852d3..49ef21f75de 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -23,8 +23,8 @@ import 'vendor/jquery.scrollTo'; $.extend(stubLocation, defaults, stubs || {}); }; - const inlineChangesTabJsonFixture = 'merge_requests/inline_changes_tab_with_comments.json'; - const parallelChangesTabJsonFixture = 'merge_requests/parallel_changes_tab_with_comments.json'; + const inlineChangesTabJsonFixture = 'merge_request_diffs/inline_changes_tab_with_comments.json'; + const parallelChangesTabJsonFixture = 'merge_request_diffs/parallel_changes_tab_with_comments.json'; preloadFixtures( 'merge_requests/merge_request_with_task_list.html.raw', 'merge_requests/diff_comment.html.raw', @@ -52,14 +52,10 @@ import 'vendor/jquery.scrollTo'; loadFixtures('merge_requests/merge_request_with_task_list.html.raw'); this.subject = this.class.activateTab; }); - it('shows the first tab when action is show', function () { + it('shows the notes tab when action is show', function () { this.subject('show'); expect($('#notes')).toHaveClass('active'); }); - it('shows the notes tab when action is notes', function () { - this.subject('notes'); - expect($('#notes')).toHaveClass('active'); - }); it('shows the commits tab when action is commits', function () { this.subject('commits'); expect($('#commits')).toHaveClass('active'); @@ -161,7 +157,7 @@ import 'vendor/jquery.scrollTo'; setLocation({ pathname: '/foo/bar/merge_requests/1/commits' }); - expect(this.subject('notes')).toBe('/foo/bar/merge_requests/1'); + expect(this.subject('show')).toBe('/foo/bar/merge_requests/1'); expect(this.subject('diffs')).toBe('/foo/bar/merge_requests/1/diffs'); }); @@ -170,7 +166,7 @@ import 'vendor/jquery.scrollTo'; pathname: '/foo/bar/merge_requests/1/diffs' }); - expect(this.subject('notes')).toBe('/foo/bar/merge_requests/1'); + expect(this.subject('show')).toBe('/foo/bar/merge_requests/1'); expect(this.subject('commits')).toBe('/foo/bar/merge_requests/1/commits'); }); @@ -178,7 +174,7 @@ import 'vendor/jquery.scrollTo'; setLocation({ pathname: '/foo/bar/merge_requests/1/diffs.html' }); - expect(this.subject('notes')).toBe('/foo/bar/merge_requests/1'); + expect(this.subject('show')).toBe('/foo/bar/merge_requests/1'); expect(this.subject('commits')).toBe('/foo/bar/merge_requests/1/commits'); }); diff --git a/spec/javascripts/monitoring/deployments_spec.js b/spec/javascripts/monitoring/deployments_spec.js deleted file mode 100644 index 19bc11d0f24..00000000000 --- a/spec/javascripts/monitoring/deployments_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import d3 from 'd3'; -import PrometheusGraph from '~/monitoring/prometheus_graph'; -import Deployments from '~/monitoring/deployments'; -import { prometheusMockData } from './prometheus_mock_data'; - -describe('Metrics deployments', () => { - const fixtureName = 'environments/metrics/metrics.html.raw'; - let deployment; - let prometheusGraph; - - const graphElement = () => document.querySelector('.prometheus-graph'); - - preloadFixtures(fixtureName); - - beforeEach((done) => { - // Setup the view - loadFixtures(fixtureName); - - d3.selectAll('.prometheus-graph') - .append('g') - .attr('class', 'graph-container'); - - prometheusGraph = new PrometheusGraph(); - deployment = new Deployments(1000, 500); - - spyOn(prometheusGraph, 'init'); - spyOn($, 'ajax').and.callFake(() => { - const d = $.Deferred(); - d.resolve({ - deployments: [{ - id: 1, - created_at: deployment.chartData[10].time, - sha: 'testing', - tag: false, - ref: { - name: 'testing', - }, - }, { - id: 2, - created_at: deployment.chartData[15].time, - sha: '', - tag: true, - ref: { - name: 'tag', - }, - }], - }); - - setTimeout(done); - - return d.promise(); - }); - - prometheusGraph.configureGraph(); - prometheusGraph.transformData(prometheusMockData.metrics); - - deployment.init(prometheusGraph.graphSpecificProperties.memory_values.data); - }); - - it('creates line on graph for deploment', () => { - expect( - graphElement().querySelectorAll('.deployment-line').length, - ).toBe(2); - }); - - it('creates hidden deploy boxes', () => { - expect( - graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box').length, - ).toBe(2); - }); - - it('hides the info boxes by default', () => { - expect( - graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box.hidden').length, - ).toBe(2); - }); - - it('shows sha short code when tag is false', () => { - expect( - graphElement().querySelector('.deploy-info-1-cpu_values .js-deploy-info-box').textContent.trim(), - ).toContain('testin'); - }); - - it('shows ref name when tag is true', () => { - expect( - graphElement().querySelector('.deploy-info-2-cpu_values .js-deploy-info-box').textContent.trim(), - ).toContain('tag'); - }); - - it('shows info box when moving mouse over line', () => { - deployment.mouseOverDeployInfo(deployment.data[0].xPos, 'cpu_values'); - - expect( - graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box.hidden').length, - ).toBe(1); - - expect( - graphElement().querySelector('.deploy-info-1-cpu_values .js-deploy-info-box.hidden'), - ).toBeNull(); - }); - - it('hides previously visible info box when moving mouse away', () => { - deployment.mouseOverDeployInfo(500, 'cpu_values'); - - expect( - graphElement().querySelectorAll('.prometheus-graph .js-deploy-info-box.hidden').length, - ).toBe(2); - - expect( - graphElement().querySelector('.deploy-info-1-cpu_values .js-deploy-info-box.hidden'), - ).not.toBeNull(); - }); - - describe('refText', () => { - it('returns shortened SHA', () => { - expect( - Deployments.refText({ - tag: false, - sha: '123456789', - }), - ).toBe('123456'); - }); - - it('returns tag name', () => { - expect( - Deployments.refText({ - tag: true, - ref: 'v1.0', - }), - ).toBe('v1.0'); - }); - }); -}); diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js new file mode 100644 index 00000000000..6f4cb989847 --- /dev/null +++ b/spec/javascripts/monitoring/mock_data.js @@ -0,0 +1,4229 @@ +/* eslint-disable quote-props, indent, comma-dangle */ + +const metricsGroupsAPIResponse = { + 'success': true, + 'data': [ + { + 'group': 'Kubernetes', + 'priority': 1, + 'metrics': [ + { + 'title': 'Memory usage', + 'weight': 1, + 'queries': [ + { + 'query_range': 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20', + 'label': 'Container memory', + 'unit': 'MiB', + 'result': [ + { + 'metric': {}, + 'values': [ + [ + 1495700554.925, + '8.0390625' + ], + [ + 1495700614.925, + '8.0390625' + ], + [ + 1495700674.925, + '8.0390625' + ], + [ + 1495700734.925, + '8.0390625' + ], + [ + 1495700794.925, + '8.0390625' + ], + [ + 1495700854.925, + '8.0390625' + ], + [ + 1495700914.925, + '8.0390625' + ], + [ + 1495700974.925, + '8.0390625' + ], + [ + 1495701034.925, + '8.0390625' + ], + [ + 1495701094.925, + '8.0390625' + ], + [ + 1495701154.925, + '8.0390625' + ], + [ + 1495701214.925, + '8.0390625' + ], + [ + 1495701274.925, + '8.0390625' + ], + [ + 1495701334.925, + '8.0390625' + ], + [ + 1495701394.925, + '8.0390625' + ], + [ + 1495701454.925, + '8.0390625' + ], + [ + 1495701514.925, + '8.0390625' + ], + [ + 1495701574.925, + '8.0390625' + ], + [ + 1495701634.925, + '8.0390625' + ], + [ + 1495701694.925, + '8.0390625' + ], + [ + 1495701754.925, + '8.0390625' + ], + [ + 1495701814.925, + '8.0390625' + ], + [ + 1495701874.925, + '8.0390625' + ], + [ + 1495701934.925, + '8.0390625' + ], + [ + 1495701994.925, + '8.0390625' + ], + [ + 1495702054.925, + '8.0390625' + ], + [ + 1495702114.925, + '8.0390625' + ], + [ + 1495702174.925, + '8.0390625' + ], + [ + 1495702234.925, + '8.0390625' + ], + [ + 1495702294.925, + '8.0390625' + ], + [ + 1495702354.925, + '8.0390625' + ], + [ + 1495702414.925, + '8.0390625' + ], + [ + 1495702474.925, + '8.0390625' + ], + [ + 1495702534.925, + '8.0390625' + ], + [ + 1495702594.925, + '8.0390625' + ], + [ + 1495702654.925, + '8.0390625' + ], + [ + 1495702714.925, + '8.0390625' + ], + [ + 1495702774.925, + '8.0390625' + ], + [ + 1495702834.925, + '8.0390625' + ], + [ + 1495702894.925, + '8.0390625' + ], + [ + 1495702954.925, + '8.0390625' + ], + [ + 1495703014.925, + '8.0390625' + ], + [ + 1495703074.925, + '8.0390625' + ], + [ + 1495703134.925, + '8.0390625' + ], + [ + 1495703194.925, + '8.0390625' + ], + [ + 1495703254.925, + '8.03515625' + ], + [ + 1495703314.925, + '8.03515625' + ], + [ + 1495703374.925, + '8.03515625' + ], + [ + 1495703434.925, + '8.03515625' + ], + [ + 1495703494.925, + '8.03515625' + ], + [ + 1495703554.925, + '8.03515625' + ], + [ + 1495703614.925, + '8.03515625' + ], + [ + 1495703674.925, + '8.03515625' + ], + [ + 1495703734.925, + '8.03515625' + ], + [ + 1495703794.925, + '8.03515625' + ], + [ + 1495703854.925, + '8.03515625' + ], + [ + 1495703914.925, + '8.03515625' + ], + [ + 1495703974.925, + '8.03515625' + ], + [ + 1495704034.925, + '8.03515625' + ], + [ + 1495704094.925, + '8.03515625' + ], + [ + 1495704154.925, + '8.03515625' + ], + [ + 1495704214.925, + '7.9296875' + ], + [ + 1495704274.925, + '7.9296875' + ], + [ + 1495704334.925, + '7.9296875' + ], + [ + 1495704394.925, + '7.9296875' + ], + [ + 1495704454.925, + '7.9296875' + ], + [ + 1495704514.925, + '7.9296875' + ], + [ + 1495704574.925, + '7.9296875' + ], + [ + 1495704634.925, + '7.9296875' + ], + [ + 1495704694.925, + '7.9296875' + ], + [ + 1495704754.925, + '7.9296875' + ], + [ + 1495704814.925, + '7.9296875' + ], + [ + 1495704874.925, + '7.9296875' + ], + [ + 1495704934.925, + '7.9296875' + ], + [ + 1495704994.925, + '7.9296875' + ], + [ + 1495705054.925, + '7.9296875' + ], + [ + 1495705114.925, + '7.9296875' + ], + [ + 1495705174.925, + '7.9296875' + ], + [ + 1495705234.925, + '7.9296875' + ], + [ + 1495705294.925, + '7.9296875' + ], + [ + 1495705354.925, + '7.9296875' + ], + [ + 1495705414.925, + '7.9296875' + ], + [ + 1495705474.925, + '7.9296875' + ], + [ + 1495705534.925, + '7.9296875' + ], + [ + 1495705594.925, + '7.9296875' + ], + [ + 1495705654.925, + '7.9296875' + ], + [ + 1495705714.925, + '7.9296875' + ], + [ + 1495705774.925, + '7.9296875' + ], + [ + 1495705834.925, + '7.9296875' + ], + [ + 1495705894.925, + '7.9296875' + ], + [ + 1495705954.925, + '7.9296875' + ], + [ + 1495706014.925, + '7.9296875' + ], + [ + 1495706074.925, + '7.9296875' + ], + [ + 1495706134.925, + '7.9296875' + ], + [ + 1495706194.925, + '7.9296875' + ], + [ + 1495706254.925, + '7.9296875' + ], + [ + 1495706314.925, + '7.9296875' + ], + [ + 1495706374.925, + '7.9296875' + ], + [ + 1495706434.925, + '7.9296875' + ], + [ + 1495706494.925, + '7.9296875' + ], + [ + 1495706554.925, + '7.9296875' + ], + [ + 1495706614.925, + '7.9296875' + ], + [ + 1495706674.925, + '7.9296875' + ], + [ + 1495706734.925, + '7.9296875' + ], + [ + 1495706794.925, + '7.9296875' + ], + [ + 1495706854.925, + '7.9296875' + ], + [ + 1495706914.925, + '7.9296875' + ], + [ + 1495706974.925, + '7.9296875' + ], + [ + 1495707034.925, + '7.9296875' + ], + [ + 1495707094.925, + '7.9296875' + ], + [ + 1495707154.925, + '7.9296875' + ], + [ + 1495707214.925, + '7.9296875' + ], + [ + 1495707274.925, + '7.9296875' + ], + [ + 1495707334.925, + '7.9296875' + ], + [ + 1495707394.925, + '7.9296875' + ], + [ + 1495707454.925, + '7.9296875' + ], + [ + 1495707514.925, + '7.9296875' + ], + [ + 1495707574.925, + '7.9296875' + ], + [ + 1495707634.925, + '7.9296875' + ], + [ + 1495707694.925, + '7.9296875' + ], + [ + 1495707754.925, + '7.9296875' + ], + [ + 1495707814.925, + '7.9296875' + ], + [ + 1495707874.925, + '7.9296875' + ], + [ + 1495707934.925, + '7.9296875' + ], + [ + 1495707994.925, + '7.9296875' + ], + [ + 1495708054.925, + '7.9296875' + ], + [ + 1495708114.925, + '7.9296875' + ], + [ + 1495708174.925, + '7.9296875' + ], + [ + 1495708234.925, + '7.9296875' + ], + [ + 1495708294.925, + '7.9296875' + ], + [ + 1495708354.925, + '7.9296875' + ], + [ + 1495708414.925, + '7.9296875' + ], + [ + 1495708474.925, + '7.9296875' + ], + [ + 1495708534.925, + '7.9296875' + ], + [ + 1495708594.925, + '7.9296875' + ], + [ + 1495708654.925, + '7.9296875' + ], + [ + 1495708714.925, + '7.9296875' + ], + [ + 1495708774.925, + '7.9296875' + ], + [ + 1495708834.925, + '7.9296875' + ], + [ + 1495708894.925, + '7.9296875' + ], + [ + 1495708954.925, + '7.8984375' + ], + [ + 1495709014.925, + '7.8984375' + ], + [ + 1495709074.925, + '7.8984375' + ], + [ + 1495709134.925, + '7.8984375' + ], + [ + 1495709194.925, + '7.8984375' + ], + [ + 1495709254.925, + '7.89453125' + ], + [ + 1495709314.925, + '7.89453125' + ], + [ + 1495709374.925, + '7.89453125' + ], + [ + 1495709434.925, + '7.89453125' + ], + [ + 1495709494.925, + '7.89453125' + ], + [ + 1495709554.925, + '7.89453125' + ], + [ + 1495709614.925, + '7.89453125' + ], + [ + 1495709674.925, + '7.89453125' + ], + [ + 1495709734.925, + '7.89453125' + ], + [ + 1495709794.925, + '7.89453125' + ], + [ + 1495709854.925, + '7.89453125' + ], + [ + 1495709914.925, + '7.89453125' + ], + [ + 1495709974.925, + '7.89453125' + ], + [ + 1495710034.925, + '7.89453125' + ], + [ + 1495710094.925, + '7.89453125' + ], + [ + 1495710154.925, + '7.89453125' + ], + [ + 1495710214.925, + '7.89453125' + ], + [ + 1495710274.925, + '7.89453125' + ], + [ + 1495710334.925, + '7.89453125' + ], + [ + 1495710394.925, + '7.89453125' + ], + [ + 1495710454.925, + '7.89453125' + ], + [ + 1495710514.925, + '7.89453125' + ], + [ + 1495710574.925, + '7.89453125' + ], + [ + 1495710634.925, + '7.89453125' + ], + [ + 1495710694.925, + '7.89453125' + ], + [ + 1495710754.925, + '7.89453125' + ], + [ + 1495710814.925, + '7.89453125' + ], + [ + 1495710874.925, + '7.89453125' + ], + [ + 1495710934.925, + '7.89453125' + ], + [ + 1495710994.925, + '7.89453125' + ], + [ + 1495711054.925, + '7.89453125' + ], + [ + 1495711114.925, + '7.89453125' + ], + [ + 1495711174.925, + '7.8515625' + ], + [ + 1495711234.925, + '7.8515625' + ], + [ + 1495711294.925, + '7.8515625' + ], + [ + 1495711354.925, + '7.8515625' + ], + [ + 1495711414.925, + '7.8515625' + ], + [ + 1495711474.925, + '7.8515625' + ], + [ + 1495711534.925, + '7.8515625' + ], + [ + 1495711594.925, + '7.8515625' + ], + [ + 1495711654.925, + '7.8515625' + ], + [ + 1495711714.925, + '7.8515625' + ], + [ + 1495711774.925, + '7.8515625' + ], + [ + 1495711834.925, + '7.8515625' + ], + [ + 1495711894.925, + '7.8515625' + ], + [ + 1495711954.925, + '7.8515625' + ], + [ + 1495712014.925, + '7.8515625' + ], + [ + 1495712074.925, + '7.8515625' + ], + [ + 1495712134.925, + '7.8515625' + ], + [ + 1495712194.925, + '7.8515625' + ], + [ + 1495712254.925, + '7.8515625' + ], + [ + 1495712314.925, + '7.8515625' + ], + [ + 1495712374.925, + '7.8515625' + ], + [ + 1495712434.925, + '7.83203125' + ], + [ + 1495712494.925, + '7.83203125' + ], + [ + 1495712554.925, + '7.83203125' + ], + [ + 1495712614.925, + '7.83203125' + ], + [ + 1495712674.925, + '7.83203125' + ], + [ + 1495712734.925, + '7.83203125' + ], + [ + 1495712794.925, + '7.83203125' + ], + [ + 1495712854.925, + '7.83203125' + ], + [ + 1495712914.925, + '7.83203125' + ], + [ + 1495712974.925, + '7.83203125' + ], + [ + 1495713034.925, + '7.83203125' + ], + [ + 1495713094.925, + '7.83203125' + ], + [ + 1495713154.925, + '7.83203125' + ], + [ + 1495713214.925, + '7.83203125' + ], + [ + 1495713274.925, + '7.83203125' + ], + [ + 1495713334.925, + '7.83203125' + ], + [ + 1495713394.925, + '7.8125' + ], + [ + 1495713454.925, + '7.8125' + ], + [ + 1495713514.925, + '7.8125' + ], + [ + 1495713574.925, + '7.8125' + ], + [ + 1495713634.925, + '7.8125' + ], + [ + 1495713694.925, + '7.8125' + ], + [ + 1495713754.925, + '7.8125' + ], + [ + 1495713814.925, + '7.8125' + ], + [ + 1495713874.925, + '7.8125' + ], + [ + 1495713934.925, + '7.8125' + ], + [ + 1495713994.925, + '7.8125' + ], + [ + 1495714054.925, + '7.8125' + ], + [ + 1495714114.925, + '7.8125' + ], + [ + 1495714174.925, + '7.8125' + ], + [ + 1495714234.925, + '7.8125' + ], + [ + 1495714294.925, + '7.8125' + ], + [ + 1495714354.925, + '7.80859375' + ], + [ + 1495714414.925, + '7.80859375' + ], + [ + 1495714474.925, + '7.80859375' + ], + [ + 1495714534.925, + '7.80859375' + ], + [ + 1495714594.925, + '7.80859375' + ], + [ + 1495714654.925, + '7.80859375' + ], + [ + 1495714714.925, + '7.80859375' + ], + [ + 1495714774.925, + '7.80859375' + ], + [ + 1495714834.925, + '7.80859375' + ], + [ + 1495714894.925, + '7.80859375' + ], + [ + 1495714954.925, + '7.80859375' + ], + [ + 1495715014.925, + '7.80859375' + ], + [ + 1495715074.925, + '7.80859375' + ], + [ + 1495715134.925, + '7.80859375' + ], + [ + 1495715194.925, + '7.80859375' + ], + [ + 1495715254.925, + '7.80859375' + ], + [ + 1495715314.925, + '7.80859375' + ], + [ + 1495715374.925, + '7.80859375' + ], + [ + 1495715434.925, + '7.80859375' + ], + [ + 1495715494.925, + '7.80859375' + ], + [ + 1495715554.925, + '7.80859375' + ], + [ + 1495715614.925, + '7.80859375' + ], + [ + 1495715674.925, + '7.80859375' + ], + [ + 1495715734.925, + '7.80859375' + ], + [ + 1495715794.925, + '7.80859375' + ], + [ + 1495715854.925, + '7.80859375' + ], + [ + 1495715914.925, + '7.80078125' + ], + [ + 1495715974.925, + '7.80078125' + ], + [ + 1495716034.925, + '7.80078125' + ], + [ + 1495716094.925, + '7.80078125' + ], + [ + 1495716154.925, + '7.80078125' + ], + [ + 1495716214.925, + '7.796875' + ], + [ + 1495716274.925, + '7.796875' + ], + [ + 1495716334.925, + '7.796875' + ], + [ + 1495716394.925, + '7.796875' + ], + [ + 1495716454.925, + '7.796875' + ], + [ + 1495716514.925, + '7.796875' + ], + [ + 1495716574.925, + '7.796875' + ], + [ + 1495716634.925, + '7.796875' + ], + [ + 1495716694.925, + '7.796875' + ], + [ + 1495716754.925, + '7.796875' + ], + [ + 1495716814.925, + '7.796875' + ], + [ + 1495716874.925, + '7.79296875' + ], + [ + 1495716934.925, + '7.79296875' + ], + [ + 1495716994.925, + '7.79296875' + ], + [ + 1495717054.925, + '7.79296875' + ], + [ + 1495717114.925, + '7.79296875' + ], + [ + 1495717174.925, + '7.7890625' + ], + [ + 1495717234.925, + '7.7890625' + ], + [ + 1495717294.925, + '7.7890625' + ], + [ + 1495717354.925, + '7.7890625' + ], + [ + 1495717414.925, + '7.7890625' + ], + [ + 1495717474.925, + '7.7890625' + ], + [ + 1495717534.925, + '7.7890625' + ], + [ + 1495717594.925, + '7.7890625' + ], + [ + 1495717654.925, + '7.7890625' + ], + [ + 1495717714.925, + '7.7890625' + ], + [ + 1495717774.925, + '7.7890625' + ], + [ + 1495717834.925, + '7.77734375' + ], + [ + 1495717894.925, + '7.77734375' + ], + [ + 1495717954.925, + '7.77734375' + ], + [ + 1495718014.925, + '7.77734375' + ], + [ + 1495718074.925, + '7.77734375' + ], + [ + 1495718134.925, + '7.7421875' + ], + [ + 1495718194.925, + '7.7421875' + ], + [ + 1495718254.925, + '7.7421875' + ], + [ + 1495718314.925, + '7.7421875' + ] + ] + } + ] + } + ] + }, + { + 'title': 'CPU usage', + 'weight': 1, + 'queries': [ + { + 'query_range': 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100', + 'result': [ + { + 'metric': {}, + 'values': [ + [ + 1495700554.925, + '0.0010794445585559514' + ], + [ + 1495700614.925, + '0.003927214935433527' + ], + [ + 1495700674.925, + '0.0053045219047619975' + ], + [ + 1495700734.925, + '0.0048892095238097155' + ], + [ + 1495700794.925, + '0.005827140952381137' + ], + [ + 1495700854.925, + '0.00569846906219937' + ], + [ + 1495700914.925, + '0.004972616802849382' + ], + [ + 1495700974.925, + '0.005117509523809902' + ], + [ + 1495701034.925, + '0.00512389061919564' + ], + [ + 1495701094.925, + '0.005199100501890691' + ], + [ + 1495701154.925, + '0.005415746394885837' + ], + [ + 1495701214.925, + '0.005607682788146286' + ], + [ + 1495701274.925, + '0.005641300000000118' + ], + [ + 1495701334.925, + '0.0071166279368766495' + ], + [ + 1495701394.925, + '0.0063242138095234044' + ], + [ + 1495701454.925, + '0.005793314698235304' + ], + [ + 1495701514.925, + '0.00703934942237556' + ], + [ + 1495701574.925, + '0.006357007076123191' + ], + [ + 1495701634.925, + '0.003753167300126738' + ], + [ + 1495701694.925, + '0.005018469678430698' + ], + [ + 1495701754.925, + '0.0045217153371887' + ], + [ + 1495701814.925, + '0.006140104285714119' + ], + [ + 1495701874.925, + '0.004818684285714102' + ], + [ + 1495701934.925, + '0.005079509718955242' + ], + [ + 1495701994.925, + '0.005059981142498263' + ], + [ + 1495702054.925, + '0.005269098389538773' + ], + [ + 1495702114.925, + '0.005269954285714175' + ], + [ + 1495702174.925, + '0.014199241435795856' + ], + [ + 1495702234.925, + '0.01511936843111017' + ], + [ + 1495702294.925, + '0.0060933692920682875' + ], + [ + 1495702354.925, + '0.004945682380952493' + ], + [ + 1495702414.925, + '0.005641266666666565' + ], + [ + 1495702474.925, + '0.005223752857142996' + ], + [ + 1495702534.925, + '0.005743098505699831' + ], + [ + 1495702594.925, + '0.00538493380952391' + ], + [ + 1495702654.925, + '0.005507793883751339' + ], + [ + 1495702714.925, + '0.005666705714285466' + ], + [ + 1495702774.925, + '0.006231530000000112' + ], + [ + 1495702834.925, + '0.006570768635394899' + ], + [ + 1495702894.925, + '0.005551146666666895' + ], + [ + 1495702954.925, + '0.005602604737098058' + ], + [ + 1495703014.925, + '0.00613993580402159' + ], + [ + 1495703074.925, + '0.004770258764368832' + ], + [ + 1495703134.925, + '0.005512376671364914' + ], + [ + 1495703194.925, + '0.005254436666666674' + ], + [ + 1495703254.925, + '0.0050109839141320505' + ], + [ + 1495703314.925, + '0.0049478019256960016' + ], + [ + 1495703374.925, + '0.0037666860965123463' + ], + [ + 1495703434.925, + '0.004813526061656314' + ], + [ + 1495703494.925, + '0.005047748095238278' + ], + [ + 1495703554.925, + '0.00386494081008772' + ], + [ + 1495703614.925, + '0.004304037408111405' + ], + [ + 1495703674.925, + '0.004999466661587168' + ], + [ + 1495703734.925, + '0.004689140476190834' + ], + [ + 1495703794.925, + '0.004746126153582475' + ], + [ + 1495703854.925, + '0.004482706382572302' + ], + [ + 1495703914.925, + '0.004032808931864524' + ], + [ + 1495703974.925, + '0.005728319047618988' + ], + [ + 1495704034.925, + '0.004436139179627006' + ], + [ + 1495704094.925, + '0.004553455714285617' + ], + [ + 1495704154.925, + '0.003455244285714341' + ], + [ + 1495704214.925, + '0.004742244761904621' + ], + [ + 1495704274.925, + '0.005366978571428422' + ], + [ + 1495704334.925, + '0.004257954837665058' + ], + [ + 1495704394.925, + '0.005431603259831257' + ], + [ + 1495704454.925, + '0.0052009214498621986' + ], + [ + 1495704514.925, + '0.004317201904761618' + ], + [ + 1495704574.925, + '0.004307384285714157' + ], + [ + 1495704634.925, + '0.004789801146644822' + ], + [ + 1495704694.925, + '0.0051429795906706485' + ], + [ + 1495704754.925, + '0.005322495714285479' + ], + [ + 1495704814.925, + '0.004512809333244233' + ], + [ + 1495704874.925, + '0.004953843582568726' + ], + [ + 1495704934.925, + '0.005812690120858119' + ], + [ + 1495704994.925, + '0.004997024285714838' + ], + [ + 1495705054.925, + '0.005246216154439592' + ], + [ + 1495705114.925, + '0.0063494966618726795' + ], + [ + 1495705174.925, + '0.005306004342898225' + ], + [ + 1495705234.925, + '0.005081412857142978' + ], + [ + 1495705294.925, + '0.00511409523809522' + ], + [ + 1495705354.925, + '0.0047861001481192' + ], + [ + 1495705414.925, + '0.005107688228042962' + ], + [ + 1495705474.925, + '0.005271929582294012' + ], + [ + 1495705534.925, + '0.004453254502681249' + ], + [ + 1495705594.925, + '0.005799134293959226' + ], + [ + 1495705654.925, + '0.005340865929502478' + ], + [ + 1495705714.925, + '0.004911654761904942' + ], + [ + 1495705774.925, + '0.005888234873953261' + ], + [ + 1495705834.925, + '0.005565283333332954' + ], + [ + 1495705894.925, + '0.005522869047618869' + ], + [ + 1495705954.925, + '0.005177549737621646' + ], + [ + 1495706014.925, + '0.0053145810232096465' + ], + [ + 1495706074.925, + '0.004751095238095275' + ], + [ + 1495706134.925, + '0.006242077142856976' + ], + [ + 1495706194.925, + '0.00621034406957871' + ], + [ + 1495706254.925, + '0.006887592738978596' + ], + [ + 1495706314.925, + '0.006328128779726213' + ], + [ + 1495706374.925, + '0.007488363809523927' + ], + [ + 1495706434.925, + '0.006193758571428157' + ], + [ + 1495706494.925, + '0.0068798371839706935' + ], + [ + 1495706554.925, + '0.005757034340423128' + ], + [ + 1495706614.925, + '0.004571388497294698' + ], + [ + 1495706674.925, + '0.00620283044923395' + ], + [ + 1495706734.925, + '0.005607562380952455' + ], + [ + 1495706794.925, + '0.005506969933620308' + ], + [ + 1495706854.925, + '0.005621118095238131' + ], + [ + 1495706914.925, + '0.004876606098698849' + ], + [ + 1495706974.925, + '0.0047871205988517206' + ], + [ + 1495707034.925, + '0.00526405939458784' + ], + [ + 1495707094.925, + '0.005716323800605852' + ], + [ + 1495707154.925, + '0.005301459523809575' + ], + [ + 1495707214.925, + '0.0051613042857144905' + ], + [ + 1495707274.925, + '0.005384792857142714' + ], + [ + 1495707334.925, + '0.005259719047619222' + ], + [ + 1495707394.925, + '0.00584101142857182' + ], + [ + 1495707454.925, + '0.0060066121920326326' + ], + [ + 1495707514.925, + '0.006359978571428453' + ], + [ + 1495707574.925, + '0.006315876322151109' + ], + [ + 1495707634.925, + '0.005590012517198831' + ], + [ + 1495707694.925, + '0.005517419877137072' + ], + [ + 1495707754.925, + '0.006089813430348506' + ], + [ + 1495707814.925, + '0.00466754476190479' + ], + [ + 1495707874.925, + '0.006059954380517721' + ], + [ + 1495707934.925, + '0.005085657142856972' + ], + [ + 1495707994.925, + '0.005897665238095296' + ], + [ + 1495708054.925, + '0.0062282023199555885' + ], + [ + 1495708114.925, + '0.00526214553236979' + ], + [ + 1495708174.925, + '0.0044803300000000644' + ], + [ + 1495708234.925, + '0.005421443333333592' + ], + [ + 1495708294.925, + '0.005694326244512144' + ], + [ + 1495708354.925, + '0.005527721904761457' + ], + [ + 1495708414.925, + '0.005988819523809819' + ], + [ + 1495708474.925, + '0.005484704285714448' + ], + [ + 1495708534.925, + '0.005041123649230085' + ], + [ + 1495708594.925, + '0.005717767639612059' + ], + [ + 1495708654.925, + '0.005412954417342863' + ], + [ + 1495708714.925, + '0.005833343333333254' + ], + [ + 1495708774.925, + '0.005448135238094969' + ], + [ + 1495708834.925, + '0.005117341428571432' + ], + [ + 1495708894.925, + '0.005888345825277833' + ], + [ + 1495708954.925, + '0.005398543809524135' + ], + [ + 1495709014.925, + '0.005325611428571416' + ], + [ + 1495709074.925, + '0.005848668571428527' + ], + [ + 1495709134.925, + '0.005135003105145044' + ], + [ + 1495709194.925, + '0.0054551400000003' + ], + [ + 1495709254.925, + '0.005319472937322171' + ], + [ + 1495709314.925, + '0.00585677857142792' + ], + [ + 1495709374.925, + '0.0062146261904759215' + ], + [ + 1495709434.925, + '0.0067105060904182265' + ], + [ + 1495709494.925, + '0.005829691904762108' + ], + [ + 1495709554.925, + '0.005719280952381261' + ], + [ + 1495709614.925, + '0.005682603793416407' + ], + [ + 1495709674.925, + '0.0055272846277326934' + ], + [ + 1495709734.925, + '0.0057123680952386735' + ], + [ + 1495709794.925, + '0.00520597958075818' + ], + [ + 1495709854.925, + '0.005584358957263837' + ], + [ + 1495709914.925, + '0.005601104275197466' + ], + [ + 1495709974.925, + '0.005991657142857066' + ], + [ + 1495710034.925, + '0.00553722238095218' + ], + [ + 1495710094.925, + '0.005127883122696293' + ], + [ + 1495710154.925, + '0.005498111927534584' + ], + [ + 1495710214.925, + '0.005609934069084202' + ], + [ + 1495710274.925, + '0.00459206285714307' + ], + [ + 1495710334.925, + '0.0047910828571428084' + ], + [ + 1495710394.925, + '0.0056014671288845685' + ], + [ + 1495710454.925, + '0.005686936791078528' + ], + [ + 1495710514.925, + '0.00444480476190448' + ], + [ + 1495710574.925, + '0.005780394696738921' + ], + [ + 1495710634.925, + '0.0053107227550210365' + ], + [ + 1495710694.925, + '0.005096031495761817' + ], + [ + 1495710754.925, + '0.005451377979091524' + ], + [ + 1495710814.925, + '0.005328136666667083' + ], + [ + 1495710874.925, + '0.006020612857143043' + ], + [ + 1495710934.925, + '0.0061063585714285365' + ], + [ + 1495710994.925, + '0.006018346015752312' + ], + [ + 1495711054.925, + '0.005069130952381193' + ], + [ + 1495711114.925, + '0.005458406190476052' + ], + [ + 1495711174.925, + '0.00577219190476179' + ], + [ + 1495711234.925, + '0.005760814645658314' + ], + [ + 1495711294.925, + '0.005371875716579101' + ], + [ + 1495711354.925, + '0.0064232666666665834' + ], + [ + 1495711414.925, + '0.009369806836906667' + ], + [ + 1495711474.925, + '0.008956864761904692' + ], + [ + 1495711534.925, + '0.005266849368559271' + ], + [ + 1495711594.925, + '0.005335111364934262' + ], + [ + 1495711654.925, + '0.006461778319586945' + ], + [ + 1495711714.925, + '0.004687939890762393' + ], + [ + 1495711774.925, + '0.004438831245760684' + ], + [ + 1495711834.925, + '0.005142786666666613' + ], + [ + 1495711894.925, + '0.007257734212054963' + ], + [ + 1495711954.925, + '0.005621991904761494' + ], + [ + 1495712014.925, + '0.007868689999999862' + ], + [ + 1495712074.925, + '0.00910970215275738' + ], + [ + 1495712134.925, + '0.006151004285714278' + ], + [ + 1495712194.925, + '0.005447120924961522' + ], + [ + 1495712254.925, + '0.005150705153929503' + ], + [ + 1495712314.925, + '0.006358108714969314' + ], + [ + 1495712374.925, + '0.0057725354795696475' + ], + [ + 1495712434.925, + '0.005232139047619015' + ], + [ + 1495712494.925, + '0.004932809617949037' + ], + [ + 1495712554.925, + '0.004511607508499662' + ], + [ + 1495712614.925, + '0.00440487701522666' + ], + [ + 1495712674.925, + '0.005479113333333174' + ], + [ + 1495712734.925, + '0.004726317619047547' + ], + [ + 1495712794.925, + '0.005582041102958029' + ], + [ + 1495712854.925, + '0.006381481216082099' + ], + [ + 1495712914.925, + '0.005474260014095208' + ], + [ + 1495712974.925, + '0.00567597142857188' + ], + [ + 1495713034.925, + '0.0064741233333332985' + ], + [ + 1495713094.925, + '0.005467475714285271' + ], + [ + 1495713154.925, + '0.004868648393824457' + ], + [ + 1495713214.925, + '0.005254923286444893' + ], + [ + 1495713274.925, + '0.005599217150312865' + ], + [ + 1495713334.925, + '0.005105413720618919' + ], + [ + 1495713394.925, + '0.007246073333333279' + ], + [ + 1495713454.925, + '0.005990312380952272' + ], + [ + 1495713514.925, + '0.005594601853351101' + ], + [ + 1495713574.925, + '0.004739258673727054' + ], + [ + 1495713634.925, + '0.003932121428571783' + ], + [ + 1495713694.925, + '0.005018188268459395' + ], + [ + 1495713754.925, + '0.004538238095237985' + ], + [ + 1495713814.925, + '0.00561816643265435' + ], + [ + 1495713874.925, + '0.0063132584495033586' + ], + [ + 1495713934.925, + '0.00442385238095213' + ], + [ + 1495713994.925, + '0.004181795887658453' + ], + [ + 1495714054.925, + '0.004437759047619037' + ], + [ + 1495714114.925, + '0.006421748157178241' + ], + [ + 1495714174.925, + '0.006525143809523842' + ], + [ + 1495714234.925, + '0.004715904935144247' + ], + [ + 1495714294.925, + '0.005966040152763461' + ], + [ + 1495714354.925, + '0.005614535466921674' + ], + [ + 1495714414.925, + '0.004934375119415906' + ], + [ + 1495714474.925, + '0.0054122933333327385' + ], + [ + 1495714534.925, + '0.004926540699612279' + ], + [ + 1495714594.925, + '0.006124649517134237' + ], + [ + 1495714654.925, + '0.004629427092013995' + ], + [ + 1495714714.925, + '0.005117951257607005' + ], + [ + 1495714774.925, + '0.004868774512685422' + ], + [ + 1495714834.925, + '0.005310093333333399' + ], + [ + 1495714894.925, + '0.0054907752286127345' + ], + [ + 1495714954.925, + '0.004597678117351089' + ], + [ + 1495715014.925, + '0.0059622552380952' + ], + [ + 1495715074.925, + '0.005352457072655368' + ], + [ + 1495715134.925, + '0.005491630952381143' + ], + [ + 1495715194.925, + '0.006391770078379791' + ], + [ + 1495715254.925, + '0.005933472857142518' + ], + [ + 1495715314.925, + '0.005301314285714163' + ], + [ + 1495715374.925, + '0.0058352959724814165' + ], + [ + 1495715434.925, + '0.006154755147867044' + ], + [ + 1495715494.925, + '0.009391935637482038' + ], + [ + 1495715554.925, + '0.007846462857142592' + ], + [ + 1495715614.925, + '0.00477608215316353' + ], + [ + 1495715674.925, + '0.006132865238094998' + ], + [ + 1495715734.925, + '0.006159762457649516' + ], + [ + 1495715794.925, + '0.005957307073265968' + ], + [ + 1495715854.925, + '0.006652319091792501' + ], + [ + 1495715914.925, + '0.005493557402895287' + ], + [ + 1495715974.925, + '0.0058652434829145166' + ], + [ + 1495716034.925, + '0.005627400430468021' + ], + [ + 1495716094.925, + '0.006240656190475609' + ], + [ + 1495716154.925, + '0.006305997676168624' + ], + [ + 1495716214.925, + '0.005388057732783248' + ], + [ + 1495716274.925, + '0.0052814916048421244' + ], + [ + 1495716334.925, + '0.00699498614272497' + ], + [ + 1495716394.925, + '0.00627768693035141' + ], + [ + 1495716454.925, + '0.0042411487048161145' + ], + [ + 1495716514.925, + '0.005348647473627653' + ], + [ + 1495716574.925, + '0.0047176657142853975' + ], + [ + 1495716634.925, + '0.004437898571428686' + ], + [ + 1495716694.925, + '0.004923527366927261' + ], + [ + 1495716754.925, + '0.005131935066048421' + ], + [ + 1495716814.925, + '0.005046949523809611' + ], + [ + 1495716874.925, + '0.00547184095238092' + ], + [ + 1495716934.925, + '0.005224140016380444' + ], + [ + 1495716994.925, + '0.005297991171665292' + ], + [ + 1495717054.925, + '0.005492965995623498' + ], + [ + 1495717114.925, + '0.005754660000000403' + ], + [ + 1495717174.925, + '0.005949557138639285' + ], + [ + 1495717234.925, + '0.006091816112534666' + ], + [ + 1495717294.925, + '0.005554210080192063' + ], + [ + 1495717354.925, + '0.006411504395279871' + ], + [ + 1495717414.925, + '0.006319643996609606' + ], + [ + 1495717474.925, + '0.005539174405717675' + ], + [ + 1495717534.925, + '0.0053157078842772255' + ], + [ + 1495717594.925, + '0.005247480952381066' + ], + [ + 1495717654.925, + '0.004820141620396252' + ], + [ + 1495717714.925, + '0.005906173868322844' + ], + [ + 1495717774.925, + '0.006173117219570961' + ], + [ + 1495717834.925, + '0.005963340952380661' + ], + [ + 1495717894.925, + '0.005698976627681527' + ], + [ + 1495717954.925, + '0.004751279096346378' + ], + [ + 1495718014.925, + '0.005733142379359711' + ], + [ + 1495718074.925, + '0.004831689010348035' + ], + [ + 1495718134.925, + '0.005188370476191092' + ], + [ + 1495718194.925, + '0.004793227554547938' + ], + [ + 1495718254.925, + '0.003997442857142731' + ], + [ + 1495718314.925, + '0.004386040132951264' + ] + ] + } + ] + } + ] + } + ] + } + ], + 'last_update': '2017-05-25T13:18:34.949Z' +}; + +export default metricsGroupsAPIResponse; + +const responseMockData = { + 'GET': { + '/root/hello-prometheus/environments/30/additional_metrics.json': metricsGroupsAPIResponse, + 'http://test.host/frontend-fixtures/environments-project/environments/1/additional_metrics.json': metricsGroupsAPIResponse, // TODO: MAke sure this works in the monitoring_bundle_spec + }, +}; + +export const deploymentData = [ + { + id: 111, + iid: 3, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master' + }, + created_at: '2017-05-31T21:23:37.881Z', + tag: false, + 'last?': true + }, + { + id: 110, + iid: 2, + sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187', + ref: { + name: 'master' + }, + created_at: '2017-05-30T20:08:04.629Z', + tag: false, + 'last?': false + }, + { + id: 109, + iid: 1, + sha: '6511e58faafaa7ad2228990ec57f19d66f7db7c2', + ref: { + name: 'update2-readme' + }, + created_at: '2017-05-30T17:42:38.409Z', + tag: false, + 'last?': false + } +]; + +export const statePaths = { + settingsPath: '/root/hello-prometheus/services/prometheus/edit', + documentationPath: '/help/administration/monitoring/prometheus/index.md', +}; + +export const singleRowMetrics = [ + { + 'title': 'CPU usage', + 'weight': 1, + 'y_label': 'Values', + 'queries': [ + { + 'query_range': 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100', + 'result': [ + { + 'metric': { + + }, + 'values': [ + { + 'time': '2017-06-04T21:22:59.508Z', + 'value': '0.06335544298150002' + }, + { + 'time': '2017-06-04T21:23:59.508Z', + 'value': '0.0420347312480917' + }, + { + 'time': '2017-06-04T21:24:59.508Z', + 'value': '0.0023175131665412706' + }, + { + 'time': '2017-06-04T21:25:59.508Z', + 'value': '0.002315870476190476' + }, + { + 'time': '2017-06-04T21:26:59.508Z', + 'value': '0.0025005961904761894' + }, + { + 'time': '2017-06-04T21:27:59.508Z', + 'value': '0.0024612605834341264' + }, + { + 'time': '2017-06-04T21:28:59.508Z', + 'value': '0.002313129398767631' + }, + { + 'time': '2017-06-04T21:29:59.508Z', + 'value': '0.002411067353663882' + }, + { + 'time': '2017-06-04T21:30:59.508Z', + 'value': '0.002577309263721303' + }, + { + 'time': '2017-06-04T21:31:59.508Z', + 'value': '0.00242688307730403' + }, + { + 'time': '2017-06-04T21:32:59.508Z', + 'value': '0.0024168360301330457' + }, + { + 'time': '2017-06-04T21:33:59.508Z', + 'value': '0.0020449528090743714' + }, + { + 'time': '2017-06-04T21:34:59.508Z', + 'value': '0.0019149619047619036' + }, + { + 'time': '2017-06-04T21:35:59.508Z', + 'value': '0.0024491714364625094' + }, + { + 'time': '2017-06-04T21:36:59.508Z', + 'value': '0.002728773131172677' + }, + { + 'time': '2017-06-04T21:37:59.508Z', + 'value': '0.0028439119047618997' + }, + { + 'time': '2017-06-04T21:38:59.508Z', + 'value': '0.0026307480952380917' + }, + { + 'time': '2017-06-04T21:39:59.508Z', + 'value': '0.0025024842620546446' + }, + { + 'time': '2017-06-04T21:40:59.508Z', + 'value': '0.002300662387260825' + }, + { + 'time': '2017-06-04T21:41:59.508Z', + 'value': '0.002052890924848337' + }, + { + 'time': '2017-06-04T21:42:59.508Z', + 'value': '0.0023711195238095275' + }, + { + 'time': '2017-06-04T21:43:59.508Z', + 'value': '0.002513477619047618' + }, + { + 'time': '2017-06-04T21:44:59.508Z', + 'value': '0.0023489776287844897' + }, + { + 'time': '2017-06-04T21:45:59.508Z', + 'value': '0.002542572310212481' + }, + { + 'time': '2017-06-04T21:46:59.508Z', + 'value': '0.0024579470671707952' + }, + { + 'time': '2017-06-04T21:47:59.508Z', + 'value': '0.0028725150236664403' + }, + { + 'time': '2017-06-04T21:48:59.508Z', + 'value': '0.0024356089105610525' + }, + { + 'time': '2017-06-04T21:49:59.508Z', + 'value': '0.002544015828269929' + }, + { + 'time': '2017-06-04T21:50:59.508Z', + 'value': '0.0029595013380824906' + }, + { + 'time': '2017-06-04T21:51:59.508Z', + 'value': '0.0023084015085858' + }, + { + 'time': '2017-06-04T21:52:59.508Z', + 'value': '0.0021070500000000083' + }, + { + 'time': '2017-06-04T21:53:59.508Z', + 'value': '0.0022950066191106617' + }, + { + 'time': '2017-06-04T21:54:59.508Z', + 'value': '0.002492719454470995' + }, + { + 'time': '2017-06-04T21:55:59.508Z', + 'value': '0.00244312761904762' + }, + { + 'time': '2017-06-04T21:56:59.508Z', + 'value': '0.0023495500000000028' + }, + { + 'time': '2017-06-04T21:57:59.508Z', + 'value': '0.0020597072353070005' + }, + { + 'time': '2017-06-04T21:58:59.508Z', + 'value': '0.0021482352044800866' + }, + { + 'time': '2017-06-04T21:59:59.508Z', + 'value': '0.002333490000000004' + }, + { + 'time': '2017-06-04T22:00:59.508Z', + 'value': '0.0025899442857142815' + }, + { + 'time': '2017-06-04T22:01:59.508Z', + 'value': '0.002430299999999999' + }, + { + 'time': '2017-06-04T22:02:59.508Z', + 'value': '0.0023550328092113476' + }, + { + 'time': '2017-06-04T22:03:59.508Z', + 'value': '0.0026521871636872793' + }, + { + 'time': '2017-06-04T22:04:59.508Z', + 'value': '0.0023080671428571398' + }, + { + 'time': '2017-06-04T22:05:59.508Z', + 'value': '0.0024108401032390896' + }, + { + 'time': '2017-06-04T22:06:59.508Z', + 'value': '0.002433249366678738' + }, + { + 'time': '2017-06-04T22:07:59.508Z', + 'value': '0.0023242202306688682' + }, + { + 'time': '2017-06-04T22:08:59.508Z', + 'value': '0.002388222857142859' + }, + { + 'time': '2017-06-04T22:09:59.508Z', + 'value': '0.002115974914046794' + }, + { + 'time': '2017-06-04T22:10:59.508Z', + 'value': '0.0025090043331269917' + }, + { + 'time': '2017-06-04T22:11:59.508Z', + 'value': '0.002445507057277277' + }, + { + 'time': '2017-06-04T22:12:59.508Z', + 'value': '0.0026348773751130976' + }, + { + 'time': '2017-06-04T22:13:59.508Z', + 'value': '0.0025616258583088104' + }, + { + 'time': '2017-06-04T22:14:59.508Z', + 'value': '0.0021544093415751505' + }, + { + 'time': '2017-06-04T22:15:59.508Z', + 'value': '0.002649394767668881' + }, + { + 'time': '2017-06-04T22:16:59.508Z', + 'value': '0.0024023332666685705' + }, + { + 'time': '2017-06-04T22:17:59.508Z', + 'value': '0.0025444105294235306' + }, + { + 'time': '2017-06-04T22:18:59.508Z', + 'value': '0.0027298872305772806' + }, + { + 'time': '2017-06-04T22:19:59.508Z', + 'value': '0.0022880104956379287' + }, + { + 'time': '2017-06-04T22:20:59.508Z', + 'value': '0.002473246666666661' + }, + { + 'time': '2017-06-04T22:21:59.508Z', + 'value': '0.002259948381935587' + }, + { + 'time': '2017-06-04T22:22:59.508Z', + 'value': '0.0025778470886268835' + }, + { + 'time': '2017-06-04T22:23:59.508Z', + 'value': '0.002246127910852894' + }, + { + 'time': '2017-06-04T22:24:59.508Z', + 'value': '0.0020697466666666758' + }, + { + 'time': '2017-06-04T22:25:59.508Z', + 'value': '0.00225859722473547' + }, + { + 'time': '2017-06-04T22:26:59.508Z', + 'value': '0.0026466728254554814' + }, + { + 'time': '2017-06-04T22:27:59.508Z', + 'value': '0.002151247619047619' + }, + { + 'time': '2017-06-04T22:28:59.508Z', + 'value': '0.002324161444543914' + }, + { + 'time': '2017-06-04T22:29:59.508Z', + 'value': '0.002476474313796452' + }, + { + 'time': '2017-06-04T22:30:59.508Z', + 'value': '0.0023922184232080517' + }, + { + 'time': '2017-06-04T22:31:59.508Z', + 'value': '0.0025094934237468933' + }, + { + 'time': '2017-06-04T22:32:59.508Z', + 'value': '0.0025665311098200883' + }, + { + 'time': '2017-06-04T22:33:59.508Z', + 'value': '0.0024154900681661374' + }, + { + 'time': '2017-06-04T22:34:59.508Z', + 'value': '0.0023267450166192037' + }, + { + 'time': '2017-06-04T22:35:59.508Z', + 'value': '0.002156521904761904' + }, + { + 'time': '2017-06-04T22:36:59.508Z', + 'value': '0.0025474356898637007' + }, + { + 'time': '2017-06-04T22:37:59.508Z', + 'value': '0.0025989409624670233' + }, + { + 'time': '2017-06-04T22:38:59.508Z', + 'value': '0.002348336664762987' + }, + { + 'time': '2017-06-04T22:39:59.508Z', + 'value': '0.002665888246554726' + }, + { + 'time': '2017-06-04T22:40:59.508Z', + 'value': '0.002652684787474174' + }, + { + 'time': '2017-06-04T22:41:59.508Z', + 'value': '0.002472620430865355' + }, + { + 'time': '2017-06-04T22:42:59.508Z', + 'value': '0.0020616469210110247' + }, + { + 'time': '2017-06-04T22:43:59.508Z', + 'value': '0.0022434546372311934' + }, + { + 'time': '2017-06-04T22:44:59.508Z', + 'value': '0.0024469386784827982' + }, + { + 'time': '2017-06-04T22:45:59.508Z', + 'value': '0.0026192823809523787' + }, + { + 'time': '2017-06-04T22:46:59.508Z', + 'value': '0.003451999542852798' + }, + { + 'time': '2017-06-04T22:47:59.508Z', + 'value': '0.0031780314285714288' + }, + { + 'time': '2017-06-04T22:48:59.508Z', + 'value': '0.0024403352380952415' + }, + { + 'time': '2017-06-04T22:49:59.508Z', + 'value': '0.001998824761904764' + }, + { + 'time': '2017-06-04T22:50:59.508Z', + 'value': '0.0023792404761904806' + }, + { + 'time': '2017-06-04T22:51:59.508Z', + 'value': '0.002725906190476185' + }, + { + 'time': '2017-06-04T22:52:59.508Z', + 'value': '0.0020989528671155624' + }, + { + 'time': '2017-06-04T22:53:59.508Z', + 'value': '0.00228808226745016' + }, + { + 'time': '2017-06-04T22:54:59.508Z', + 'value': '0.0019860807413192147' + }, + { + 'time': '2017-06-04T22:55:59.508Z', + 'value': '0.0022698085714285897' + }, + { + 'time': '2017-06-04T22:56:59.508Z', + 'value': '0.0022839098467604415' + }, + { + 'time': '2017-06-04T22:57:59.508Z', + 'value': '0.002531114761904749' + }, + { + 'time': '2017-06-04T22:58:59.508Z', + 'value': '0.0028941072550999016' + }, + { + 'time': '2017-06-04T22:59:59.508Z', + 'value': '0.002547169523809506' + }, + { + 'time': '2017-06-04T23:00:59.508Z', + 'value': '0.0024062999999999958' + }, + { + 'time': '2017-06-04T23:01:59.508Z', + 'value': '0.0026939518471604386' + }, + { + 'time': '2017-06-04T23:02:59.508Z', + 'value': '0.002362901428571429' + }, + { + 'time': '2017-06-04T23:03:59.508Z', + 'value': '0.002663927142857154' + }, + { + 'time': '2017-06-04T23:04:59.508Z', + 'value': '0.0026173314285714354' + }, + { + 'time': '2017-06-04T23:05:59.508Z', + 'value': '0.002326527366406044' + }, + { + 'time': '2017-06-04T23:06:59.508Z', + 'value': '0.002035313809523809' + }, + { + 'time': '2017-06-04T23:07:59.508Z', + 'value': '0.002421447414786533' + }, + { + 'time': '2017-06-04T23:08:59.508Z', + 'value': '0.002898313809523804' + }, + { + 'time': '2017-06-04T23:09:59.508Z', + 'value': '0.002544891856112907' + }, + { + 'time': '2017-06-04T23:10:59.508Z', + 'value': '0.002290625356938882' + }, + { + 'time': '2017-06-04T23:11:59.508Z', + 'value': '0.002483028095238096' + }, + { + 'time': '2017-06-04T23:12:59.508Z', + 'value': '0.0023396832350784237' + }, + { + 'time': '2017-06-04T23:13:59.508Z', + 'value': '0.002085529248176153' + }, + { + 'time': '2017-06-04T23:14:59.508Z', + 'value': '0.0022417815068428012' + }, + { + 'time': '2017-06-04T23:15:59.508Z', + 'value': '0.002660293333333341' + }, + { + 'time': '2017-06-04T23:16:59.508Z', + 'value': '0.0029845149093818226' + }, + { + 'time': '2017-06-04T23:17:59.508Z', + 'value': '0.0027716655079475464' + }, + { + 'time': '2017-06-04T23:18:59.508Z', + 'value': '0.0025217708908741128' + }, + { + 'time': '2017-06-04T23:19:59.508Z', + 'value': '0.0025811235131094055' + }, + { + 'time': '2017-06-04T23:20:59.508Z', + 'value': '0.002209904761904762' + }, + { + 'time': '2017-06-04T23:21:59.508Z', + 'value': '0.0025053322926383344' + }, + { + 'time': '2017-06-04T23:22:59.508Z', + 'value': '0.002350917636526411' + }, + { + 'time': '2017-06-04T23:23:59.508Z', + 'value': '0.0018477500000000078' + }, + { + 'time': '2017-06-04T23:24:59.508Z', + 'value': '0.002427629523809527' + }, + { + 'time': '2017-06-04T23:25:59.508Z', + 'value': '0.0019305498147601655' + }, + { + 'time': '2017-06-04T23:26:59.508Z', + 'value': '0.002097250000000006' + }, + { + 'time': '2017-06-04T23:27:59.508Z', + 'value': '0.002675020952780041' + }, + { + 'time': '2017-06-04T23:28:59.508Z', + 'value': '0.0023142214285714374' + }, + { + 'time': '2017-06-04T23:29:59.508Z', + 'value': '0.0023644723809523737' + }, + { + 'time': '2017-06-04T23:30:59.508Z', + 'value': '0.002108696190476198' + }, + { + 'time': '2017-06-04T23:31:59.508Z', + 'value': '0.0019918289697997194' + }, + { + 'time': '2017-06-04T23:32:59.508Z', + 'value': '0.001583584285714283' + }, + { + 'time': '2017-06-04T23:33:59.508Z', + 'value': '0.002073770226383112' + }, + { + 'time': '2017-06-04T23:34:59.508Z', + 'value': '0.0025877664234966818' + }, + { + 'time': '2017-06-04T23:35:59.508Z', + 'value': '0.0021138238095238147' + }, + { + 'time': '2017-06-04T23:36:59.508Z', + 'value': '0.0022140838095238303' + }, + { + 'time': '2017-06-04T23:37:59.508Z', + 'value': '0.0018592674425248847' + }, + { + 'time': '2017-06-04T23:38:59.508Z', + 'value': '0.0020461969533657016' + }, + { + 'time': '2017-06-04T23:39:59.508Z', + 'value': '0.0021593628571428543' + }, + { + 'time': '2017-06-04T23:40:59.508Z', + 'value': '0.0024330682564928188' + }, + { + 'time': '2017-06-04T23:41:59.508Z', + 'value': '0.0021501804779093174' + }, + { + 'time': '2017-06-04T23:42:59.508Z', + 'value': '0.0025787493928397945' + }, + { + 'time': '2017-06-04T23:43:59.508Z', + 'value': '0.002593657082448396' + }, + { + 'time': '2017-06-04T23:44:59.508Z', + 'value': '0.0021316752380952306' + }, + { + 'time': '2017-06-04T23:45:59.508Z', + 'value': '0.0026972905019952086' + }, + { + 'time': '2017-06-04T23:46:59.508Z', + 'value': '0.002580250764292983' + }, + { + 'time': '2017-06-04T23:47:59.508Z', + 'value': '0.00227103000000001' + }, + { + 'time': '2017-06-04T23:48:59.508Z', + 'value': '0.0023678515647321146' + }, + { + 'time': '2017-06-04T23:49:59.508Z', + 'value': '0.002371472857142866' + }, + { + 'time': '2017-06-04T23:50:59.508Z', + 'value': '0.0026181353688500978' + }, + { + 'time': '2017-06-04T23:51:59.508Z', + 'value': '0.0025609667711121217' + }, + { + 'time': '2017-06-04T23:52:59.508Z', + 'value': '0.0027145308139922557' + }, + { + 'time': '2017-06-04T23:53:59.508Z', + 'value': '0.0024249397613310512' + }, + { + 'time': '2017-06-04T23:54:59.508Z', + 'value': '0.002399907142857147' + }, + { + 'time': '2017-06-04T23:55:59.508Z', + 'value': '0.0024753357142857195' + }, + { + 'time': '2017-06-04T23:56:59.508Z', + 'value': '0.0026179149325231575' + }, + { + 'time': '2017-06-04T23:57:59.508Z', + 'value': '0.0024261340368186956' + }, + { + 'time': '2017-06-04T23:58:59.508Z', + 'value': '0.0021061071428571517' + }, + { + 'time': '2017-06-04T23:59:59.508Z', + 'value': '0.0024033971105037015' + }, + { + 'time': '2017-06-05T00:00:59.508Z', + 'value': '0.0028287676190475956' + }, + { + 'time': '2017-06-05T00:01:59.508Z', + 'value': '0.002499719050294778' + }, + { + 'time': '2017-06-05T00:02:59.508Z', + 'value': '0.0026726102153353856' + }, + { + 'time': '2017-06-05T00:03:59.508Z', + 'value': '0.00262582619047618' + }, + { + 'time': '2017-06-05T00:04:59.508Z', + 'value': '0.002280473147363316' + }, + { + 'time': '2017-06-05T00:05:59.508Z', + 'value': '0.002095581470652675' + }, + { + 'time': '2017-06-05T00:06:59.508Z', + 'value': '0.002270768490828408' + }, + { + 'time': '2017-06-05T00:07:59.508Z', + 'value': '0.002728577415023017' + }, + { + 'time': '2017-06-05T00:08:59.508Z', + 'value': '0.002652512857142863' + }, + { + 'time': '2017-06-05T00:09:59.508Z', + 'value': '0.0022781033924455674' + }, + { + 'time': '2017-06-05T00:10:59.508Z', + 'value': '0.0025345038095238234' + }, + { + 'time': '2017-06-05T00:11:59.508Z', + 'value': '0.002376050020000397' + }, + { + 'time': '2017-06-05T00:12:59.508Z', + 'value': '0.002455068143506122' + }, + { + 'time': '2017-06-05T00:13:59.508Z', + 'value': '0.002826705714285719' + }, + { + 'time': '2017-06-05T00:14:59.508Z', + 'value': '0.002343833692070314' + }, + { + 'time': '2017-06-05T00:15:59.508Z', + 'value': '0.00264853297122164' + }, + { + 'time': '2017-06-05T00:16:59.508Z', + 'value': '0.0027656335117426257' + }, + { + 'time': '2017-06-05T00:17:59.508Z', + 'value': '0.0025896543842439564' + }, + { + 'time': '2017-06-05T00:18:59.508Z', + 'value': '0.002180053237081201' + }, + { + 'time': '2017-06-05T00:19:59.508Z', + 'value': '0.002475245002333342' + }, + { + 'time': '2017-06-05T00:20:59.508Z', + 'value': '0.0027559767805101065' + }, + { + 'time': '2017-06-05T00:21:59.508Z', + 'value': '0.0022294836141296607' + }, + { + 'time': '2017-06-05T00:22:59.508Z', + 'value': '0.0021383590476190643' + }, + { + 'time': '2017-06-05T00:23:59.508Z', + 'value': '0.002085417956361494' + }, + { + 'time': '2017-06-05T00:24:59.508Z', + 'value': '0.0024140319047619013' + }, + { + 'time': '2017-06-05T00:25:59.508Z', + 'value': '0.0024513114285714304' + }, + { + 'time': '2017-06-05T00:26:59.508Z', + 'value': '0.0026932152380952446' + }, + { + 'time': '2017-06-05T00:27:59.508Z', + 'value': '0.0022656844350898517' + }, + { + 'time': '2017-06-05T00:28:59.508Z', + 'value': '0.0024483785714285704' + }, + { + 'time': '2017-06-05T00:29:59.508Z', + 'value': '0.002559505804817207' + }, + { + 'time': '2017-06-05T00:30:59.508Z', + 'value': '0.0019485681088751649' + }, + { + 'time': '2017-06-05T00:31:59.508Z', + 'value': '0.00228367984456996' + }, + { + 'time': '2017-06-05T00:32:59.508Z', + 'value': '0.002522149047619049' + }, + { + 'time': '2017-06-05T00:33:59.508Z', + 'value': '0.0026860117715406737' + }, + { + 'time': '2017-06-05T00:34:59.508Z', + 'value': '0.002679669523809523' + }, + { + 'time': '2017-06-05T00:35:59.508Z', + 'value': '0.0022201920970675937' + }, + { + 'time': '2017-06-05T00:36:59.508Z', + 'value': '0.0022917647619047615' + }, + { + 'time': '2017-06-05T00:37:59.508Z', + 'value': '0.0021774059294673576' + }, + { + 'time': '2017-06-05T00:38:59.508Z', + 'value': '0.0024637766666666763' + }, + { + 'time': '2017-06-05T00:39:59.508Z', + 'value': '0.002470468290174195' + }, + { + 'time': '2017-06-05T00:40:59.508Z', + 'value': '0.0022188616082057812' + }, + { + 'time': '2017-06-05T00:41:59.508Z', + 'value': '0.002421840744373875' + }, + { + 'time': '2017-06-05T00:42:59.508Z', + 'value': '0.0023918266666666547' + }, + { + 'time': '2017-06-05T00:43:59.508Z', + 'value': '0.002195743809523809' + }, + { + 'time': '2017-06-05T00:44:59.508Z', + 'value': '0.0025514828571428687' + }, + { + 'time': '2017-06-05T00:45:59.508Z', + 'value': '0.0027981709349612694' + }, + { + 'time': '2017-06-05T00:46:59.508Z', + 'value': '0.002557977142857146' + }, + { + 'time': '2017-06-05T00:47:59.508Z', + 'value': '0.002213244285714286' + }, + { + 'time': '2017-06-05T00:48:59.508Z', + 'value': '0.0025706738095238046' + }, + { + 'time': '2017-06-05T00:49:59.508Z', + 'value': '0.002210976666666671' + }, + { + 'time': '2017-06-05T00:50:59.508Z', + 'value': '0.002055377091646749' + }, + { + 'time': '2017-06-05T00:51:59.508Z', + 'value': '0.002308368095238119' + }, + { + 'time': '2017-06-05T00:52:59.508Z', + 'value': '0.0024687939885141615' + }, + { + 'time': '2017-06-05T00:53:59.508Z', + 'value': '0.002563018571428578' + }, + { + 'time': '2017-06-05T00:54:59.508Z', + 'value': '0.00240563291078959' + } + ] + } + ] + } + ] + }, + { + 'title': 'Memory usage', + 'weight': 1, + 'y_label': 'Values', + 'queries': [ + { + 'query_range': 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20', + 'label': 'Container memory', + 'unit': 'MiB', + 'result': [ + { + 'metric': { + + }, + 'values': [ + { + 'time': '2017-06-04T21:22:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:23:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:24:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:25:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:26:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:27:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:28:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:29:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:30:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:31:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:32:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:33:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:34:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:35:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:36:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:37:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:38:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:39:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:40:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:41:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:42:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:43:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:44:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:45:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:46:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:47:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:48:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:49:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:50:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:51:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:52:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:53:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:54:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:55:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:56:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:57:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:58:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T21:59:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:00:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:01:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:02:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:03:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:04:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:05:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:06:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:07:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:08:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:09:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:10:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:11:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:12:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:13:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:14:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:15:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:16:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:17:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:18:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:19:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:20:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:21:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:22:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:23:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:24:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:25:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:26:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:27:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:28:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:29:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:30:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:31:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:32:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:33:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:34:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:35:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:36:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:37:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:38:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:39:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:40:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:41:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:42:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:43:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:44:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:45:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:46:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:47:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:48:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:49:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:50:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:51:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:52:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:53:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:54:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:55:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:56:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:57:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:58:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T22:59:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:00:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:01:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:02:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:03:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:04:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:05:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:06:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:07:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:08:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:09:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:10:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:11:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:12:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:13:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:14:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:15:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:16:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:17:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:18:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:19:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:20:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:21:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:22:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:23:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:24:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:25:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:26:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:27:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:28:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:29:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:30:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:31:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:32:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:33:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:34:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:35:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:36:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:37:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:38:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:39:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:40:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:41:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:42:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:43:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:44:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:45:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:46:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:47:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:48:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:49:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:50:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:51:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:52:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:53:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:54:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:55:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:56:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:57:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:58:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-04T23:59:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:00:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:01:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:02:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:03:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:04:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:05:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:06:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:07:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:08:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:09:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:10:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:11:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:12:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:13:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:14:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:15:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:16:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:17:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:18:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:19:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:20:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:21:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:22:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:23:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:24:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:25:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:26:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:27:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:28:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:29:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:30:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:31:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:32:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:33:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:34:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:35:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:36:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:37:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:38:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:39:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:40:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:41:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:42:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:43:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:44:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:45:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:46:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:47:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:48:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:49:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:50:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:51:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:52:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:53:59.508Z', + 'value': '15.0859375' + }, + { + 'time': '2017-06-05T00:54:59.508Z', + 'value': '15.0859375' + } + ] + } + ] + } + ] + } +]; + +export function MonitorMockInterceptor(request, next) { + const body = responseMockData[request.method.toUpperCase()][request.url]; + + next(request.respondWith(JSON.stringify(body), { + status: 200, + })); +} diff --git a/spec/javascripts/monitoring/monitoring_column_spec.js b/spec/javascripts/monitoring/monitoring_column_spec.js new file mode 100644 index 00000000000..c8787f9708c --- /dev/null +++ b/spec/javascripts/monitoring/monitoring_column_spec.js @@ -0,0 +1,97 @@ +import Vue from 'vue'; +import _ from 'underscore'; +import MonitoringColumn from '~/monitoring/components/monitoring_column.vue'; +import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins'; +import eventHub from '~/monitoring/event_hub'; +import { deploymentData, singleRowMetrics } from './mock_data'; + +const createComponent = (propsData) => { + const Component = Vue.extend(MonitoringColumn); + + return new Component({ + propsData, + }).$mount(); +}; + +describe('MonitoringColumn', () => { + beforeEach(() => { + spyOn(MonitoringMixins.methods, 'formatDeployments').and.callFake(function fakeFormat() { + return {}; + }); + }); + + it('has a title', () => { + const component = createComponent({ + columnData: singleRowMetrics[0], + classType: 'col-md-6', + updateAspectRatio: false, + deploymentData, + }); + + expect(component.$el.querySelector('.text-center').innerText.trim()).toBe(component.columnData.title); + }); + + it('creates a path for the line and area of the graph', (done) => { + const component = createComponent({ + columnData: singleRowMetrics[0], + classType: 'col-md-6', + updateAspectRatio: false, + deploymentData, + }); + + Vue.nextTick(() => { + expect(component.area).toBeDefined(); + expect(component.line).toBeDefined(); + expect(typeof component.area).toEqual('string'); + expect(typeof component.line).toEqual('string'); + expect(_.isFunction(component.xScale)).toBe(true); + expect(_.isFunction(component.yScale)).toBe(true); + done(); + }); + }); + + describe('Computed props', () => { + it('axisTransform translates an element Y position depending of its height', () => { + const component = createComponent({ + columnData: singleRowMetrics[0], + classType: 'col-md-6', + updateAspectRatio: false, + deploymentData, + }); + + const transformedHeight = `${component.graphHeight - 100}`; + expect(component.axisTransform.indexOf(transformedHeight)) + .not.toEqual(-1); + }); + + it('outterViewBox gets a width and height property based on the DOM size of the element', () => { + const component = createComponent({ + columnData: singleRowMetrics[0], + classType: 'col-md-6', + updateAspectRatio: false, + deploymentData, + }); + + const viewBoxArray = component.outterViewBox.split(' '); + expect(typeof component.outterViewBox).toEqual('string'); + expect(viewBoxArray[2]).toEqual(component.graphWidth.toString()); + expect(viewBoxArray[3]).toEqual(component.graphHeight.toString()); + }); + }); + + it('sends an event to the eventhub when it has finished resizing', (done) => { + const component = createComponent({ + columnData: singleRowMetrics[0], + classType: 'col-md-6', + updateAspectRatio: false, + deploymentData, + }); + spyOn(eventHub, '$emit'); + + component.updateAspectRatio = true; + Vue.nextTick(() => { + expect(eventHub.$emit).toHaveBeenCalled(); + done(); + }); + }); +}); diff --git a/spec/javascripts/monitoring/monitoring_deployment_spec.js b/spec/javascripts/monitoring/monitoring_deployment_spec.js new file mode 100644 index 00000000000..5cc5b514824 --- /dev/null +++ b/spec/javascripts/monitoring/monitoring_deployment_spec.js @@ -0,0 +1,137 @@ +import Vue from 'vue'; +import MonitoringState from '~/monitoring/components/monitoring_deployment.vue'; +import { deploymentData } from './mock_data'; + +const createComponent = (propsData) => { + const Component = Vue.extend(MonitoringState); + + return new Component({ + propsData, + }).$mount(); +}; + +describe('MonitoringDeployment', () => { + const reducedDeploymentData = [deploymentData[0]]; + reducedDeploymentData[0].ref = reducedDeploymentData[0].ref.name; + reducedDeploymentData[0].xPos = 10; + reducedDeploymentData[0].time = new Date(reducedDeploymentData[0].created_at); + describe('Methods', () => { + it('refText shows the ref when a tag is available', () => { + reducedDeploymentData[0].tag = '1.0'; + const component = createComponent({ + showDeployInfo: false, + deploymentData: reducedDeploymentData, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect( + component.refText(reducedDeploymentData[0]), + ).toEqual(reducedDeploymentData[0].ref); + }); + + it('refText shows the sha when no tag is available', () => { + reducedDeploymentData[0].tag = null; + const component = createComponent({ + showDeployInfo: false, + deploymentData: reducedDeploymentData, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect( + component.refText(reducedDeploymentData[0]), + ).toContain('f5bcd1'); + }); + + it('nameDeploymentClass creates a class with the prefix deploy-info-', () => { + const component = createComponent({ + showDeployInfo: false, + deploymentData: reducedDeploymentData, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect( + component.nameDeploymentClass(reducedDeploymentData[0]), + ).toContain('deploy-info'); + }); + + it('transformDeploymentGroup translates an available deployment', () => { + const component = createComponent({ + showDeployInfo: false, + deploymentData: reducedDeploymentData, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect( + component.transformDeploymentGroup(reducedDeploymentData[0]), + ).toContain('translate(11, 20)'); + }); + + it('hides the deployment flag', () => { + reducedDeploymentData[0].showDeploymentFlag = false; + const component = createComponent({ + showDeployInfo: true, + deploymentData: reducedDeploymentData, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect(component.$el.querySelector('.js-deploy-info-box')).toBeNull(); + }); + + it('shows the deployment flag', () => { + reducedDeploymentData[0].showDeploymentFlag = true; + const component = createComponent({ + showDeployInfo: true, + deploymentData: reducedDeploymentData, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect( + component.$el.querySelector('.js-deploy-info-box').style.display, + ).not.toEqual('display: none;'); + }); + + it('shows the refText inside a text element with the deploy-info-text class', () => { + reducedDeploymentData[0].showDeploymentFlag = true; + const component = createComponent({ + showDeployInfo: true, + deploymentData: reducedDeploymentData, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect( + component.$el.querySelector('.deploy-info-text').firstChild.nodeValue.trim(), + ).toEqual(component.refText(reducedDeploymentData[0])); + }); + + it('should contain a hidden gradient', () => { + const component = createComponent({ + showDeployInfo: true, + deploymentData: reducedDeploymentData, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect(component.$el.querySelector('#shadow-gradient')).not.toBeNull(); + }); + + describe('Computed props', () => { + it('calculatedHeight', () => { + const component = createComponent({ + showDeployInfo: true, + deploymentData: reducedDeploymentData, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect(component.calculatedHeight).toEqual(180); + }); + }); + }); +}); diff --git a/spec/javascripts/monitoring/monitoring_flag_spec.js b/spec/javascripts/monitoring/monitoring_flag_spec.js new file mode 100644 index 00000000000..3861a95ff07 --- /dev/null +++ b/spec/javascripts/monitoring/monitoring_flag_spec.js @@ -0,0 +1,76 @@ +import Vue from 'vue'; +import MonitoringFlag from '~/monitoring/components/monitoring_flag.vue'; + +const createComponent = (propsData) => { + const Component = Vue.extend(MonitoringFlag); + + return new Component({ + propsData, + }).$mount(); +}; + +function getCoordinate(component, selector, coordinate) { + const coordinateVal = component.$el.querySelector(selector).getAttribute(coordinate); + return parseInt(coordinateVal, 10); +} + +describe('MonitoringFlag', () => { + it('has a line and a circle located at the currentXCoordinate and currentYCoordinate', () => { + const component = createComponent({ + currentXCoordinate: 200, + currentYCoordinate: 100, + currentFlagPosition: 100, + currentData: { + time: new Date('2017-06-04T18:17:33.501Z'), + value: '1.49609375', + }, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect(getCoordinate(component, '.selected-metric-line', 'x1')) + .toEqual(component.currentXCoordinate); + expect(getCoordinate(component, '.selected-metric-line', 'x2')) + .toEqual(component.currentXCoordinate); + expect(getCoordinate(component, '.circle-metric', 'cx')) + .toEqual(component.currentXCoordinate); + expect(getCoordinate(component, '.circle-metric', 'cy')) + .toEqual(component.currentYCoordinate); + }); + + it('has a SVG with the class rect-text-metric at the currentFlagPosition', () => { + const component = createComponent({ + currentXCoordinate: 200, + currentYCoordinate: 100, + currentFlagPosition: 100, + currentData: { + time: new Date('2017-06-04T18:17:33.501Z'), + value: '1.49609375', + }, + graphHeight: 300, + graphHeightOffset: 120, + }); + + const svg = component.$el.querySelector('.rect-text-metric'); + expect(svg.tagName).toEqual('svg'); + expect(parseInt(svg.getAttribute('x'), 10)).toEqual(component.currentFlagPosition); + }); + + describe('Computed props', () => { + it('calculatedHeight', () => { + const component = createComponent({ + currentXCoordinate: 200, + currentYCoordinate: 100, + currentFlagPosition: 100, + currentData: { + time: new Date('2017-06-04T18:17:33.501Z'), + value: '1.49609375', + }, + graphHeight: 300, + graphHeightOffset: 120, + }); + + expect(component.calculatedHeight).toEqual(180); + }); + }); +}); diff --git a/spec/javascripts/monitoring/monitoring_legends_spec.js b/spec/javascripts/monitoring/monitoring_legends_spec.js new file mode 100644 index 00000000000..4c69b81e650 --- /dev/null +++ b/spec/javascripts/monitoring/monitoring_legends_spec.js @@ -0,0 +1,111 @@ +import Vue from 'vue'; +import MonitoringLegends from '~/monitoring/components/monitoring_legends.vue'; +import measurements from '~/monitoring/utils/measurements'; + +const createComponent = (propsData) => { + const Component = Vue.extend(MonitoringLegends); + + return new Component({ + propsData, + }).$mount(); +}; + +function getTextFromNode(component, selector) { + return component.$el.querySelector(selector).firstChild.nodeValue.trim(); +} + +describe('MonitoringLegends', () => { + describe('Computed props', () => { + it('textTransform', () => { + const component = createComponent({ + graphWidth: 500, + graphHeight: 300, + margin: measurements.large.margin, + measurements: measurements.large, + areaColorRgb: '#f0f0f0', + legendTitle: 'Title', + yAxisLabel: 'Values', + metricUsage: 'Value', + }); + + expect(component.textTransform).toContain('translate(15, 120) rotate(-90)'); + }); + + it('xPosition', () => { + const component = createComponent({ + graphWidth: 500, + graphHeight: 300, + margin: measurements.large.margin, + measurements: measurements.large, + areaColorRgb: '#f0f0f0', + legendTitle: 'Title', + yAxisLabel: 'Values', + metricUsage: 'Value', + }); + + expect(component.xPosition).toEqual(180); + }); + + it('yPosition', () => { + const component = createComponent({ + graphWidth: 500, + graphHeight: 300, + margin: measurements.large.margin, + measurements: measurements.large, + areaColorRgb: '#f0f0f0', + legendTitle: 'Title', + yAxisLabel: 'Values', + metricUsage: 'Value', + }); + + expect(component.yPosition).toEqual(240); + }); + + it('rectTransform', () => { + const component = createComponent({ + graphWidth: 500, + graphHeight: 300, + margin: measurements.large.margin, + measurements: measurements.large, + areaColorRgb: '#f0f0f0', + legendTitle: 'Title', + yAxisLabel: 'Values', + metricUsage: 'Value', + }); + + expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)'); + }); + }); + + it('has 2 rect-axis-text rect svg elements', () => { + const component = createComponent({ + graphWidth: 500, + graphHeight: 300, + margin: measurements.large.margin, + measurements: measurements.large, + areaColorRgb: '#f0f0f0', + legendTitle: 'Title', + yAxisLabel: 'Values', + metricUsage: 'Value', + }); + + expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2); + }); + + it('contains text to signal the usage, title and time', () => { + const component = createComponent({ + graphWidth: 500, + graphHeight: 300, + margin: measurements.large.margin, + measurements: measurements.large, + areaColorRgb: '#f0f0f0', + legendTitle: 'Title', + yAxisLabel: 'Values', + metricUsage: 'Value', + }); + + expect(getTextFromNode(component, '.text-metric-title')).toEqual(component.legendTitle); + expect(getTextFromNode(component, '.text-metric-usage')).toEqual(component.metricUsage); + expect(getTextFromNode(component, '.label-axis-text')).toEqual(component.yAxisLabel); + }); +}); diff --git a/spec/javascripts/monitoring/monitoring_row_spec.js b/spec/javascripts/monitoring/monitoring_row_spec.js new file mode 100644 index 00000000000..a82480e8342 --- /dev/null +++ b/spec/javascripts/monitoring/monitoring_row_spec.js @@ -0,0 +1,57 @@ +import Vue from 'vue'; +import MonitoringRow from '~/monitoring/components/monitoring_row.vue'; +import { deploymentData, singleRowMetrics } from './mock_data'; + +const createComponent = (propsData) => { + const Component = Vue.extend(MonitoringRow); + + return new Component({ + propsData, + }).$mount(); +}; + +describe('MonitoringRow', () => { + describe('Computed props', () => { + it('bootstrapClass is set to col-md-6 when rowData is higher/equal to 2', () => { + const component = createComponent({ + rowData: singleRowMetrics, + updateAspectRatio: false, + deploymentData, + }); + + expect(component.bootstrapClass).toEqual('col-md-6'); + }); + + it('bootstrapClass is set to col-md-12 when rowData is lower than 2', () => { + const component = createComponent({ + rowData: [singleRowMetrics[0]], + updateAspectRatio: false, + deploymentData, + }); + + expect(component.bootstrapClass).toEqual('col-md-12'); + }); + }); + + it('has one column', () => { + const component = createComponent({ + rowData: singleRowMetrics, + updateAspectRatio: false, + deploymentData, + }); + + expect(component.$el.querySelectorAll('.prometheus-svg-container').length) + .toEqual(component.rowData.length); + }); + + it('has two columns', () => { + const component = createComponent({ + rowData: singleRowMetrics, + updateAspectRatio: false, + deploymentData, + }); + + expect(component.$el.querySelectorAll('.col-md-6').length) + .toEqual(component.rowData.length); + }); +}); diff --git a/spec/javascripts/monitoring/monitoring_spec.js b/spec/javascripts/monitoring/monitoring_spec.js new file mode 100644 index 00000000000..6c7b691baa4 --- /dev/null +++ b/spec/javascripts/monitoring/monitoring_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import Monitoring from '~/monitoring/components/monitoring.vue'; +import { MonitorMockInterceptor } from './mock_data'; + +describe('Monitoring', () => { + const fixtureName = 'environments/metrics/metrics.html.raw'; + let MonitoringComponent; + let component; + preloadFixtures(fixtureName); + + beforeEach(() => { + loadFixtures(fixtureName); + MonitoringComponent = Vue.extend(Monitoring); + }); + + describe('no metrics are available yet', () => { + it('shows a getting started empty state when no metrics are present', () => { + component = new MonitoringComponent({ + el: document.querySelector('#prometheus-graphs'), + }); + + component.$mount(); + expect(component.$el.querySelector('#prometheus-graphs')).toBe(null); + expect(component.state).toEqual('gettingStarted'); + }); + }); + + describe('requests information to the server', () => { + beforeEach(() => { + document.querySelector('#prometheus-graphs').setAttribute('data-has-metrics', 'true'); + Vue.http.interceptors.push(MonitorMockInterceptor); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, MonitorMockInterceptor); + }); + + it('shows up a loading state', (done) => { + component = new MonitoringComponent({ + el: document.querySelector('#prometheus-graphs'), + }); + component.$mount(); + Vue.nextTick(() => { + expect(component.state).toEqual('loading'); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/monitoring/monitoring_state_spec.js b/spec/javascripts/monitoring/monitoring_state_spec.js new file mode 100644 index 00000000000..4c0c558502f --- /dev/null +++ b/spec/javascripts/monitoring/monitoring_state_spec.js @@ -0,0 +1,110 @@ +import Vue from 'vue'; +import MonitoringState from '~/monitoring/components/monitoring_state.vue'; +import { statePaths } from './mock_data'; + +const createComponent = (propsData) => { + const Component = Vue.extend(MonitoringState); + + return new Component({ + propsData, + }).$mount(); +}; + +function getTextFromNode(component, selector) { + return component.$el.querySelector(selector).firstChild.nodeValue.trim(); +} + +describe('MonitoringState', () => { + describe('Computed props', () => { + it('currentState', () => { + const component = createComponent({ + selectedState: 'gettingStarted', + settingsPath: statePaths.settingsPath, + documentationPath: statePaths.documentationPath, + }); + + expect(component.currentState).toBe(component.states.gettingStarted); + }); + + it('buttonPath returns settings path for the state "gettingStarted"', () => { + const component = createComponent({ + selectedState: 'gettingStarted', + settingsPath: statePaths.settingsPath, + documentationPath: statePaths.documentationPath, + }); + + expect(component.buttonPath).toEqual(statePaths.settingsPath); + expect(component.buttonPath).not.toEqual(statePaths.documentationPath); + }); + + it('buttonPath returns documentation path for any of the other states', () => { + const component = createComponent({ + selectedState: 'loading', + settingsPath: statePaths.settingsPath, + documentationPath: statePaths.documentationPath, + }); + + expect(component.buttonPath).toEqual(statePaths.documentationPath); + expect(component.buttonPath).not.toEqual(statePaths.settingsPath); + }); + + it('showButtonDescription returns a description with a link for the unableToConnect state', () => { + const component = createComponent({ + selectedState: 'unableToConnect', + settingsPath: statePaths.settingsPath, + documentationPath: statePaths.documentationPath, + }); + + expect(component.showButtonDescription).toEqual(true); + }); + + it('showButtonDescription returns the description without a link for any other state', () => { + const component = createComponent({ + selectedState: 'loading', + settingsPath: statePaths.settingsPath, + documentationPath: statePaths.documentationPath, + }); + + expect(component.showButtonDescription).toEqual(false); + }); + }); + + it('should show the gettingStarted state', () => { + const component = createComponent({ + selectedState: 'gettingStarted', + settingsPath: statePaths.settingsPath, + documentationPath: statePaths.documentationPath, + }); + + expect(component.$el.querySelector('svg')).toBeDefined(); + expect(getTextFromNode(component, '.state-title')).toEqual(component.states.gettingStarted.title); + expect(getTextFromNode(component, '.state-description')).toEqual(component.states.gettingStarted.description); + expect(getTextFromNode(component, '.btn-success')).toEqual(component.states.gettingStarted.buttonText); + }); + + it('should show the loading state', () => { + const component = createComponent({ + selectedState: 'loading', + settingsPath: statePaths.settingsPath, + documentationPath: statePaths.documentationPath, + }); + + expect(component.$el.querySelector('svg')).toBeDefined(); + expect(getTextFromNode(component, '.state-title')).toEqual(component.states.loading.title); + expect(getTextFromNode(component, '.state-description')).toEqual(component.states.loading.description); + expect(getTextFromNode(component, '.btn-success')).toEqual(component.states.loading.buttonText); + }); + + it('should show the unableToConnect state', () => { + const component = createComponent({ + selectedState: 'unableToConnect', + settingsPath: statePaths.settingsPath, + documentationPath: statePaths.documentationPath, + }); + + expect(component.$el.querySelector('svg')).toBeDefined(); + expect(getTextFromNode(component, '.state-title')).toEqual(component.states.unableToConnect.title); + expect(component.$el.querySelector('.state-description a')).toBeDefined(); + expect(getTextFromNode(component, '.btn-success')).toEqual(component.states.unableToConnect.buttonText); + }); +}); diff --git a/spec/javascripts/monitoring/monitoring_store_spec.js b/spec/javascripts/monitoring/monitoring_store_spec.js new file mode 100644 index 00000000000..20c1e6a0005 --- /dev/null +++ b/spec/javascripts/monitoring/monitoring_store_spec.js @@ -0,0 +1,24 @@ +import MonitoringStore from '~/monitoring/stores/monitoring_store'; +import MonitoringMock, { deploymentData } from './mock_data'; + +describe('MonitoringStore', () => { + this.store = new MonitoringStore(); + this.store.storeMetrics(MonitoringMock.data); + + it('contains one group that contains two queries sorted by priority in one row', () => { + expect(this.store.groups).toBeDefined(); + expect(this.store.groups.length).toEqual(1); + expect(this.store.groups[0].metrics.length).toEqual(1); + }); + + it('gets the metrics count for every group', () => { + expect(this.store.getMetricsCount()).toEqual(2); + }); + + it('contains deployment data', () => { + this.store.storeDeploymentData(deploymentData); + expect(this.store.deploymentData).toBeDefined(); + expect(this.store.deploymentData.length).toEqual(3); + expect(typeof this.store.deploymentData[0]).toEqual('object'); + }); +}); diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js deleted file mode 100644 index 25578bf1c6e..00000000000 --- a/spec/javascripts/monitoring/prometheus_graph_spec.js +++ /dev/null @@ -1,98 +0,0 @@ -import 'jquery'; -import PrometheusGraph from '~/monitoring/prometheus_graph'; -import { prometheusMockData } from './prometheus_mock_data'; - -describe('PrometheusGraph', () => { - const fixtureName = 'environments/metrics/metrics.html.raw'; - const prometheusGraphContainer = '.prometheus-graph'; - const prometheusGraphContents = `${prometheusGraphContainer}[graph-type=cpu_values]`; - - preloadFixtures(fixtureName); - - beforeEach(() => { - loadFixtures(fixtureName); - $('.prometheus-container').data('has-metrics', 'true'); - this.prometheusGraph = new PrometheusGraph(); - const self = this; - const fakeInit = (metricsResponse) => { - self.prometheusGraph.transformData(metricsResponse); - self.prometheusGraph.createGraph(); - }; - spyOn(this.prometheusGraph, 'init').and.callFake(fakeInit); - }); - - it('initializes graph properties', () => { - // Test for the measurements - expect(this.prometheusGraph.margin).toBeDefined(); - expect(this.prometheusGraph.marginLabelContainer).toBeDefined(); - expect(this.prometheusGraph.originalWidth).toBeDefined(); - expect(this.prometheusGraph.originalHeight).toBeDefined(); - expect(this.prometheusGraph.height).toBeDefined(); - expect(this.prometheusGraph.width).toBeDefined(); - expect(this.prometheusGraph.backOffRequestCounter).toBeDefined(); - // Test for the graph properties (colors, radius, etc.) - expect(this.prometheusGraph.graphSpecificProperties).toBeDefined(); - expect(this.prometheusGraph.commonGraphProperties).toBeDefined(); - }); - - it('transforms the data', () => { - this.prometheusGraph.init(prometheusMockData.metrics); - Object.keys(this.prometheusGraph.graphSpecificProperties, (key) => { - const graphProps = this.prometheusGraph.graphSpecificProperties[key]; - expect(graphProps.data).toBeDefined(); - expect(graphProps.data.length).toBe(121); - }); - }); - - it('creates two graphs', () => { - this.prometheusGraph.init(prometheusMockData.metrics); - expect($(prometheusGraphContainer).length).toBe(2); - }); - - describe('Graph contents', () => { - beforeEach(() => { - this.prometheusGraph.init(prometheusMockData.metrics); - }); - - it('has axis, an area, a line and a overlay', () => { - const $graphContainer = $(prometheusGraphContents).find('.x-axis').parent(); - expect($graphContainer.find('.x-axis')).toBeDefined(); - expect($graphContainer.find('.y-axis')).toBeDefined(); - expect($graphContainer.find('.prometheus-graph-overlay')).toBeDefined(); - expect($graphContainer.find('.metric-line')).toBeDefined(); - expect($graphContainer.find('.metric-area')).toBeDefined(); - }); - - it('has legends, labels and an extra axis that labels the metrics', () => { - const $prometheusGraphContents = $(prometheusGraphContents); - const $axisLabelContainer = $(prometheusGraphContents).find('.label-x-axis-line').parent(); - expect($prometheusGraphContents.find('.label-x-axis-line')).toBeDefined(); - expect($prometheusGraphContents.find('.label-y-axis-line')).toBeDefined(); - expect($prometheusGraphContents.find('.label-axis-text')).toBeDefined(); - expect($prometheusGraphContents.find('.rect-axis-text')).toBeDefined(); - expect($axisLabelContainer.find('rect').length).toBe(3); - expect($axisLabelContainer.find('text').length).toBe(4); - }); - }); -}); - -describe('PrometheusGraphs UX states', () => { - const fixtureName = 'environments/metrics/metrics.html.raw'; - preloadFixtures(fixtureName); - - beforeEach(() => { - loadFixtures(fixtureName); - this.prometheusGraph = new PrometheusGraph(); - }); - - it('shows a specified state', () => { - this.prometheusGraph.state = '.js-getting-started'; - this.prometheusGraph.updateState(); - const $state = $('.js-getting-started'); - expect($state).toBeDefined(); - expect($('.state-title', $state)).toBeDefined(); - expect($('.state-svg', $state)).toBeDefined(); - expect($('.state-description', $state)).toBeDefined(); - expect($('.state-button', $state)).toBeDefined(); - }); -}); diff --git a/spec/javascripts/monitoring/prometheus_mock_data.js b/spec/javascripts/monitoring/prometheus_mock_data.js deleted file mode 100644 index 1cdc14faaa8..00000000000 --- a/spec/javascripts/monitoring/prometheus_mock_data.js +++ /dev/null @@ -1,1014 +0,0 @@ -/* eslint-disable import/prefer-default-export*/ -export const prometheusMockData = { - status: 200, - metrics: { - success: true, - metrics: { - memory_values: [ - { - metric: { - }, - values: [ - [ - 1488462917.256, - '10.12890625', - ], - [ - 1488462977.256, - '10.140625', - ], - [ - 1488463037.256, - '10.140625', - ], - [ - 1488463097.256, - '10.14453125', - ], - [ - 1488463157.256, - '10.1484375', - ], - [ - 1488463217.256, - '10.15625', - ], - [ - 1488463277.256, - '10.15625', - ], - [ - 1488463337.256, - '10.15625', - ], - [ - 1488463397.256, - '10.1640625', - ], - [ - 1488463457.256, - '10.171875', - ], - [ - 1488463517.256, - '10.171875', - ], - [ - 1488463577.256, - '10.171875', - ], - [ - 1488463637.256, - '10.18359375', - ], - [ - 1488463697.256, - '10.1953125', - ], - [ - 1488463757.256, - '10.203125', - ], - [ - 1488463817.256, - '10.20703125', - ], - [ - 1488463877.256, - '10.20703125', - ], - [ - 1488463937.256, - '10.20703125', - ], - [ - 1488463997.256, - '10.20703125', - ], - [ - 1488464057.256, - '10.2109375', - ], - [ - 1488464117.256, - '10.2109375', - ], - [ - 1488464177.256, - '10.2109375', - ], - [ - 1488464237.256, - '10.2109375', - ], - [ - 1488464297.256, - '10.21484375', - ], - [ - 1488464357.256, - '10.22265625', - ], - [ - 1488464417.256, - '10.22265625', - ], - [ - 1488464477.256, - '10.2265625', - ], - [ - 1488464537.256, - '10.23046875', - ], - [ - 1488464597.256, - '10.23046875', - ], - [ - 1488464657.256, - '10.234375', - ], - [ - 1488464717.256, - '10.234375', - ], - [ - 1488464777.256, - '10.234375', - ], - [ - 1488464837.256, - '10.234375', - ], - [ - 1488464897.256, - '10.234375', - ], - [ - 1488464957.256, - '10.234375', - ], - [ - 1488465017.256, - '10.23828125', - ], - [ - 1488465077.256, - '10.23828125', - ], - [ - 1488465137.256, - '10.2421875', - ], - [ - 1488465197.256, - '10.2421875', - ], - [ - 1488465257.256, - '10.2421875', - ], - [ - 1488465317.256, - '10.2421875', - ], - [ - 1488465377.256, - '10.2421875', - ], - [ - 1488465437.256, - '10.2421875', - ], - [ - 1488465497.256, - '10.2421875', - ], - [ - 1488465557.256, - '10.2421875', - ], - [ - 1488465617.256, - '10.2421875', - ], - [ - 1488465677.256, - '10.2421875', - ], - [ - 1488465737.256, - '10.2421875', - ], - [ - 1488465797.256, - '10.24609375', - ], - [ - 1488465857.256, - '10.25', - ], - [ - 1488465917.256, - '10.25390625', - ], - [ - 1488465977.256, - '9.98828125', - ], - [ - 1488466037.256, - '9.9921875', - ], - [ - 1488466097.256, - '9.9921875', - ], - [ - 1488466157.256, - '9.99609375', - ], - [ - 1488466217.256, - '10', - ], - [ - 1488466277.256, - '10.00390625', - ], - [ - 1488466337.256, - '10.0078125', - ], - [ - 1488466397.256, - '10.01171875', - ], - [ - 1488466457.256, - '10.0234375', - ], - [ - 1488466517.256, - '10.02734375', - ], - [ - 1488466577.256, - '10.02734375', - ], - [ - 1488466637.256, - '10.03125', - ], - [ - 1488466697.256, - '10.03125', - ], - [ - 1488466757.256, - '10.03125', - ], - [ - 1488466817.256, - '10.03125', - ], - [ - 1488466877.256, - '10.03125', - ], - [ - 1488466937.256, - '10.03125', - ], - [ - 1488466997.256, - '10.03125', - ], - [ - 1488467057.256, - '10.0390625', - ], - [ - 1488467117.256, - '10.0390625', - ], - [ - 1488467177.256, - '10.04296875', - ], - [ - 1488467237.256, - '10.05078125', - ], - [ - 1488467297.256, - '10.05859375', - ], - [ - 1488467357.256, - '10.06640625', - ], - [ - 1488467417.256, - '10.06640625', - ], - [ - 1488467477.256, - '10.0703125', - ], - [ - 1488467537.256, - '10.07421875', - ], - [ - 1488467597.256, - '10.0859375', - ], - [ - 1488467657.256, - '10.0859375', - ], - [ - 1488467717.256, - '10.09765625', - ], - [ - 1488467777.256, - '10.1015625', - ], - [ - 1488467837.256, - '10.10546875', - ], - [ - 1488467897.256, - '10.10546875', - ], - [ - 1488467957.256, - '10.125', - ], - [ - 1488468017.256, - '10.13671875', - ], - [ - 1488468077.256, - '10.1484375', - ], - [ - 1488468137.256, - '10.15625', - ], - [ - 1488468197.256, - '10.16796875', - ], - [ - 1488468257.256, - '10.171875', - ], - [ - 1488468317.256, - '10.171875', - ], - [ - 1488468377.256, - '10.171875', - ], - [ - 1488468437.256, - '10.171875', - ], - [ - 1488468497.256, - '10.171875', - ], - [ - 1488468557.256, - '10.171875', - ], - [ - 1488468617.256, - '10.171875', - ], - [ - 1488468677.256, - '10.17578125', - ], - [ - 1488468737.256, - '10.17578125', - ], - [ - 1488468797.256, - '10.265625', - ], - [ - 1488468857.256, - '10.19921875', - ], - [ - 1488468917.256, - '10.19921875', - ], - [ - 1488468977.256, - '10.19921875', - ], - [ - 1488469037.256, - '10.19921875', - ], - [ - 1488469097.256, - '10.19921875', - ], - [ - 1488469157.256, - '10.203125', - ], - [ - 1488469217.256, - '10.43359375', - ], - [ - 1488469277.256, - '10.20703125', - ], - [ - 1488469337.256, - '10.2109375', - ], - [ - 1488469397.256, - '10.22265625', - ], - [ - 1488469457.256, - '10.21484375', - ], - [ - 1488469517.256, - '10.21484375', - ], - [ - 1488469577.256, - '10.21484375', - ], - [ - 1488469637.256, - '10.22265625', - ], - [ - 1488469697.256, - '10.234375', - ], - [ - 1488469757.256, - '10.234375', - ], - [ - 1488469817.256, - '10.234375', - ], - [ - 1488469877.256, - '10.2421875', - ], - [ - 1488469937.256, - '10.25', - ], - [ - 1488469997.256, - '10.25390625', - ], - [ - 1488470057.256, - '10.26171875', - ], - [ - 1488470117.256, - '10.2734375', - ], - ], - }, - ], - memory_current: [ - { - metric: { - }, - value: [ - 1488470117.737, - '10.2734375', - ], - }, - ], - cpu_values: [ - { - metric: { - }, - values: [ - [ - 1488462918.15, - '0.0002996458625058103', - ], - [ - 1488462978.15, - '0.0002652382333333314', - ], - [ - 1488463038.15, - '0.0003485461333333421', - ], - [ - 1488463098.15, - '0.0003420421999999886', - ], - [ - 1488463158.15, - '0.00023107150000001297', - ], - [ - 1488463218.15, - '0.00030463981666664826', - ], - [ - 1488463278.15, - '0.0002477177833333677', - ], - [ - 1488463338.15, - '0.00026936656666665115', - ], - [ - 1488463398.15, - '0.000406264750000022', - ], - [ - 1488463458.15, - '0.00029592802026561453', - ], - [ - 1488463518.15, - '0.00023426999683316343', - ], - [ - 1488463578.15, - '0.0003057080666666915', - ], - [ - 1488463638.15, - '0.0003408470500000149', - ], - [ - 1488463698.15, - '0.00025497336666665166', - ], - [ - 1488463758.15, - '0.0003009282833333534', - ], - [ - 1488463818.15, - '0.0003119383499999924', - ], - [ - 1488463878.15, - '0.00028719019999998705', - ], - [ - 1488463938.15, - '0.000327864749999988', - ], - [ - 1488463998.15, - '0.0002514917333333422', - ], - [ - 1488464058.15, - '0.0003614651166666742', - ], - [ - 1488464118.15, - '0.0003221668000000122', - ], - [ - 1488464178.15, - '0.00023323083333330884', - ], - [ - 1488464238.15, - '0.00028531499475009274', - ], - [ - 1488464298.15, - '0.0002627695294921391', - ], - [ - 1488464358.15, - '0.00027145463333333453', - ], - [ - 1488464418.15, - '0.00025669488333335266', - ], - [ - 1488464478.15, - '0.00022307761666665965', - ], - [ - 1488464538.15, - '0.0003307265833333517', - ], - [ - 1488464598.15, - '0.0002817050666666709', - ], - [ - 1488464658.15, - '0.00022357458333332285', - ], - [ - 1488464718.15, - '0.00032648590000000275', - ], - [ - 1488464778.15, - '0.00028410750000000816', - ], - [ - 1488464838.15, - '0.0003038076999999954', - ], - [ - 1488464898.15, - '0.00037568226666667335', - ], - [ - 1488464958.15, - '0.00020160354999999202', - ], - [ - 1488465018.15, - '0.0003229403333333399', - ], - [ - 1488465078.15, - '0.00033516069999999236', - ], - [ - 1488465138.15, - '0.0003365978333333371', - ], - [ - 1488465198.15, - '0.00020262178333331585', - ], - [ - 1488465258.15, - '0.00040567498333331876', - ], - [ - 1488465318.15, - '0.00029114155000001436', - ], - [ - 1488465378.15, - '0.0002498841000000122', - ], - [ - 1488465438.15, - '0.00027296763333331715', - ], - [ - 1488465498.15, - '0.0002958794000000135', - ], - [ - 1488465558.15, - '0.0002922354666666867', - ], - [ - 1488465618.15, - '0.00034186624999999653', - ], - [ - 1488465678.15, - '0.0003397984166666627', - ], - [ - 1488465738.15, - '0.0002658284166666469', - ], - [ - 1488465798.15, - '0.00026221139999999346', - ], - [ - 1488465858.15, - '0.00029467960000001034', - ], - [ - 1488465918.15, - '0.0002634141333333358', - ], - [ - 1488465978.15, - '0.0003202958333333209', - ], - [ - 1488466038.15, - '0.00037890760000000394', - ], - [ - 1488466098.15, - '0.00023453356666666518', - ], - [ - 1488466158.15, - '0.0002866827333333433', - ], - [ - 1488466218.15, - '0.0003335935499999998', - ], - [ - 1488466278.15, - '0.00022787131666666125', - ], - [ - 1488466338.15, - '0.00033821938333333064', - ], - [ - 1488466398.15, - '0.00029233375000001043', - ], - [ - 1488466458.15, - '0.00026562758333333514', - ], - [ - 1488466518.15, - '0.0003142600999999819', - ], - [ - 1488466578.15, - '0.00027392178333333444', - ], - [ - 1488466638.15, - '0.00028178598333334173', - ], - [ - 1488466698.15, - '0.0002463400666666911', - ], - [ - 1488466758.15, - '0.00040234373333332125', - ], - [ - 1488466818.15, - '0.00023677453333332822', - ], - [ - 1488466878.15, - '0.00030852703333333523', - ], - [ - 1488466938.15, - '0.0003582272833333455', - ], - [ - 1488466998.15, - '0.0002176380833332973', - ], - [ - 1488467058.15, - '0.00026180203333335447', - ], - [ - 1488467118.15, - '0.00027862966666667436', - ], - [ - 1488467178.15, - '0.0002769731166666567', - ], - [ - 1488467238.15, - '0.0002832899166666477', - ], - [ - 1488467298.15, - '0.0003446533500000311', - ], - [ - 1488467358.15, - '0.0002691345999999761', - ], - [ - 1488467418.15, - '0.000284919933333357', - ], - [ - 1488467478.15, - '0.0002396026166666528', - ], - [ - 1488467538.15, - '0.00035625295000002075', - ], - [ - 1488467598.15, - '0.00036759816666664946', - ], - [ - 1488467658.15, - '0.00030326608333333855', - ], - [ - 1488467718.15, - '0.00023584972418043393', - ], - [ - 1488467778.15, - '0.00025744508892115107', - ], - [ - 1488467838.15, - '0.00036737541666663395', - ], - [ - 1488467898.15, - '0.00034325741666666094', - ], - [ - 1488467958.15, - '0.00026390046666667407', - ], - [ - 1488468018.15, - '0.0003302534500000102', - ], - [ - 1488468078.15, - '0.00035243794999999527', - ], - [ - 1488468138.15, - '0.00020149738333333407', - ], - [ - 1488468198.15, - '0.0003183469666666679', - ], - [ - 1488468258.15, - '0.0003835329166666845', - ], - [ - 1488468318.15, - '0.0002485075333333124', - ], - [ - 1488468378.15, - '0.0003011457166666768', - ], - [ - 1488468438.15, - '0.00032242785497684965', - ], - [ - 1488468498.15, - '0.0002659713747457531', - ], - [ - 1488468558.15, - '0.0003476860333333202', - ], - [ - 1488468618.15, - '0.00028336403333334794', - ], - [ - 1488468678.15, - '0.00017132354999998728', - ], - [ - 1488468738.15, - '0.0003001915833333276', - ], - [ - 1488468798.15, - '0.0003025715666666725', - ], - [ - 1488468858.15, - '0.0003012370166666815', - ], - [ - 1488468918.15, - '0.00030203619999997025', - ], - [ - 1488468978.15, - '0.0002804355000000314', - ], - [ - 1488469038.15, - '0.00033194884999998564', - ], - [ - 1488469098.15, - '0.00025201496666665455', - ], - [ - 1488469158.15, - '0.0002777531500000189', - ], - [ - 1488469218.15, - '0.0003314885833333392', - ], - [ - 1488469278.15, - '0.0002234891422095589', - ], - [ - 1488469338.15, - '0.000349117355867791', - ], - [ - 1488469398.15, - '0.0004036731333333303', - ], - [ - 1488469458.15, - '0.00024553911666667835', - ], - [ - 1488469518.15, - '0.0003056456833333184', - ], - [ - 1488469578.15, - '0.0002618737166666681', - ], - [ - 1488469638.15, - '0.00022972643333331414', - ], - [ - 1488469698.15, - '0.0003713522500000307', - ], - [ - 1488469758.15, - '0.00018322576666666515', - ], - [ - 1488469818.15, - '0.00034534762753952466', - ], - [ - 1488469878.15, - '0.00028200510008501677', - ], - [ - 1488469938.15, - '0.0002773708499999768', - ], - [ - 1488469998.15, - '0.00027547160000001013', - ], - [ - 1488470058.15, - '0.00031713610000000023', - ], - [ - 1488470118.15, - '0.00035276853333332525', - ], - ], - }, - ], - cpu_current: [ - { - metric: { - }, - value: [ - 1488470118.566, - '0.00035276853333332525', - ], - }, - ], - last_update: '2017-03-02T15:55:18.981Z', - }, - }, -}; diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb index 5653cfee686..8813f129ef5 100644 --- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_base_spec.rb @@ -6,6 +6,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :trunca before do allow(migration).to receive(:say) + TestEnv.clean_test_path end def migration_namespace(namespace) @@ -153,6 +154,30 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :trunca end end + describe '#perform_rename' do + describe 'for namespaces' do + let(:namespace) { create(:namespace, path: 'the-path') } + it 'renames the path' do + subject.perform_rename(migration_namespace(namespace), 'the-path', 'renamed') + + expect(namespace.reload.path).to eq('renamed') + end + + it 'renames all the routes for the namespace' do + child = create(:group, path: 'child', parent: namespace) + project = create(:project, namespace: child, path: 'the-project') + other_one = create(:namespace, path: 'the-path-is-similar') + + subject.perform_rename(migration_namespace(namespace), 'the-path', 'renamed') + + expect(namespace.reload.route.path).to eq('renamed') + expect(child.reload.route.path).to eq('renamed/child') + expect(project.reload.route.path).to eq('renamed/child/the-project') + expect(other_one.reload.route.path).to eq('the-path-is-similar') + end + end + end + describe '#move_pages' do it 'moves the pages directory' do expect(subject).to receive(:move_folders) @@ -203,4 +228,53 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameBase, :trunca expect(File.exist?(expected_file)).to be(true) end end + + describe '#track_rename', redis: true do + it 'tracks a rename in redis' do + key = 'rename:FakeRenameReservedPathMigrationV1:namespace' + + subject.track_rename('namespace', 'path/to/namespace', 'path/to/renamed') + + old_path, new_path = [nil, nil] + Gitlab::Redis.with do |redis| + rename_info = redis.lpop(key) + old_path, new_path = JSON.parse(rename_info) + end + + expect(old_path).to eq('path/to/namespace') + expect(new_path).to eq('path/to/renamed') + end + end + + describe '#reverts_for_type', redis: true do + it 'yields for each tracked rename' do + subject.track_rename('project', 'old_path', 'new_path') + subject.track_rename('project', 'old_path2', 'new_path2') + subject.track_rename('namespace', 'namespace_path', 'new_namespace_path') + + expect { |b| subject.reverts_for_type('project', &b) } + .to yield_successive_args(%w(old_path2 new_path2), %w(old_path new_path)) + expect { |b| subject.reverts_for_type('namespace', &b) } + .to yield_with_args('namespace_path', 'new_namespace_path') + end + + it 'keeps the revert in redis if it failed' do + subject.track_rename('project', 'old_path', 'new_path') + + subject.reverts_for_type('project') do + raise 'whatever happens, keep going!' + end + + key = 'rename:FakeRenameReservedPathMigrationV1:project' + stored_renames = nil + rename_count = 0 + Gitlab::Redis.with do |redis| + stored_renames = redis.lrange(key, 0, 1) + rename_count = redis.llen(key) + end + + expect(rename_count).to eq(1) + expect(JSON.parse(stored_renames.first)).to eq(%w(old_path new_path)) + end + end end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb index 8125dedd3fc..803e923b4a5 100644 --- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb @@ -3,9 +3,11 @@ require 'spec_helper' describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, :truncate do let(:migration) { FakeRenameReservedPathMigrationV1.new } let(:subject) { described_class.new(['the-path'], migration) } + let(:namespace) { create(:group, name: 'the-path') } before do allow(migration).to receive(:say) + TestEnv.clean_test_path end def migration_namespace(namespace) @@ -137,8 +139,6 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, : end describe "#rename_namespace" do - let(:namespace) { create(:group, name: 'the-path') } - it 'renames paths & routes for the namespace' do expect(subject).to receive(:rename_path_for_routable) .with(namespace) @@ -149,11 +149,27 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, : expect(namespace.reload.path).to eq('the-path0') end + it 'tracks the rename' do + expect(subject).to receive(:track_rename) + .with('namespace', 'the-path', 'the-path0') + + subject.rename_namespace(namespace) + end + + it 'renames things related to the namespace' do + expect(subject).to receive(:rename_namespace_dependencies) + .with(namespace, 'the-path', 'the-path0') + + subject.rename_namespace(namespace) + end + end + + describe '#rename_namespace_dependencies' do it "moves the the repository for a project in the namespace" do create(:project, namespace: namespace, path: "the-path-project") expected_repo = File.join(TestEnv.repos_path, "the-path0", "the-path-project.git") - subject.rename_namespace(namespace) + subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0') expect(File.directory?(expected_repo)).to be(true) end @@ -161,13 +177,13 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, : it "moves the uploads for the namespace" do expect(subject).to receive(:move_uploads).with("the-path", "the-path0") - subject.rename_namespace(namespace) + subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0') end it "moves the pages for the namespace" do expect(subject).to receive(:move_pages).with("the-path", "the-path0") - subject.rename_namespace(namespace) + subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0') end it 'invalidates the markdown cache of related projects' do @@ -175,13 +191,13 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, : expect(subject).to receive(:remove_cached_html_for_projects).with([project.id]) - subject.rename_namespace(namespace) + subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0') end it "doesn't rename users for other namespaces" do expect(subject).not_to receive(:rename_user) - subject.rename_namespace(namespace) + subject.rename_namespace_dependencies(namespace, 'the-path', 'the-path0') end it 'renames the username of a namespace for a user' do @@ -189,7 +205,7 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, : expect(subject).to receive(:rename_user).with('the-path', 'the-path0') - subject.rename_namespace(user.namespace) + subject.rename_namespace_dependencies(user.namespace, 'the-path', 'the-path0') end end @@ -224,4 +240,50 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces, : subject.rename_namespaces(type: :child) end end + + describe '#revert_renames', redis: true do + it 'renames the routes back to the previous values' do + project = create(:project, path: 'a-project', namespace: namespace) + subject.rename_namespace(namespace) + + expect(subject).to receive(:perform_rename) + .with( + kind_of(Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::Namespace), + 'the-path0', + 'the-path' + ).and_call_original + + subject.revert_renames + + expect(namespace.reload.path).to eq('the-path') + expect(namespace.reload.route.path).to eq('the-path') + expect(project.reload.route.path).to eq('the-path/a-project') + end + + it 'moves the repositories back to their original place' do + project = create(:project, path: 'a-project', namespace: namespace) + project.create_repository + subject.rename_namespace(namespace) + + expected_path = File.join(TestEnv.repos_path, 'the-path', 'a-project.git') + + expect(subject).to receive(:rename_namespace_dependencies) + .with( + kind_of(Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::Namespace), + 'the-path0', + 'the-path' + ).and_call_original + + subject.revert_renames + + expect(File.directory?(expected_path)).to be_truthy + end + + it "doesn't break when the namespace was renamed" do + subject.rename_namespace(namespace) + namespace.update_attributes!(path: 'renamed-afterwards') + + expect { subject.revert_renames }.not_to raise_error + end + end end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb index 802f77ad430..0e240a5ccf1 100644 --- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_projects_spec.rb @@ -3,9 +3,15 @@ require 'spec_helper' describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :truncate do let(:migration) { FakeRenameReservedPathMigrationV1.new } let(:subject) { described_class.new(['the-path'], migration) } + let(:project) do + create(:empty_project, + path: 'the-path', + namespace: create(:namespace, path: 'known-parent' )) + end before do allow(migration).to receive(:say) + TestEnv.clean_test_path end describe '#projects_for_paths' do @@ -47,12 +53,6 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :tr end describe '#rename_project' do - let(:project) do - create(:empty_project, - path: 'the-path', - namespace: create(:namespace, path: 'known-parent' )) - end - it 'renames path & route for the project' do expect(subject).to receive(:rename_path_for_routable) .with(project) @@ -63,27 +63,42 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :tr expect(project.reload.path).to eq('the-path0') end + it 'tracks the rename' do + expect(subject).to receive(:track_rename) + .with('project', 'known-parent/the-path', 'known-parent/the-path0') + + subject.rename_project(project) + end + + it 'renames the folders for the project' do + expect(subject).to receive(:move_project_folders).with(project, 'known-parent/the-path', 'known-parent/the-path0') + + subject.rename_project(project) + end + end + + describe '#move_project_folders' do it 'moves the wiki & the repo' do expect(subject).to receive(:move_repository) .with(project, 'known-parent/the-path.wiki', 'known-parent/the-path0.wiki') expect(subject).to receive(:move_repository) .with(project, 'known-parent/the-path', 'known-parent/the-path0') - subject.rename_project(project) + subject.move_project_folders(project, 'known-parent/the-path', 'known-parent/the-path0') end it 'moves uploads' do expect(subject).to receive(:move_uploads) .with('known-parent/the-path', 'known-parent/the-path0') - subject.rename_project(project) + subject.move_project_folders(project, 'known-parent/the-path', 'known-parent/the-path0') end it 'moves pages' do expect(subject).to receive(:move_pages) .with('known-parent/the-path', 'known-parent/the-path0') - subject.rename_project(project) + subject.move_project_folders(project, 'known-parent/the-path', 'known-parent/the-path0') end end @@ -99,4 +114,47 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameProjects, :tr expect(File.directory?(expected_path)).to be(true) end end + + describe '#revert_renames', redis: true do + it 'renames the routes back to the previous values' do + subject.rename_project(project) + + expect(subject).to receive(:perform_rename) + .with( + kind_of(Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::Project), + 'known-parent/the-path0', + 'known-parent/the-path' + ).and_call_original + + subject.revert_renames + + expect(project.reload.path).to eq('the-path') + expect(project.route.path).to eq('known-parent/the-path') + end + + it 'moves the repositories back to their original place' do + project.create_repository + subject.rename_project(project) + + expected_path = File.join(TestEnv.repos_path, 'known-parent', 'the-path.git') + + expect(subject).to receive(:move_project_folders) + .with( + kind_of(Gitlab::Database::RenameReservedPathsMigration::V1::MigrationClasses::Project), + 'known-parent/the-path0', + 'known-parent/the-path' + ).and_call_original + + subject.revert_renames + + expect(File.directory?(expected_path)).to be_truthy + end + + it "doesn't break when the project was renamed" do + subject.rename_project(project) + project.update_attributes!(path: 'renamed-afterwards') + + expect { subject.revert_renames }.not_to raise_error + end + end end diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb index 1d5e58855c1..7695b95dc57 100644 --- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1_spec.rb @@ -51,4 +51,26 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1, :truncate do subject.rename_root_paths('the-path') end end + + describe '#revert_renames' do + it 'renames namespaces' do + rename_namespaces = double + expect(described_class::RenameNamespaces) + .to receive(:new).with([], subject) + .and_return(rename_namespaces) + expect(rename_namespaces).to receive(:revert_renames) + + subject.revert_renames + end + + it 'renames projects' do + rename_projects = double + expect(described_class::RenameProjects) + .to receive(:new).with([], subject) + .and_return(rename_projects) + expect(rename_projects).to receive(:revert_renames) + + subject.revert_renames + end + end end diff --git a/spec/lib/gitlab/database/sha_attribute_spec.rb b/spec/lib/gitlab/database/sha_attribute_spec.rb new file mode 100644 index 00000000000..62c1d37ea1c --- /dev/null +++ b/spec/lib/gitlab/database/sha_attribute_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe Gitlab::Database::ShaAttribute do + let(:sha) do + '9a573a369a5bfbb9a4a36e98852c21af8a44ea8b' + end + + let(:binary_sha) do + [sha].pack('H*') + end + + let(:binary_from_db) do + if Gitlab::Database.postgresql? + "\\x#{sha}" + else + binary_sha + end + end + + let(:attribute) { described_class.new } + + describe '#type_cast_from_database' do + it 'converts the binary SHA to a String' do + expect(attribute.type_cast_from_database(binary_from_db)).to eq(sha) + end + end + + describe '#type_cast_for_database' do + it 'converts a SHA String to binary data' do + expect(attribute.type_cast_for_database(sha).to_s).to eq(binary_sha) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb b/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb index 7e32770f95d..64b233f3e68 100644 --- a/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb +++ b/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb @@ -87,5 +87,9 @@ describe Gitlab::DependencyLinker::RequirementsTxtLinker, lib: true do it 'links URLs' do expect(subject).to include(link('http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl', 'http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl')) end + + it 'does not contain link with a newline as package name' do + expect(subject).not_to include(link("\n", "https://pypi.python.org/pypi/\n")) + end end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 4894b558e03..0cd458bf933 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -26,6 +26,10 @@ describe Gitlab::Git::Repository, seed_helper: true do end end + it 'returns UTF-8' do + expect(repository.root_ref.encoding).to eq(Encoding.find('UTF-8')) + end + context 'with gitaly enabled' do before do stub_gitaly @@ -123,6 +127,11 @@ describe Gitlab::Git::Repository, seed_helper: true do it 'has SeedRepo::Repo::BRANCHES.size elements' do expect(subject.size).to eq(SeedRepo::Repo::BRANCHES.size) end + + it 'returns UTF-8' do + expect(subject.first.encoding).to eq(Encoding.find('UTF-8')) + end + it { is_expected.to include("master") } it { is_expected.not_to include("branch-from-space") } @@ -158,10 +167,15 @@ describe Gitlab::Git::Repository, seed_helper: true do subject { repository.tag_names } it { is_expected.to be_kind_of Array } + it 'has SeedRepo::Repo::TAGS.size elements' do expect(subject.size).to eq(SeedRepo::Repo::TAGS.size) end + it 'returns UTF-8' do + expect(subject.first.encoding).to eq(Encoding.find('UTF-8')) + end + describe '#last' do subject { super().last } it { is_expected.to eq("v1.2.1") } @@ -348,7 +362,7 @@ describe Gitlab::Git::Repository, seed_helper: true do let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) } context 'where repo has submodules' do - let(:submodules) { repository.submodules('master') } + let(:submodules) { repository.send(:submodules, 'master') } let(:submodule) { submodules.first } it { expect(submodules).to be_kind_of Hash } @@ -383,12 +397,12 @@ describe Gitlab::Git::Repository, seed_helper: true do end it 'should not have an entry for an uncommited submodule dir' do - submodules = repository.submodules('fix-existing-submodule-dir') + submodules = repository.send(:submodules, 'fix-existing-submodule-dir') expect(submodules).not_to have_key('submodule-existing-dir') end it 'should handle tags correctly' do - submodules = repository.submodules('v1.2.1') + submodules = repository.send(:submodules, 'v1.2.1') expect(submodules.first).to eq([ "six", { @@ -414,7 +428,7 @@ describe Gitlab::Git::Repository, seed_helper: true do end context 'where repo doesn\'t have submodules' do - let(:submodules) { repository.submodules('6d39438') } + let(:submodules) { repository.send(:submodules, '6d39438') } it 'should return an empty hash' do expect(submodules).to be_empty end @@ -1276,6 +1290,16 @@ describe Gitlab::Git::Repository, seed_helper: true do Gitlab::GitalyClient.clear_stubs! end + it 'returns a Branch with UTF-8 fields' do + branches = @repo.local_branches.to_a + expect(branches.size).to be > 0 + utf_8 = Encoding.find('utf-8') + branches.each do |branch| + expect(branch.name.encoding).to eq(utf_8) + expect(branch.target.encoding).to eq(utf_8) unless branch.target.nil? + end + end + it 'gets the branches from GitalyClient' do expect_any_instance_of(Gitlab::GitalyClient::Ref).to receive(:local_branches) .and_return([]) diff --git a/spec/lib/gitlab/gitaly_client/ref_spec.rb b/spec/lib/gitlab/gitaly_client/ref_spec.rb index 42dba2ff874..8ad39a02b93 100644 --- a/spec/lib/gitlab/gitaly_client/ref_spec.rb +++ b/spec/lib/gitlab/gitaly_client/ref_spec.rb @@ -69,6 +69,15 @@ describe Gitlab::GitalyClient::Ref do client.local_branches(sort_by: 'updated_desc') end + it 'translates known mismatches on sort param values' do + expect_any_instance_of(Gitaly::Ref::Stub) + .to receive(:find_local_branches) + .with(gitaly_request_with_params(sort_by: :NAME), kind_of(Hash)) + .and_return([]) + + client.local_branches(sort_by: 'name_asc') + end + it 'raises an argument error if an invalid sort_by parameter is passed' do expect { client.local_branches(sort_by: 'invalid_sort') }.to raise_error(ArgumentError) end diff --git a/spec/lib/gitlab/ldap/access_spec.rb b/spec/lib/gitlab/ldap/access_spec.rb index 9dd997aa7dc..756fcb0fcaf 100644 --- a/spec/lib/gitlab/ldap/access_spec.rb +++ b/spec/lib/gitlab/ldap/access_spec.rb @@ -4,6 +4,16 @@ describe Gitlab::LDAP::Access, lib: true do let(:access) { Gitlab::LDAP::Access.new user } let(:user) { create(:omniauth_user) } + describe '.allowed?' do + it 'updates the users `last_credential_check_at' do + expect(access).to receive(:allowed?) { true } + expect(described_class).to receive(:open).and_yield(access) + + expect { described_class.allowed?(user) } + .to change { user.last_credential_check_at } + end + end + describe '#allowed?' do subject { access.allowed? } diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index 3c7c7562b46..c6718827028 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -30,7 +30,8 @@ describe Gitlab::UsageData do expect(count_data.keys).to match_array(%i( boards ci_builds - ci_pipelines + ci_internal_pipelines + ci_external_pipelines ci_runners ci_triggers ci_pipeline_schedules diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 090f9e70c50..dc7a0d80752 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -2,8 +2,8 @@ require 'spec_helper' describe Ability, lib: true do context 'using a nil subject' do - it 'is always empty' do - expect(Ability.allowed(nil, nil).to_set).to be_empty + it 'has no permissions' do + expect(Ability.policy_for(nil, nil)).to be_banned end end @@ -255,12 +255,15 @@ describe Ability, lib: true do describe '.project_disabled_features_rules' do let(:project) { create(:empty_project, :wiki_disabled) } - subject { described_class.allowed(project.owner, project) } + subject { described_class.policy_for(project.owner, project) } context 'wiki named abilities' do it 'disables wiki abilities if the project has no wiki' do expect(project).to receive(:has_external_wiki?).and_return(false) - expect(subject).not_to include(:read_wiki, :create_wiki, :update_wiki, :admin_wiki) + expect(subject).not_to be_allowed(:read_wiki) + expect(subject).not_to be_allowed(:create_wiki) + expect(subject).not_to be_allowed(:update_wiki) + expect(subject).not_to be_allowed(:admin_wiki) end end end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index fef40874d95..5ed02031708 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -672,6 +672,12 @@ describe Ci::Pipeline, models: true do end end + describe '.internal_sources' do + subject { described_class.internal_sources } + + it { is_expected.to be_an(Array) } + end + describe '#status' do let(:build) do create(:ci_build, :created, pipeline: pipeline, name: 'test') diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index 83494af24ba..329682a0771 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -3,14 +3,8 @@ require 'spec_helper' describe Ci::Variable, models: true do subject { build(:ci_variable) } - let(:secret_value) { 'secret' } - - it { is_expected.to validate_presence_of(:key) } + it { is_expected.to include_module(HasVariable) } it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id) } - it { is_expected.to validate_length_of(:key).is_at_most(255) } - it { is_expected.to allow_value('foo').for(:key) } - it { is_expected.not_to allow_value('foo bar').for(:key) } - it { is_expected.not_to allow_value('foo/bar').for(:key) } describe '.unprotected' do subject { described_class.unprotected } @@ -33,36 +27,4 @@ describe Ci::Variable, models: true do end end end - - describe '#value' do - before do - subject.value = secret_value - end - - it 'stores the encrypted value' do - expect(subject.encrypted_value).not_to be_nil - end - - it 'stores an iv for value' do - expect(subject.encrypted_value_iv).not_to be_nil - end - - it 'stores a salt for value' do - expect(subject.encrypted_value_salt).not_to be_nil - end - - it 'fails to decrypt if iv is incorrect' do - subject.encrypted_value_iv = SecureRandom.hex - subject.instance_variable_set(:@value, nil) - expect { subject.value } - .to raise_error(OpenSSL::Cipher::CipherError, 'bad decrypt') - end - end - - describe '#to_runner_variable' do - it 'returns a hash for the runner' do - expect(subject.to_runner_variable) - .to eq(key: subject.key, value: subject.value, public: false) - end - end end diff --git a/spec/models/concerns/feature_gate_spec.rb b/spec/models/concerns/feature_gate_spec.rb new file mode 100644 index 00000000000..3f601243245 --- /dev/null +++ b/spec/models/concerns/feature_gate_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe FeatureGate do + describe 'User' do + describe '#flipper_id' do + context 'when user is not persisted' do + let(:user) { build(:user) } + + it { expect(user.flipper_id).to be_nil } + end + + context 'when user is persisted' do + let(:user) { create(:user) } + + it { expect(user.flipper_id).to eq "User:#{user.id}" } + end + end + end +end diff --git a/spec/models/concerns/has_variable_spec.rb b/spec/models/concerns/has_variable_spec.rb new file mode 100644 index 00000000000..f4b24e6d1d9 --- /dev/null +++ b/spec/models/concerns/has_variable_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe HasVariable do + subject { build(:ci_variable) } + + it { is_expected.to validate_presence_of(:key) } + it { is_expected.to validate_length_of(:key).is_at_most(255) } + it { is_expected.to allow_value('foo').for(:key) } + it { is_expected.not_to allow_value('foo bar').for(:key) } + it { is_expected.not_to allow_value('foo/bar').for(:key) } + + describe '#value' do + before do + subject.value = 'secret' + end + + it 'stores the encrypted value' do + expect(subject.encrypted_value).not_to be_nil + end + + it 'stores an iv for value' do + expect(subject.encrypted_value_iv).not_to be_nil + end + + it 'stores a salt for value' do + expect(subject.encrypted_value_salt).not_to be_nil + end + + it 'fails to decrypt if iv is incorrect' do + subject.encrypted_value_iv = SecureRandom.hex + subject.instance_variable_set(:@value, nil) + expect { subject.value } + .to raise_error(OpenSSL::Cipher::CipherError, 'bad decrypt') + end + end + + describe '#to_runner_variable' do + it 'returns a hash for the runner' do + expect(subject.to_runner_variable) + .to eq(key: subject.key, value: subject.value, public: false) + end + end +end diff --git a/spec/models/concerns/sha_attribute_spec.rb b/spec/models/concerns/sha_attribute_spec.rb new file mode 100644 index 00000000000..9e37c2b20c4 --- /dev/null +++ b/spec/models/concerns/sha_attribute_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe ShaAttribute do + let(:model) { Class.new { include ShaAttribute } } + + before do + columns = [ + double(:column, name: 'name', type: :text), + double(:column, name: 'sha1', type: :binary) + ] + + allow(model).to receive(:columns).and_return(columns) + end + + describe '#sha_attribute' do + it 'defines a SHA attribute for a binary column' do + expect(model).to receive(:attribute) + .with(:sha1, an_instance_of(Gitlab::Database::ShaAttribute)) + + model.sha_attribute(:sha1) + end + + it 'raises ArgumentError when the column type is not :binary' do + expect { model.sha_attribute(:name) }.to raise_error(ArgumentError) + end + end +end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index e7c3acf19eb..62c4ea01ce1 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -323,6 +323,36 @@ describe Namespace, models: true do end end + describe '#users_with_descendants', :nested_groups do + let(:user_a) { create(:user) } + let(:user_b) { create(:user) } + + let(:group) { create(:group) } + let(:nested_group) { create(:group, parent: group) } + let(:deep_nested_group) { create(:group, parent: nested_group) } + + it 'returns member users on every nest level without duplication' do + group.add_developer(user_a) + nested_group.add_developer(user_b) + deep_nested_group.add_developer(user_a) + + expect(group.users_with_descendants).to contain_exactly(user_a, user_b) + expect(nested_group.users_with_descendants).to contain_exactly(user_a, user_b) + expect(deep_nested_group.users_with_descendants).to contain_exactly(user_a) + end + end + + describe '#soft_delete_without_removing_associations' do + let(:project1) { create(:project_empty_repo, namespace: namespace) } + + it 'updates the deleted_at timestamp but preserves projects' do + namespace.soft_delete_without_removing_associations + + expect(Project.all).to include(project1) + expect(namespace.deleted_at).not_to be_nil + end + end + describe '#user_ids_for_project_authorizations' do it 'returns the user IDs for which to refresh authorizations' do expect(namespace.user_ids_for_project_authorizations) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index c434b3796ff..fb39357659c 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -286,15 +286,6 @@ describe Project, models: true do end end - describe 'default_scope' do - it 'excludes projects pending deletion from the results' do - project = create(:empty_project) - create(:empty_project, pending_delete: true) - - expect(Project.all).to eq [project] - end - end - describe 'project token' do it 'sets an random token if none provided' do project = FactoryGirl.create :empty_project, runners_token: '' @@ -1181,6 +1172,16 @@ describe Project, models: true do expect(relation.search(project.namespace.name)).to eq([project]) end + + describe 'with pending_delete project' do + let(:pending_delete_project) { create(:empty_project, pending_delete: true) } + + it 'shows pending deletion project' do + search_result = described_class.search(pending_delete_project.name) + + expect(search_result).to eq([pending_delete_project]) + end + end end describe '#rename_repo' do @@ -1329,6 +1330,37 @@ describe Project, models: true do end end + describe '#ensure_repository' do + let(:project) { create(:project, :repository) } + let(:shell) { Gitlab::Shell.new } + + before do + allow(project).to receive(:gitlab_shell).and_return(shell) + end + + it 'creates the repository if it not exist' do + allow(project).to receive(:repository_exists?) + .and_return(false) + + allow(shell).to receive(:add_repository) + .with(project.repository_storage_path, project.path_with_namespace) + .and_return(true) + + expect(project).to receive(:create_repository) + + project.ensure_repository + end + + it 'does not create the repository if it exists' do + allow(project).to receive(:repository_exists?) + .and_return(true) + + expect(project).not_to receive(:create_repository) + + project.ensure_repository + end + end + describe '#user_can_push_to_empty_repo?' do let(:project) { create(:empty_project) } let(:user) { create(:user) } @@ -1480,6 +1512,40 @@ describe Project, models: true do end end + describe 'project import state transitions' do + context 'state transition: [:started] => [:finished]' do + let(:housekeeping_service) { spy } + + before do + allow(Projects::HousekeepingService).to receive(:new) { housekeeping_service } + end + + it 'performs housekeeping when an import of a fresh project is completed' do + project = create(:project_empty_repo, :import_started, import_type: :github) + + project.import_finish + + expect(housekeeping_service).to have_received(:execute) + end + + it 'does not perform housekeeping when project repository does not exist' do + project = create(:empty_project, :import_started, import_type: :github) + + project.import_finish + + expect(housekeeping_service).not_to have_received(:execute) + end + + it 'does not perform housekeeping when project does not have a valid import type' do + project = create(:empty_project, :import_started, import_type: nil) + + project.import_finish + + expect(housekeeping_service).not_to have_received(:execute) + end + end + end + describe '#latest_successful_builds_for' do def create_pipeline(status = 'success') create(:ci_pipeline, project: project, diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index bf74ac5ea25..1f314791479 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -278,6 +278,24 @@ describe ProjectWiki, models: true do end end + describe '#ensure_repository' do + it 'creates the repository if it not exist' do + allow(subject).to receive(:repository_exists?).and_return(false) + + expect(subject).to receive(:create_repo!) + + subject.ensure_repository + end + + it 'does not create the repository if it exists' do + allow(subject).to receive(:repository_exists?).and_return(true) + + expect(subject).not_to receive(:create_repo!) + + subject.ensure_repository + end + end + describe '#hook_attrs' do it 'returns a hash with values' do expect(subject.hook_attrs).to be_a Hash diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 8e895ec6634..448555d2190 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -754,42 +754,49 @@ describe User, models: true do end describe '.search' do - let(:user) { create(:user) } + let!(:user) { create(:user, name: 'user', username: 'usern', email: 'email@gmail.com') } + let!(:user2) { create(:user, name: 'user name', username: 'username', email: 'someemail@gmail.com') } - it 'returns users with a matching name' do - expect(described_class.search(user.name)).to eq([user]) - end + describe 'name matching' do + it 'returns users with a matching name with exact match first' do + expect(described_class.search(user.name)).to eq([user, user2]) + end - it 'returns users with a partially matching name' do - expect(described_class.search(user.name[0..2])).to eq([user]) - end + it 'returns users with a partially matching name' do + expect(described_class.search(user.name[0..2])).to eq([user2, user]) + end - it 'returns users with a matching name regardless of the casing' do - expect(described_class.search(user.name.upcase)).to eq([user]) + it 'returns users with a matching name regardless of the casing' do + expect(described_class.search(user2.name.upcase)).to eq([user2]) + end end - it 'returns users with a matching Email' do - expect(described_class.search(user.email)).to eq([user]) - end + describe 'email matching' do + it 'returns users with a matching Email' do + expect(described_class.search(user.email)).to eq([user, user2]) + end - it 'returns users with a partially matching Email' do - expect(described_class.search(user.email[0..2])).to eq([user]) - end + it 'returns users with a partially matching Email' do + expect(described_class.search(user.email[0..2])).to eq([user2, user]) + end - it 'returns users with a matching Email regardless of the casing' do - expect(described_class.search(user.email.upcase)).to eq([user]) + it 'returns users with a matching Email regardless of the casing' do + expect(described_class.search(user2.email.upcase)).to eq([user2]) + end end - it 'returns users with a matching username' do - expect(described_class.search(user.username)).to eq([user]) - end + describe 'username matching' do + it 'returns users with a matching username' do + expect(described_class.search(user.username)).to eq([user, user2]) + end - it 'returns users with a partially matching username' do - expect(described_class.search(user.username[0..2])).to eq([user]) - end + it 'returns users with a partially matching username' do + expect(described_class.search(user.username[0..2])).to eq([user2, user]) + end - it 'returns users with a matching username regardless of the casing' do - expect(described_class.search(user.username.upcase)).to eq([user]) + it 'returns users with a matching username regardless of the casing' do + expect(described_class.search(user2.username.upcase)).to eq([user2]) + end end end diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb index 02acdcb36df..e1963091a72 100644 --- a/spec/policies/base_policy_spec.rb +++ b/spec/policies/base_policy_spec.rb @@ -3,17 +3,17 @@ require 'spec_helper' describe BasePolicy, models: true do describe '.class_for' do it 'detects policy class based on the subject ancestors' do - expect(described_class.class_for(GenericCommitStatus.new)).to eq(CommitStatusPolicy) + expect(DeclarativePolicy.class_for(GenericCommitStatus.new)).to eq(CommitStatusPolicy) end it 'detects policy class for a presented subject' do presentee = Ci::BuildPresenter.new(Ci::Build.new) - expect(described_class.class_for(presentee)).to eq(Ci::BuildPolicy) + expect(DeclarativePolicy.class_for(presentee)).to eq(Ci::BuildPolicy) end it 'uses GlobalPolicy when :global is given' do - expect(described_class.class_for(:global)).to eq(GlobalPolicy) + expect(DeclarativePolicy.class_for(:global)).to eq(GlobalPolicy) end end end diff --git a/spec/policies/ci/build_policy_spec.rb b/spec/policies/ci/build_policy_spec.rb index 48a139d4b83..ace95ac7067 100644 --- a/spec/policies/ci/build_policy_spec.rb +++ b/spec/policies/ci/build_policy_spec.rb @@ -5,8 +5,8 @@ describe Ci::BuildPolicy, :models do let(:build) { create(:ci_build, pipeline: pipeline) } let(:pipeline) { create(:ci_empty_pipeline, project: project) } - let(:policies) do - described_class.abilities(user, build).to_set + let(:policy) do + described_class.new(user, build) end shared_context 'public pipelines disabled' do @@ -21,7 +21,7 @@ describe Ci::BuildPolicy, :models do context 'when public builds are enabled' do it 'does not include ability to read build' do - expect(policies).not_to include :read_build + expect(policy).not_to be_allowed :read_build end end @@ -29,7 +29,7 @@ describe Ci::BuildPolicy, :models do include_context 'public pipelines disabled' it 'does not include ability to read build' do - expect(policies).not_to include :read_build + expect(policy).not_to be_allowed :read_build end end end @@ -39,7 +39,7 @@ describe Ci::BuildPolicy, :models do context 'when public builds are enabled' do it 'includes ability to read build' do - expect(policies).to include :read_build + expect(policy).to be_allowed :read_build end end @@ -47,7 +47,7 @@ describe Ci::BuildPolicy, :models do include_context 'public pipelines disabled' it 'does not include ability to read build' do - expect(policies).not_to include :read_build + expect(policy).not_to be_allowed :read_build end end end @@ -62,7 +62,7 @@ describe Ci::BuildPolicy, :models do context 'when public builds are enabled' do it 'includes ability to read build' do - expect(policies).to include :read_build + expect(policy).to be_allowed :read_build end end @@ -70,7 +70,7 @@ describe Ci::BuildPolicy, :models do include_context 'public pipelines disabled' it 'does not include ability to read build' do - expect(policies).not_to include :read_build + expect(policy).not_to be_allowed :read_build end end end @@ -82,7 +82,7 @@ describe Ci::BuildPolicy, :models do context 'when public builds are enabled' do it 'includes ability to read build' do - expect(policies).to include :read_build + expect(policy).to be_allowed :read_build end end @@ -90,7 +90,7 @@ describe Ci::BuildPolicy, :models do include_context 'public pipelines disabled' it 'does not include ability to read build' do - expect(policies).to include :read_build + expect(policy).to be_allowed :read_build end end end @@ -115,7 +115,7 @@ describe Ci::BuildPolicy, :models do end it 'does not include ability to update build' do - expect(policies).not_to include :update_build + expect(policy).to be_disallowed :update_build end end @@ -125,7 +125,7 @@ describe Ci::BuildPolicy, :models do end it 'includes ability to update build' do - expect(policies).to include :update_build + expect(policy).to be_allowed :update_build end end end @@ -135,7 +135,7 @@ describe Ci::BuildPolicy, :models do let(:build) { create(:ci_build, :manual, pipeline: pipeline) } it 'includes ability to update build' do - expect(policies).to include :update_build + expect(policy).to be_allowed :update_build end end @@ -143,7 +143,7 @@ describe Ci::BuildPolicy, :models do let(:build) { create(:ci_build, pipeline: pipeline) } it 'includes ability to update build' do - expect(policies).to include :update_build + expect(policy).to be_allowed :update_build end end end diff --git a/spec/policies/ci/trigger_policy_spec.rb b/spec/policies/ci/trigger_policy_spec.rb index 63ad5eb7322..ed4010e723b 100644 --- a/spec/policies/ci/trigger_policy_spec.rb +++ b/spec/policies/ci/trigger_policy_spec.rb @@ -6,36 +6,36 @@ describe Ci::TriggerPolicy, :models do let(:trigger) { create(:ci_trigger, project: project, owner: owner) } let(:policies) do - described_class.abilities(user, trigger).to_set + described_class.new(user, trigger) end shared_examples 'allows to admin and manage trigger' do it 'does include ability to admin trigger' do - expect(policies).to include :admin_trigger + expect(policies).to be_allowed :admin_trigger end it 'does include ability to manage trigger' do - expect(policies).to include :manage_trigger + expect(policies).to be_allowed :manage_trigger end end shared_examples 'allows to manage trigger' do it 'does not include ability to admin trigger' do - expect(policies).not_to include :admin_trigger + expect(policies).not_to be_allowed :admin_trigger end it 'does include ability to manage trigger' do - expect(policies).to include :manage_trigger + expect(policies).to be_allowed :manage_trigger end end shared_examples 'disallows to admin and manage trigger' do it 'does not include ability to admin trigger' do - expect(policies).not_to include :admin_trigger + expect(policies).not_to be_allowed :admin_trigger end it 'does not include ability to manage trigger' do - expect(policies).not_to include :manage_trigger + expect(policies).not_to be_allowed :manage_trigger end end diff --git a/spec/policies/deploy_key_policy_spec.rb b/spec/policies/deploy_key_policy_spec.rb index 28e10f0bfe2..f15f4a11f02 100644 --- a/spec/policies/deploy_key_policy_spec.rb +++ b/spec/policies/deploy_key_policy_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe DeployKeyPolicy, models: true do - subject { described_class.abilities(current_user, deploy_key).to_set } + subject { described_class.new(current_user, deploy_key) } describe 'updating a deploy_key' do context 'when a regular user' do @@ -16,7 +16,7 @@ describe DeployKeyPolicy, models: true do project.deploy_keys << deploy_key end - it { is_expected.to include(:update_deploy_key) } + it { is_expected.to be_allowed(:update_deploy_key) } end context 'tries to update private deploy key attached to other project' do @@ -27,13 +27,13 @@ describe DeployKeyPolicy, models: true do other_project.deploy_keys << deploy_key end - it { is_expected.not_to include(:update_deploy_key) } + it { is_expected.to be_disallowed(:update_deploy_key) } end context 'tries to update public deploy key' do let(:deploy_key) { create(:another_deploy_key, public: true) } - it { is_expected.not_to include(:update_deploy_key) } + it { is_expected.to be_disallowed(:update_deploy_key) } end end @@ -43,13 +43,13 @@ describe DeployKeyPolicy, models: true do context ' tries to update private deploy key' do let(:deploy_key) { create(:deploy_key, public: false) } - it { is_expected.to include(:update_deploy_key) } + it { is_expected.to be_allowed(:update_deploy_key) } end context 'when an admin user tries to update public deploy key' do let(:deploy_key) { create(:another_deploy_key, public: true) } - it { is_expected.to include(:update_deploy_key) } + it { is_expected.to be_allowed(:update_deploy_key) } end end end diff --git a/spec/policies/environment_policy_spec.rb b/spec/policies/environment_policy_spec.rb index 650432520bb..035e20c7452 100644 --- a/spec/policies/environment_policy_spec.rb +++ b/spec/policies/environment_policy_spec.rb @@ -8,8 +8,8 @@ describe EnvironmentPolicy do create(:environment, :with_review_app, project: project) end - let(:policies) do - described_class.abilities(user, environment).to_set + let(:policy) do + described_class.new(user, environment) end describe '#rules' do @@ -17,7 +17,7 @@ describe EnvironmentPolicy do let(:project) { create(:project, :private) } it 'does not include ability to stop environment' do - expect(policies).not_to include :stop_environment + expect(policy).to be_disallowed :stop_environment end end @@ -25,7 +25,7 @@ describe EnvironmentPolicy do let(:project) { create(:project, :public) } it 'does not include ability to stop environment' do - expect(policies).not_to include :stop_environment + expect(policy).to be_disallowed :stop_environment end end @@ -38,7 +38,7 @@ describe EnvironmentPolicy do context 'when team member has ability to stop environment' do it 'does includes ability to stop environment' do - expect(policies).to include :stop_environment + expect(policy).to be_allowed :stop_environment end end @@ -49,7 +49,7 @@ describe EnvironmentPolicy do end it 'does not include ability to stop environment' do - expect(policies).not_to include :stop_environment + expect(policy).to be_disallowed :stop_environment end end end diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index a8331ceb5ff..06db0ea56e3 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -36,16 +36,24 @@ describe GroupPolicy, models: true do group.add_owner(owner) end - subject { described_class.abilities(current_user, group).to_set } + subject { described_class.new(current_user, group) } + + def expect_allowed(*permissions) + permissions.each { |p| is_expected.to be_allowed(p) } + end + + def expect_disallowed(*permissions) + permissions.each { |p| is_expected.not_to be_allowed(p) } + end context 'with no user' do let(:current_user) { nil } it do - is_expected.to include(:read_group) - is_expected.not_to include(*reporter_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(:read_group) + expect_disallowed(*reporter_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -53,10 +61,10 @@ describe GroupPolicy, models: true do let(:current_user) { guest } it do - is_expected.to include(:read_group) - is_expected.not_to include(*reporter_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(:read_group) + expect_disallowed(*reporter_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -64,10 +72,10 @@ describe GroupPolicy, models: true do let(:current_user) { reporter } it do - is_expected.to include(:read_group) - is_expected.to include(*reporter_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(:read_group) + expect_allowed(*reporter_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -75,10 +83,10 @@ describe GroupPolicy, models: true do let(:current_user) { developer } it do - is_expected.to include(:read_group) - is_expected.to include(*reporter_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(:read_group) + expect_allowed(*reporter_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -86,10 +94,10 @@ describe GroupPolicy, models: true do let(:current_user) { master } it do - is_expected.to include(:read_group) - is_expected.to include(*reporter_permissions) - is_expected.to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(:read_group) + expect_allowed(*reporter_permissions) + expect_allowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -97,10 +105,10 @@ describe GroupPolicy, models: true do let(:current_user) { owner } it do - is_expected.to include(:read_group) - is_expected.to include(*reporter_permissions) - is_expected.to include(*master_permissions) - is_expected.to include(*owner_permissions) + expect_allowed(:read_group) + expect_allowed(*reporter_permissions) + expect_allowed(*master_permissions) + expect_allowed(*owner_permissions) end end @@ -108,10 +116,10 @@ describe GroupPolicy, models: true do let(:current_user) { admin } it do - is_expected.to include(:read_group) - is_expected.to include(*reporter_permissions) - is_expected.to include(*master_permissions) - is_expected.to include(*owner_permissions) + expect_allowed(:read_group) + expect_allowed(*reporter_permissions) + expect_allowed(*master_permissions) + expect_allowed(*owner_permissions) end end @@ -130,16 +138,16 @@ describe GroupPolicy, models: true do nested_group.add_owner(owner) end - subject { described_class.abilities(current_user, nested_group).to_set } + subject { described_class.new(current_user, nested_group) } context 'with no user' do let(:current_user) { nil } it do - is_expected.not_to include(:read_group) - is_expected.not_to include(*reporter_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_disallowed(:read_group) + expect_disallowed(*reporter_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -147,10 +155,10 @@ describe GroupPolicy, models: true do let(:current_user) { guest } it do - is_expected.to include(:read_group) - is_expected.not_to include(*reporter_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(:read_group) + expect_disallowed(*reporter_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -158,10 +166,10 @@ describe GroupPolicy, models: true do let(:current_user) { reporter } it do - is_expected.to include(:read_group) - is_expected.to include(*reporter_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(:read_group) + expect_allowed(*reporter_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -169,10 +177,10 @@ describe GroupPolicy, models: true do let(:current_user) { developer } it do - is_expected.to include(:read_group) - is_expected.to include(*reporter_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(:read_group) + expect_allowed(*reporter_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -180,10 +188,10 @@ describe GroupPolicy, models: true do let(:current_user) { master } it do - is_expected.to include(:read_group) - is_expected.to include(*reporter_permissions) - is_expected.to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(:read_group) + expect_allowed(*reporter_permissions) + expect_allowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -191,10 +199,10 @@ describe GroupPolicy, models: true do let(:current_user) { owner } it do - is_expected.to include(:read_group) - is_expected.to include(*reporter_permissions) - is_expected.to include(*master_permissions) - is_expected.to include(*owner_permissions) + expect_allowed(:read_group) + expect_allowed(*reporter_permissions) + expect_allowed(*master_permissions) + expect_allowed(*owner_permissions) end end end diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb index 4a07c864428..c978cbd6185 100644 --- a/spec/policies/issue_policy_spec.rb +++ b/spec/policies/issue_policy_spec.rb @@ -9,7 +9,7 @@ describe IssuePolicy, models: true do let(:reporter_from_group_link) { create(:user) } def permissions(user, issue) - described_class.abilities(user, issue).to_set + described_class.new(user, issue) end context 'a private project' do @@ -30,42 +30,42 @@ describe IssuePolicy, models: true do end it 'does not allow non-members to read issues' do - expect(permissions(non_member, issue)).not_to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(non_member, issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(non_member, issue)).to be_disallowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(non_member, issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue) end it 'allows guests to read issues' do - expect(permissions(guest, issue)).to include(:read_issue) - expect(permissions(guest, issue)).not_to include(:update_issue, :admin_issue) + expect(permissions(guest, issue)).to be_allowed(:read_issue) + expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue) - expect(permissions(guest, issue_no_assignee)).to include(:read_issue) - expect(permissions(guest, issue_no_assignee)).not_to include(:update_issue, :admin_issue) + expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue) + expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue) end it 'allows reporters to read, update, and admin issues' do - expect(permissions(reporter, issue)).to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(reporter, issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter, issue)).to be_allowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue) end it 'allows reporters from group links to read, update, and admin issues' do - expect(permissions(reporter_from_group_link, issue)).to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(reporter_from_group_link, issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue) end it 'allows issue authors to read and update their issues' do - expect(permissions(author, issue)).to include(:read_issue, :update_issue) - expect(permissions(author, issue)).not_to include(:admin_issue) + expect(permissions(author, issue)).to be_allowed(:read_issue, :update_issue) + expect(permissions(author, issue)).to be_disallowed(:admin_issue) - expect(permissions(author, issue_no_assignee)).to include(:read_issue) - expect(permissions(author, issue_no_assignee)).not_to include(:update_issue, :admin_issue) + expect(permissions(author, issue_no_assignee)).to be_allowed(:read_issue) + expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue) end it 'allows issue assignees to read and update their issues' do - expect(permissions(assignee, issue)).to include(:read_issue, :update_issue) - expect(permissions(assignee, issue)).not_to include(:admin_issue) + expect(permissions(assignee, issue)).to be_allowed(:read_issue, :update_issue) + expect(permissions(assignee, issue)).to be_disallowed(:admin_issue) - expect(permissions(assignee, issue_no_assignee)).to include(:read_issue) - expect(permissions(assignee, issue_no_assignee)).not_to include(:update_issue, :admin_issue) + expect(permissions(assignee, issue_no_assignee)).to be_allowed(:read_issue) + expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue) end context 'with confidential issues' do @@ -73,37 +73,37 @@ describe IssuePolicy, models: true do let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow non-members to read confidential issues' do - expect(permissions(non_member, confidential_issue)).not_to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(non_member, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(non_member, confidential_issue)).to be_disallowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(non_member, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue) end it 'does not allow guests to read confidential issues' do - expect(permissions(guest, confidential_issue)).not_to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(guest, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue) end it 'allows reporters to read, update, and admin confidential issues' do - expect(permissions(reporter, confidential_issue)).to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(reporter, confidential_issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue) end it 'allows reporters from group links to read, update, and admin confidential issues' do - expect(permissions(reporter_from_group_link, confidential_issue)).to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue) end it 'allows issue authors to read and update their confidential issues' do - expect(permissions(author, confidential_issue)).to include(:read_issue, :update_issue) - expect(permissions(author, confidential_issue)).not_to include(:admin_issue) + expect(permissions(author, confidential_issue)).to be_allowed(:read_issue, :update_issue) + expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue) - expect(permissions(author, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue) end it 'allows issue assignees to read and update their confidential issues' do - expect(permissions(assignee, confidential_issue)).to include(:read_issue, :update_issue) - expect(permissions(assignee, confidential_issue)).not_to include(:admin_issue) + expect(permissions(assignee, confidential_issue)).to be_allowed(:read_issue, :update_issue) + expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue) - expect(permissions(assignee, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue) end end end @@ -123,37 +123,37 @@ describe IssuePolicy, models: true do end it 'allows guests to read issues' do - expect(permissions(guest, issue)).to include(:read_issue) - expect(permissions(guest, issue)).not_to include(:update_issue, :admin_issue) + expect(permissions(guest, issue)).to be_allowed(:read_issue) + expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue) - expect(permissions(guest, issue_no_assignee)).to include(:read_issue) - expect(permissions(guest, issue_no_assignee)).not_to include(:update_issue, :admin_issue) + expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue) + expect(permissions(guest, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue) end it 'allows reporters to read, update, and admin issues' do - expect(permissions(reporter, issue)).to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(reporter, issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter, issue)).to be_allowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter, issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue) end it 'allows reporters from group links to read, update, and admin issues' do - expect(permissions(reporter_from_group_link, issue)).to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(reporter_from_group_link, issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter_from_group_link, issue)).to be_allowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter_from_group_link, issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue) end it 'allows issue authors to read and update their issues' do - expect(permissions(author, issue)).to include(:read_issue, :update_issue) - expect(permissions(author, issue)).not_to include(:admin_issue) + expect(permissions(author, issue)).to be_allowed(:read_issue, :update_issue) + expect(permissions(author, issue)).to be_disallowed(:admin_issue) - expect(permissions(author, issue_no_assignee)).to include(:read_issue) - expect(permissions(author, issue_no_assignee)).not_to include(:update_issue, :admin_issue) + expect(permissions(author, issue_no_assignee)).to be_allowed(:read_issue) + expect(permissions(author, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue) end it 'allows issue assignees to read and update their issues' do - expect(permissions(assignee, issue)).to include(:read_issue, :update_issue) - expect(permissions(assignee, issue)).not_to include(:admin_issue) + expect(permissions(assignee, issue)).to be_allowed(:read_issue, :update_issue) + expect(permissions(assignee, issue)).to be_disallowed(:admin_issue) - expect(permissions(assignee, issue_no_assignee)).to include(:read_issue) - expect(permissions(assignee, issue_no_assignee)).not_to include(:update_issue, :admin_issue) + expect(permissions(assignee, issue_no_assignee)).to be_allowed(:read_issue) + expect(permissions(assignee, issue_no_assignee)).to be_disallowed(:update_issue, :admin_issue) end context 'with confidential issues' do @@ -161,32 +161,32 @@ describe IssuePolicy, models: true do let(:confidential_issue_no_assignee) { create(:issue, :confidential, project: project) } it 'does not allow guests to read confidential issues' do - expect(permissions(guest, confidential_issue)).not_to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(guest, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(guest, confidential_issue)).to be_disallowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(guest, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue) end it 'allows reporters to read, update, and admin confidential issues' do - expect(permissions(reporter, confidential_issue)).to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(reporter, confidential_issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter, confidential_issue)).to be_allowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter, confidential_issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue) end it 'allows reporter from group links to read, update, and admin confidential issues' do - expect(permissions(reporter_from_group_link, confidential_issue)).to include(:read_issue, :update_issue, :admin_issue) - expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter_from_group_link, confidential_issue)).to be_allowed(:read_issue, :update_issue, :admin_issue) + expect(permissions(reporter_from_group_link, confidential_issue_no_assignee)).to be_allowed(:read_issue, :update_issue, :admin_issue) end it 'allows issue authors to read and update their confidential issues' do - expect(permissions(author, confidential_issue)).to include(:read_issue, :update_issue) - expect(permissions(author, confidential_issue)).not_to include(:admin_issue) + expect(permissions(author, confidential_issue)).to be_allowed(:read_issue, :update_issue) + expect(permissions(author, confidential_issue)).to be_disallowed(:admin_issue) - expect(permissions(author, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(author, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue) end it 'allows issue assignees to read and update their confidential issues' do - expect(permissions(assignee, confidential_issue)).to include(:read_issue, :update_issue) - expect(permissions(assignee, confidential_issue)).not_to include(:admin_issue) + expect(permissions(assignee, confidential_issue)).to be_allowed(:read_issue, :update_issue) + expect(permissions(assignee, confidential_issue)).to be_disallowed(:admin_issue) - expect(permissions(assignee, confidential_issue_no_assignee)).not_to include(:read_issue, :update_issue, :admin_issue) + expect(permissions(assignee, confidential_issue_no_assignee)).to be_disallowed(:read_issue, :update_issue, :admin_issue) end end end diff --git a/spec/policies/personal_snippet_policy_spec.rb b/spec/policies/personal_snippet_policy_spec.rb index 58aa1145c9e..4d6350fc653 100644 --- a/spec/policies/personal_snippet_policy_spec.rb +++ b/spec/policies/personal_snippet_policy_spec.rb @@ -14,7 +14,7 @@ describe PersonalSnippetPolicy, models: true do end def permissions(user) - described_class.abilities(user, snippet).to_set + described_class.new(user, snippet) end context 'public snippet' do @@ -24,9 +24,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(nil) } it do - is_expected.to include(:read_personal_snippet) - is_expected.not_to include(:comment_personal_snippet) - is_expected.not_to include(*author_permissions) + is_expected.to be_allowed(:read_personal_snippet) + is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*author_permissions) end end @@ -34,9 +34,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(regular_user) } it do - is_expected.to include(:read_personal_snippet) - is_expected.to include(:comment_personal_snippet) - is_expected.not_to include(*author_permissions) + is_expected.to be_allowed(:read_personal_snippet) + is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_disallowed(*author_permissions) end end @@ -44,9 +44,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(snippet.author) } it do - is_expected.to include(:read_personal_snippet) - is_expected.to include(:comment_personal_snippet) - is_expected.to include(*author_permissions) + is_expected.to be_allowed(:read_personal_snippet) + is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_allowed(*author_permissions) end end end @@ -58,9 +58,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(nil) } it do - is_expected.not_to include(:read_personal_snippet) - is_expected.not_to include(:comment_personal_snippet) - is_expected.not_to include(*author_permissions) + is_expected.to be_disallowed(:read_personal_snippet) + is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*author_permissions) end end @@ -68,9 +68,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(regular_user) } it do - is_expected.to include(:read_personal_snippet) - is_expected.to include(:comment_personal_snippet) - is_expected.not_to include(*author_permissions) + is_expected.to be_allowed(:read_personal_snippet) + is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_disallowed(*author_permissions) end end @@ -78,9 +78,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(external_user) } it do - is_expected.not_to include(:read_personal_snippet) - is_expected.not_to include(:comment_personal_snippet) - is_expected.not_to include(*author_permissions) + is_expected.to be_disallowed(:read_personal_snippet) + is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*author_permissions) end end @@ -88,9 +88,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(snippet.author) } it do - is_expected.to include(:read_personal_snippet) - is_expected.to include(:comment_personal_snippet) - is_expected.to include(*author_permissions) + is_expected.to be_allowed(:read_personal_snippet) + is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_allowed(*author_permissions) end end end @@ -102,9 +102,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(nil) } it do - is_expected.not_to include(:read_personal_snippet) - is_expected.not_to include(:comment_personal_snippet) - is_expected.not_to include(*author_permissions) + is_expected.to be_disallowed(:read_personal_snippet) + is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*author_permissions) end end @@ -112,9 +112,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(regular_user) } it do - is_expected.not_to include(:read_personal_snippet) - is_expected.not_to include(:comment_personal_snippet) - is_expected.not_to include(*author_permissions) + is_expected.to be_disallowed(:read_personal_snippet) + is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*author_permissions) end end @@ -122,9 +122,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(external_user) } it do - is_expected.not_to include(:read_personal_snippet) - is_expected.not_to include(:comment_personal_snippet) - is_expected.not_to include(*author_permissions) + is_expected.to be_disallowed(:read_personal_snippet) + is_expected.to be_disallowed(:comment_personal_snippet) + is_expected.to be_disallowed(*author_permissions) end end @@ -132,9 +132,9 @@ describe PersonalSnippetPolicy, models: true do subject { permissions(snippet.author) } it do - is_expected.to include(:read_personal_snippet) - is_expected.to include(:comment_personal_snippet) - is_expected.to include(*author_permissions) + is_expected.to be_allowed(:read_personal_snippet) + is_expected.to be_allowed(:comment_personal_snippet) + is_expected.to be_allowed(*author_permissions) end end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index d70e15f006b..ca435dd0218 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -73,37 +73,45 @@ describe ProjectPolicy, models: true do project.team << [reporter, :reporter] end + def expect_allowed(*permissions) + permissions.each { |p| is_expected.to be_allowed(p) } + end + + def expect_disallowed(*permissions) + permissions.each { |p| is_expected.not_to be_allowed(p) } + end + it 'does not include the read_issue permission when the issue author is not a member of the private project' do project = create(:empty_project, :private) issue = create(:issue, project: project) user = issue.author - expect(project.team.member?(issue.author)).to eq(false) + expect(project.team.member?(issue.author)).to be false - expect(BasePolicy.class_for(project).abilities(user, project).can_set) - .not_to include(:read_issue) - - expect(Ability.allowed?(user, :read_issue, project)).to be_falsy + expect(Ability).not_to be_allowed(user, :read_issue, project) end - it 'does not include the wiki permissions when the feature is disabled' do - project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED) - wiki_permissions = [:read_wiki, :create_wiki, :update_wiki, :admin_wiki, :download_wiki_code] + context 'when the feature is disabled' do + subject { described_class.new(owner, project) } - permissions = described_class.abilities(owner, project).to_set + before do + project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED) + end - expect(permissions).not_to include(*wiki_permissions) + it 'does not include the wiki permissions' do + expect_disallowed :read_wiki, :create_wiki, :update_wiki, :admin_wiki, :download_wiki_code + end end context 'abilities for non-public projects' do let(:project) { create(:empty_project, namespace: owner.namespace) } - subject { described_class.abilities(current_user, project).to_set } + subject { described_class.new(current_user, project) } context 'with no user' do let(:current_user) { nil } - it { is_expected.to be_empty } + it { is_expected.to be_banned } end context 'guests' do @@ -114,18 +122,18 @@ describe ProjectPolicy, models: true do end it do - is_expected.to include(*guest_permissions) - is_expected.not_to include(*reporter_public_build_permissions) - is_expected.not_to include(*team_member_reporter_permissions) - is_expected.not_to include(*developer_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(*guest_permissions) + expect_disallowed(*reporter_public_build_permissions) + expect_disallowed(*team_member_reporter_permissions) + expect_disallowed(*developer_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end context 'public builds enabled' do it do - is_expected.to include(*guest_permissions) - is_expected.to include(:read_build, :read_pipeline) + expect_allowed(*guest_permissions) + expect_allowed(:read_build, :read_pipeline) end end @@ -135,8 +143,8 @@ describe ProjectPolicy, models: true do end it do - is_expected.to include(*guest_permissions) - is_expected.not_to include(:read_build, :read_pipeline) + expect_allowed(*guest_permissions) + expect_disallowed(:read_build, :read_pipeline) end end @@ -147,8 +155,8 @@ describe ProjectPolicy, models: true do end it do - is_expected.not_to include(:read_build) - is_expected.to include(:read_pipeline) + expect_disallowed(:read_build) + expect_allowed(:read_pipeline) end end end @@ -157,12 +165,13 @@ describe ProjectPolicy, models: true do let(:current_user) { reporter } it do - is_expected.to include(*guest_permissions) - is_expected.to include(*reporter_permissions) - is_expected.to include(*team_member_reporter_permissions) - is_expected.not_to include(*developer_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(*guest_permissions) + expect_allowed(*reporter_permissions) + expect_allowed(*reporter_permissions) + expect_allowed(*team_member_reporter_permissions) + expect_disallowed(*developer_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -170,12 +179,12 @@ describe ProjectPolicy, models: true do let(:current_user) { dev } it do - is_expected.to include(*guest_permissions) - is_expected.to include(*reporter_permissions) - is_expected.to include(*team_member_reporter_permissions) - is_expected.to include(*developer_permissions) - is_expected.not_to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(*guest_permissions) + expect_allowed(*reporter_permissions) + expect_allowed(*team_member_reporter_permissions) + expect_allowed(*developer_permissions) + expect_disallowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -183,12 +192,12 @@ describe ProjectPolicy, models: true do let(:current_user) { master } it do - is_expected.to include(*guest_permissions) - is_expected.to include(*reporter_permissions) - is_expected.to include(*team_member_reporter_permissions) - is_expected.to include(*developer_permissions) - is_expected.to include(*master_permissions) - is_expected.not_to include(*owner_permissions) + expect_allowed(*guest_permissions) + expect_allowed(*reporter_permissions) + expect_allowed(*team_member_reporter_permissions) + expect_allowed(*developer_permissions) + expect_allowed(*master_permissions) + expect_disallowed(*owner_permissions) end end @@ -196,12 +205,12 @@ describe ProjectPolicy, models: true do let(:current_user) { owner } it do - is_expected.to include(*guest_permissions) - is_expected.to include(*reporter_permissions) - is_expected.to include(*team_member_reporter_permissions) - is_expected.to include(*developer_permissions) - is_expected.to include(*master_permissions) - is_expected.to include(*owner_permissions) + expect_allowed(*guest_permissions) + expect_allowed(*reporter_permissions) + expect_allowed(*team_member_reporter_permissions) + expect_allowed(*developer_permissions) + expect_allowed(*master_permissions) + expect_allowed(*owner_permissions) end end @@ -209,12 +218,12 @@ describe ProjectPolicy, models: true do let(:current_user) { admin } it do - is_expected.to include(*guest_permissions) - is_expected.to include(*reporter_permissions) - is_expected.not_to include(*team_member_reporter_permissions) - is_expected.to include(*developer_permissions) - is_expected.to include(*master_permissions) - is_expected.to include(*owner_permissions) + expect_allowed(*guest_permissions) + expect_allowed(*reporter_permissions) + expect_disallowed(*team_member_reporter_permissions) + expect_allowed(*developer_permissions) + expect_allowed(*master_permissions) + expect_allowed(*owner_permissions) end end end diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb index d2b2528c57a..2799f03fb9b 100644 --- a/spec/policies/project_snippet_policy_spec.rb +++ b/spec/policies/project_snippet_policy_spec.rb @@ -15,7 +15,15 @@ describe ProjectSnippetPolicy, models: true do def abilities(user, snippet_visibility) snippet = create(:project_snippet, snippet_visibility, project: project) - described_class.abilities(user, snippet).to_set + described_class.new(user, snippet) + end + + def expect_allowed(*permissions) + permissions.each { |p| is_expected.to be_allowed(p) } + end + + def expect_disallowed(*permissions) + permissions.each { |p| is_expected.not_to be_allowed(p) } end context 'public snippet' do @@ -23,8 +31,8 @@ describe ProjectSnippetPolicy, models: true do subject { abilities(nil, :public) } it do - is_expected.to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_allowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end @@ -32,8 +40,8 @@ describe ProjectSnippetPolicy, models: true do subject { abilities(regular_user, :public) } it do - is_expected.to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_allowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end @@ -41,8 +49,8 @@ describe ProjectSnippetPolicy, models: true do subject { abilities(external_user, :public) } it do - is_expected.to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_allowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end end @@ -52,8 +60,8 @@ describe ProjectSnippetPolicy, models: true do subject { abilities(nil, :internal) } it do - is_expected.not_to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_disallowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end @@ -61,8 +69,8 @@ describe ProjectSnippetPolicy, models: true do subject { abilities(regular_user, :internal) } it do - is_expected.to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_allowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end @@ -70,8 +78,8 @@ describe ProjectSnippetPolicy, models: true do subject { abilities(external_user, :internal) } it do - is_expected.not_to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_disallowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end @@ -83,8 +91,8 @@ describe ProjectSnippetPolicy, models: true do end it do - is_expected.to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_allowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end end @@ -94,8 +102,8 @@ describe ProjectSnippetPolicy, models: true do subject { abilities(nil, :private) } it do - is_expected.not_to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_disallowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end @@ -103,19 +111,19 @@ describe ProjectSnippetPolicy, models: true do subject { abilities(regular_user, :private) } it do - is_expected.not_to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_disallowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end context 'snippet author' do let(:snippet) { create(:project_snippet, :private, author: regular_user, project: project) } - subject { described_class.abilities(regular_user, snippet).to_set } + subject { described_class.new(regular_user, snippet) } it do - is_expected.to include(:read_project_snippet) - is_expected.to include(*author_permissions) + expect_allowed(:read_project_snippet) + expect_allowed(*author_permissions) end end @@ -127,8 +135,8 @@ describe ProjectSnippetPolicy, models: true do end it do - is_expected.to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_allowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end @@ -140,8 +148,8 @@ describe ProjectSnippetPolicy, models: true do end it do - is_expected.to include(:read_project_snippet) - is_expected.not_to include(*author_permissions) + expect_allowed(:read_project_snippet) + expect_disallowed(*author_permissions) end end @@ -149,8 +157,8 @@ describe ProjectSnippetPolicy, models: true do subject { abilities(create(:admin), :private) } it do - is_expected.to include(:read_project_snippet) - is_expected.to include(*author_permissions) + expect_allowed(:read_project_snippet) + expect_allowed(*author_permissions) end end end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index d5761390d39..0251d5dcf1c 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -4,34 +4,34 @@ describe UserPolicy, models: true do let(:current_user) { create(:user) } let(:user) { create(:user) } - subject { described_class.abilities(current_user, user).to_set } + subject { UserPolicy.new(current_user, user) } describe "reading a user's information" do - it { is_expected.to include(:read_user) } + it { is_expected.to be_allowed(:read_user) } end describe "destroying a user" do context "when a regular user tries to destroy another regular user" do - it { is_expected.not_to include(:destroy_user) } + it { is_expected.not_to be_allowed(:destroy_user) } end context "when a regular user tries to destroy themselves" do let(:current_user) { user } - it { is_expected.to include(:destroy_user) } + it { is_expected.to be_allowed(:destroy_user) } end context "when an admin user tries to destroy a regular user" do let(:current_user) { create(:user, :admin) } - it { is_expected.to include(:destroy_user) } + it { is_expected.to be_allowed(:destroy_user) } end context "when an admin user tries to destroy a ghost user" do let(:current_user) { create(:user, :admin) } let(:user) { create(:user, :ghost) } - it { is_expected.not_to include(:destroy_user) } + it { is_expected.not_to be_allowed(:destroy_user) } end end end diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb index f169e6661d1..1d8aaeea8f2 100644 --- a/spec/requests/api/features_spec.rb +++ b/spec/requests/api/features_spec.rb @@ -4,6 +4,13 @@ describe API::Features do let(:user) { create(:user) } let(:admin) { create(:admin) } + before do + Flipper.unregister_groups + Flipper.register(:perf_team) do |actor| + actor.respond_to?(:admin) && actor.admin? + end + end + describe 'GET /features' do let(:expected_features) do [ @@ -16,6 +23,14 @@ describe API::Features do 'name' => 'feature_2', 'state' => 'off', 'gates' => [{ 'key' => 'boolean', 'value' => false }] + }, + { + 'name' => 'feature_3', + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'groups', 'value' => ['perf_team'] } + ] } ] end @@ -23,6 +38,7 @@ describe API::Features do before do Feature.get('feature_1').enable Feature.get('feature_2').disable + Feature.get('feature_3').enable Feature.group(:perf_team) end it 'returns a 401 for anonymous users' do @@ -47,30 +63,70 @@ describe API::Features do describe 'POST /feature' do let(:feature_name) { 'my_feature' } - it 'returns a 401 for anonymous users' do - post api("/features/#{feature_name}") - expect(response).to have_http_status(401) - end + context 'when the feature does not exist' do + it 'returns a 401 for anonymous users' do + post api("/features/#{feature_name}") - it 'returns a 403 for users' do - post api("/features/#{feature_name}", user) + expect(response).to have_http_status(401) + end - expect(response).to have_http_status(403) - end + it 'returns a 403 for users' do + post api("/features/#{feature_name}", user) - it 'creates an enabled feature if passed true' do - post api("/features/#{feature_name}", admin), value: 'true' + expect(response).to have_http_status(403) + end - expect(response).to have_http_status(201) - expect(Feature.get(feature_name)).to be_enabled - end + context 'when passed value=true' do + it 'creates an enabled feature' do + post api("/features/#{feature_name}", admin), value: 'true' - it 'creates a feature with the given percentage if passed an integer' do - post api("/features/#{feature_name}", admin), value: '50' + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'on', + 'gates' => [{ 'key' => 'boolean', 'value' => true }]) + end + + it 'creates an enabled feature for the given Flipper group when passed feature_group=perf_team' do + post api("/features/#{feature_name}", admin), value: 'true', feature_group: 'perf_team' + + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'groups', 'value' => ['perf_team'] } + ]) + end + + it 'creates an enabled feature for the given user when passed user=username' do + post api("/features/#{feature_name}", admin), value: 'true', user: user.username - expect(response).to have_http_status(201) - expect(Feature.get(feature_name).percentage_of_time_value).to be(50) + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'actors', 'value' => ["User:#{user.id}"] } + ]) + end + end + + it 'creates a feature with the given percentage if passed an integer' do + post api("/features/#{feature_name}", admin), value: '50' + + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'percentage_of_time', 'value' => 50 } + ]) + end end context 'when the feature exists' do @@ -80,11 +136,83 @@ describe API::Features do feature.disable # This also persists the feature on the DB end - it 'enables the feature if passed true' do - post api("/features/#{feature_name}", admin), value: 'true' + context 'when passed value=true' do + it 'enables the feature' do + post api("/features/#{feature_name}", admin), value: 'true' - expect(response).to have_http_status(201) - expect(feature).to be_enabled + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'on', + 'gates' => [{ 'key' => 'boolean', 'value' => true }]) + end + + it 'enables the feature for the given Flipper group when passed feature_group=perf_team' do + post api("/features/#{feature_name}", admin), value: 'true', feature_group: 'perf_team' + + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'groups', 'value' => ['perf_team'] } + ]) + end + + it 'enables the feature for the given user when passed user=username' do + post api("/features/#{feature_name}", admin), value: 'true', user: user.username + + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'actors', 'value' => ["User:#{user.id}"] } + ]) + end + end + + context 'when feature is enabled and value=false is passed' do + it 'disables the feature' do + feature.enable + expect(feature).to be_enabled + + post api("/features/#{feature_name}", admin), value: 'false' + + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'off', + 'gates' => [{ 'key' => 'boolean', 'value' => false }]) + end + + it 'disables the feature for the given Flipper group when passed feature_group=perf_team' do + feature.enable(Feature.group(:perf_team)) + expect(Feature.get(feature_name).enabled?(admin)).to be_truthy + + post api("/features/#{feature_name}", admin), value: 'false', feature_group: 'perf_team' + + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'off', + 'gates' => [{ 'key' => 'boolean', 'value' => false }]) + end + + it 'disables the feature for the given user when passed user=username' do + feature.enable(user) + expect(Feature.get(feature_name).enabled?(user)).to be_truthy + + post api("/features/#{feature_name}", admin), value: 'false', user: user.username + + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'off', + 'gates' => [{ 'key' => 'boolean', 'value' => false }]) + end end context 'with a pre-existing percentage value' do @@ -96,7 +224,13 @@ describe API::Features do post api("/features/#{feature_name}", admin), value: '30' expect(response).to have_http_status(201) - expect(Feature.get(feature_name).percentage_of_time_value).to be(30) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'percentage_of_time', 'value' => 30 } + ]) end end end diff --git a/spec/requests/api/namespaces_spec.rb b/spec/requests/api/namespaces_spec.rb index 3bf16a3ae27..26cf653ca8e 100644 --- a/spec/requests/api/namespaces_spec.rb +++ b/spec/requests/api/namespaces_spec.rb @@ -15,6 +15,20 @@ describe API::Namespaces do end context "when authenticated as admin" do + it "returns correct attributes" do + get api("/namespaces", admin) + + group_kind_json_response = json_response.find { |resource| resource['kind'] == 'group' } + user_kind_json_response = json_response.find { |resource| resource['kind'] == 'user' } + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(group_kind_json_response.keys).to contain_exactly('id', 'kind', 'name', 'path', 'full_path', + 'parent_id', 'members_count_with_descendants') + + expect(user_kind_json_response.keys).to contain_exactly('id', 'kind', 'name', 'path', 'full_path', 'parent_id') + end + it "admin: returns an array of all namespaces" do get api("/namespaces", admin) @@ -37,6 +51,27 @@ describe API::Namespaces do end context "when authenticated as a regular user" do + it "returns correct attributes when user can admin group" do + group1.add_owner(user) + + get api("/namespaces", user) + + owned_group_response = json_response.find { |resource| resource['id'] == group1.id } + + expect(owned_group_response.keys).to contain_exactly('id', 'kind', 'name', 'path', 'full_path', + 'parent_id', 'members_count_with_descendants') + end + + it "returns correct attributes when user cannot admin group" do + group1.add_guest(user) + + get api("/namespaces", user) + + guest_group_response = json_response.find { |resource| resource['id'] == group1.id } + + expect(guest_group_response.keys).to contain_exactly('id', 'kind', 'name', 'path', 'full_path', 'parent_id') + end + it "user: returns an array of namespaces" do get api("/namespaces", user) diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 1fd5ded363e..d19ce2d9887 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -700,7 +700,8 @@ describe API::Projects do 'name' => user.namespace.name, 'path' => user.namespace.path, 'kind' => user.namespace.kind, - 'full_path' => user.namespace.full_path + 'full_path' => user.namespace.full_path, + 'parent_id' => nil }) end diff --git a/spec/requests/api/v3/projects_spec.rb b/spec/requests/api/v3/projects_spec.rb index cb74868324c..af44ffa2331 100644 --- a/spec/requests/api/v3/projects_spec.rb +++ b/spec/requests/api/v3/projects_spec.rb @@ -734,7 +734,8 @@ describe API::V3::Projects do 'name' => user.namespace.name, 'path' => user.namespace.path, 'kind' => user.namespace.kind, - 'full_path' => user.namespace.full_path + 'full_path' => user.namespace.full_path, + 'parent_id' => nil }) end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index 95d40138fea..2f1c3c95e59 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -246,28 +246,13 @@ describe 'project routing' do end end - # diffs_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id/diffs(.:format) projects/merge_requests#diffs - # commits_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id/commits(.:format) projects/merge_requests#commits - # merge_namespace_project_merge_request POST /:namespace_id/:project_id/merge_requests/:id/merge(.:format) projects/merge_requests#merge - # ci_status_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id/ci_status(.:format) projects/merge_requests#ci_status - # toggle_subscription_namespace_project_merge_request POST /:namespace_id/:project_id/merge_requests/:id/toggle_subscription(.:format) projects/merge_requests#toggle_subscription - # branch_from_namespace_project_merge_requests GET /:namespace_id/:project_id/merge_requests/branch_from(.:format) projects/merge_requests#branch_from - # branch_to_namespace_project_merge_requests GET /:namespace_id/:project_id/merge_requests/branch_to(.:format) projects/merge_requests#branch_to - # update_branches_namespace_project_merge_requests GET /:namespace_id/:project_id/merge_requests/update_branches(.:format) projects/merge_requests#update_branches - # namespace_project_merge_requests GET /:namespace_id/:project_id/merge_requests(.:format) projects/merge_requests#index - # POST /:namespace_id/:project_id/merge_requests(.:format) projects/merge_requests#create - # new_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/new(.:format) projects/merge_requests#new - # edit_namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id/edit(.:format) projects/merge_requests#edit - # namespace_project_merge_request GET /:namespace_id/:project_id/merge_requests/:id(.:format) projects/merge_requests#show - # PATCH /:namespace_id/:project_id/merge_requests/:id(.:format) projects/merge_requests#update - # PUT /:namespace_id/:project_id/merge_requests/:id(.:format) projects/merge_requests#update describe Projects::MergeRequestsController, 'routing' do - it 'to #diffs' do - expect(get('/gitlab/gitlabhq/merge_requests/1/diffs')).to route_to('projects/merge_requests#diffs', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') + it 'to #commits' do + expect(get('/gitlab/gitlabhq/merge_requests/1/commits.json')).to route_to('projects/merge_requests#commits', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'json') end - it 'to #commits' do - expect(get('/gitlab/gitlabhq/merge_requests/1/commits')).to route_to('projects/merge_requests#commits', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') + it 'to #pipelines' do + expect(get('/gitlab/gitlabhq/merge_requests/1/pipelines.json')).to route_to('projects/merge_requests#pipelines', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'json') end it 'to #merge' do @@ -277,25 +262,59 @@ describe 'project routing' do ) end + it 'to #show' do + expect(get('/gitlab/gitlabhq/merge_requests/1.diff')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'diff') + expect(get('/gitlab/gitlabhq/merge_requests/1.patch')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'patch') + expect(get('/gitlab/gitlabhq/merge_requests/1/diffs')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', tab: 'diffs') + expect(get('/gitlab/gitlabhq/merge_requests/1/commits')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', tab: 'commits') + expect(get('/gitlab/gitlabhq/merge_requests/1/pipelines')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', tab: 'pipelines') + end + + it_behaves_like 'RESTful project resources' do + let(:controller) { 'merge_requests' } + let(:actions) { [:index, :edit, :show, :update] } + end + end + + describe Projects::MergeRequests::CreationsController, 'routing' do + it 'to #new' do + expect(get('/gitlab/gitlabhq/merge_requests/new')).to route_to('projects/merge_requests/creations#new', namespace_id: 'gitlab', project_id: 'gitlabhq') + expect(get('/gitlab/gitlabhq/merge_requests/new/diffs')).to route_to('projects/merge_requests/creations#new', namespace_id: 'gitlab', project_id: 'gitlabhq', tab: 'diffs') + expect(get('/gitlab/gitlabhq/merge_requests/new/pipelines')).to route_to('projects/merge_requests/creations#new', namespace_id: 'gitlab', project_id: 'gitlabhq', tab: 'pipelines') + end + + it 'to #create' do + expect(post('/gitlab/gitlabhq/merge_requests')).to route_to('projects/merge_requests/creations#create', namespace_id: 'gitlab', project_id: 'gitlabhq') + end + it 'to #branch_from' do - expect(get('/gitlab/gitlabhq/merge_requests/branch_from')).to route_to('projects/merge_requests#branch_from', namespace_id: 'gitlab', project_id: 'gitlabhq') + expect(get('/gitlab/gitlabhq/merge_requests/new/branch_from')).to route_to('projects/merge_requests/creations#branch_from', namespace_id: 'gitlab', project_id: 'gitlabhq') end it 'to #branch_to' do - expect(get('/gitlab/gitlabhq/merge_requests/branch_to')).to route_to('projects/merge_requests#branch_to', namespace_id: 'gitlab', project_id: 'gitlabhq') + expect(get('/gitlab/gitlabhq/merge_requests/new/branch_to')).to route_to('projects/merge_requests/creations#branch_to', namespace_id: 'gitlab', project_id: 'gitlabhq') end - it 'to #show' do - expect(get('/gitlab/gitlabhq/merge_requests/1.diff')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'diff') - expect(get('/gitlab/gitlabhq/merge_requests/1.patch')).to route_to('projects/merge_requests#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'patch') + it 'to #pipelines' do + expect(get('/gitlab/gitlabhq/merge_requests/new/pipelines.json')).to route_to('projects/merge_requests/creations#pipelines', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'json') end - it_behaves_like 'RESTful project resources' do - let(:controller) { 'merge_requests' } - let(:actions) { [:index, :create, :new, :edit, :show, :update] } + it 'to #diffs' do + expect(get('/gitlab/gitlabhq/merge_requests/new/diffs.json')).to route_to('projects/merge_requests/creations#diffs', namespace_id: 'gitlab', project_id: 'gitlabhq', format: 'json') + end + end + + describe Projects::MergeRequests::DiffsController, 'routing' do + it 'to #show' do + expect(get('/gitlab/gitlabhq/merge_requests/1/diffs.json')).to route_to('projects/merge_requests/diffs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1', format: 'json') end end + describe Projects::MergeRequests::ConflictsController, 'routing' do + it 'to #show' do + expect(get('/gitlab/gitlabhq/merge_requests/1/conflicts')).to route_to('projects/merge_requests/conflicts#show', namespace_id: 'gitlab', project_id: 'gitlabhq', id: '1') + end + end # raw_project_snippet GET /:project_id/snippets/:id/raw(.:format) snippets#raw # project_snippets GET /:project_id/snippets(.:format) snippets#index # POST /:project_id/snippets(.:format) snippets#create diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb index a1e220c2322..a66cc2cd6e9 100644 --- a/spec/services/boards/issues/list_service_spec.rb +++ b/spec/services/boards/issues/list_service_spec.rb @@ -67,7 +67,7 @@ describe Boards::Issues::ListService, services: true do issues = described_class.new(project, user, params).execute - expect(issues).to eq [closed_issue4, closed_issue2, closed_issue3, closed_issue1] + expect(issues).to eq [closed_issue4, closed_issue2, closed_issue5, closed_issue3, closed_issue1] end it 'returns opened issues that have label list applied when listing issues from a label list' do diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb index a37257d1bf4..d59b37bee36 100644 --- a/spec/services/groups/destroy_service_spec.rb +++ b/spec/services/groups/destroy_service_spec.rb @@ -15,6 +15,14 @@ describe Groups::DestroyService, services: true do group.add_user(user, Gitlab::Access::OWNER) end + def destroy_group(group, user, async) + if async + Groups::DestroyService.new(group, user).async_execute + else + Groups::DestroyService.new(group, user).execute + end + end + shared_examples 'group destruction' do |async| context 'database records' do before do @@ -30,30 +38,14 @@ describe Groups::DestroyService, services: true do context 'file system' do context 'Sidekiq inline' do before do - # Run sidekiq immediatly to check that renamed dir will be removed + # Run sidekiq immediately to check that renamed dir will be removed Sidekiq::Testing.inline! { destroy_group(group, user, async) } end - it { expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey } - it { expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey } - end - - context 'Sidekiq fake' do - before do - # Don't run sidekiq to check if renamed repository exists - Sidekiq::Testing.fake! { destroy_group(group, user, async) } + it 'verifies that paths have been deleted' do + expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey + expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey end - - it { expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_falsey } - it { expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_truthy } - end - end - - def destroy_group(group, user, async) - if async - Groups::DestroyService.new(group, user).async_execute - else - Groups::DestroyService.new(group, user).execute end end end @@ -61,6 +53,26 @@ describe Groups::DestroyService, services: true do describe 'asynchronous delete' do it_behaves_like 'group destruction', true + context 'Sidekiq fake' do + before do + # Don't run Sidekiq to verify that group and projects are not actually destroyed + Sidekiq::Testing.fake! { destroy_group(group, user, true) } + end + + after do + # Clean up stale directories + gitlab_shell.rm_namespace(project.repository_storage_path, group.path) + gitlab_shell.rm_namespace(project.repository_storage_path, remove_path) + end + + it 'verifies original paths and projects still exist' do + expect(gitlab_shell.exists?(project.repository_storage_path, group.path)).to be_truthy + expect(gitlab_shell.exists?(project.repository_storage_path, remove_path)).to be_falsey + expect(Project.unscoped.count).to eq(1) + expect(Group.unscoped.count).to eq(2) + end + end + context 'potential race conditions' do context "when the `GroupDestroyWorker` task runs immediately" do it "deletes the group" do diff --git a/spec/services/notification_recipient_service_spec.rb b/spec/services/notification_recipient_service_spec.rb new file mode 100644 index 00000000000..dfe1ee7c41e --- /dev/null +++ b/spec/services/notification_recipient_service_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe NotificationRecipientService, services: true do + set(:user) { create(:user) } + set(:project) { create(:empty_project, :public) } + set(:issue) { create(:issue, project: project) } + + set(:watcher) do + watcher = create(:user) + setting = watcher.notification_settings_for(project) + setting.level = :watch + setting.save + + watcher + end + + subject { described_class.new(project) } + + describe '#build_recipients' do + it 'does not modify the participants of the target' do + expect { subject.build_recipients(issue, user, action: :new_issue) } + .not_to change { issue.participants(user) } + end + end + + describe '#build_new_note_recipients' do + set(:note) { create(:note_on_issue, noteable: issue, project: project) } + + it 'does not modify the participants of the target' do + expect { subject.build_new_note_recipients(note) } + .not_to change { note.noteable.participants(note.author) } + end + end +end diff --git a/spec/support/fake_migration_classes.rb b/spec/support/fake_migration_classes.rb index 3de0460c3ca..b0fc8422857 100644 --- a/spec/support/fake_migration_classes.rb +++ b/spec/support/fake_migration_classes.rb @@ -1,3 +1,11 @@ class FakeRenameReservedPathMigrationV1 < ActiveRecord::Migration include Gitlab::Database::RenameReservedPathsMigration::V1 + + def version + '20170316163845' + end + + def name + "FakeRenameReservedPathMigrationV1" + end end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 50869099bb7..98b014df6cd 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -28,7 +28,12 @@ shared_examples 'issuable record that supports quick actions in its description describe "new #{issuable_type}", js: true do context 'with commands in the description' do it "creates the #{issuable_type} and interpret commands accordingly" do - visit public_send("new_namespace_project_#{issuable_type}_path", project.namespace, project, new_url_opts) + case issuable_type + when :merge_request + visit public_send("namespace_project_new_merge_request_path", project.namespace, project, new_url_opts) + when :issue + visit public_send("new_namespace_project_issue_path", project.namespace, project, new_url_opts) + end fill_in "#{issuable_type}_title", with: 'bug 345' fill_in "#{issuable_type}_description", with: "bug description\n/label ~bug\n/milestone %\"ASAP\"" click_button "Submit #{issuable_type}".humanize diff --git a/spec/support/generate-seed-repo-rb b/spec/support/generate-seed-repo-rb index 7335f74c0e9..c89389b90ca 100755 --- a/spec/support/generate-seed-repo-rb +++ b/spec/support/generate-seed-repo-rb @@ -15,7 +15,7 @@ require 'erb' require 'tempfile' -SOURCE = 'https://gitlab.com/gitlab-org/gitlab-git-test.git'.freeze +SOURCE = File.expand_path('../gitlab-git-test.git', __FILE__).freeze SCRIPT_NAME = 'generate-seed-repo-rb'.freeze REPO_NAME = 'gitlab-git-test.git'.freeze diff --git a/spec/support/gitlab-git-test.git/HEAD b/spec/support/gitlab-git-test.git/HEAD new file mode 100644 index 00000000000..cb089cd89a7 --- /dev/null +++ b/spec/support/gitlab-git-test.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/spec/support/gitlab-git-test.git/README.md b/spec/support/gitlab-git-test.git/README.md new file mode 100644 index 00000000000..f072cd421be --- /dev/null +++ b/spec/support/gitlab-git-test.git/README.md @@ -0,0 +1,16 @@ +# Gitlab::Git test repository + +This repository is used by (some of) the tests in spec/lib/gitlab/git. + +Do not add new large files to this repository. Otherwise we needlessly +inflate the size of the gitlab-ce repository. + +## How to make changes to this repository + +- (if needed) clone `https://gitlab.com/gitlab-org/gitlab-ce.git` to your local machine +- clone `gitlab-ce/spec/support/gitlab-git-test.git` locally (i.e. clone from your hard drive, not from the internet) +- make changes in your local clone of gitlab-git-test +- run `git push` which will push to your local source `gitlab-ce/spec/support/gitlab-git-test.git` +- in gitlab-ce: run `spec/support/prepare-gitlab-git-test-for-commit` +- in gitlab-ce: `git add spec/support/seed_repo.rb spec/support/gitlab-git-test.git` +- commit your changes in gitlab-ce diff --git a/spec/support/gitlab-git-test.git/config b/spec/support/gitlab-git-test.git/config new file mode 100644 index 00000000000..03e2d1b1e0f --- /dev/null +++ b/spec/support/gitlab-git-test.git/config @@ -0,0 +1,7 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true + precomposeunicode = true +[remote "origin"] + url = https://gitlab.com/gitlab-org/gitlab-git-test.git diff --git a/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.idx b/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.idx Binary files differnew file mode 100644 index 00000000000..2253da798c4 --- /dev/null +++ b/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.idx diff --git a/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.pack b/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.pack Binary files differnew file mode 100644 index 00000000000..3a61107c5b1 --- /dev/null +++ b/spec/support/gitlab-git-test.git/objects/pack/pack-691247af2a6acb0b63b73ac0cb90540e93614043.pack diff --git a/spec/support/gitlab-git-test.git/packed-refs b/spec/support/gitlab-git-test.git/packed-refs new file mode 100644 index 00000000000..ce5ab1f705b --- /dev/null +++ b/spec/support/gitlab-git-test.git/packed-refs @@ -0,0 +1,18 @@ +# pack-refs with: peeled fully-peeled +0b4bc9a49b562e85de7cc9e834518ea6828729b9 refs/heads/feature +12d65c8dd2b2676fa3ac47d955accc085a37a9c1 refs/heads/fix +6473c90867124755509e100d0d35ebdc85a0b6ae refs/heads/fix-blob-path +58fa1a3af4de73ea83fe25a1ef1db8e0c56f67e5 refs/heads/fix-existing-submodule-dir +40f4a7a617393735a95a0bb67b08385bc1e7c66d refs/heads/fix-mode +9abd6a8c113a2dd76df3fdb3d58a8cec6db75f8d refs/heads/gitattributes +46e1395e609395de004cacd4b142865ab0e52a29 refs/heads/gitattributes-updated +4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6 refs/heads/master +5937ac0a7beb003549fc5fd26fc247adbce4a52e refs/heads/merge-test +f4e6814c3e4e7a0de82a9e7cd20c626cc963a2f8 refs/tags/v1.0.0 +^6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9 +8a2a6eb295bb170b34c24c76c49ed0e9b2eaf34b refs/tags/v1.1.0 +^5937ac0a7beb003549fc5fd26fc247adbce4a52e +10d64eed7760f2811ee2d64b44f1f7d3b364f17b refs/tags/v1.2.0 +^eb49186cfa5c4338011f5f590fac11bd66c5c631 +2ac1f24e253e08135507d0830508febaaccf02ee refs/tags/v1.2.1 +^fa1b1e6c004a68b7d8763b86455da9e6b23e36d6 diff --git a/spec/support/gitlab-git-test.git/refs/heads/.gitkeep b/spec/support/gitlab-git-test.git/refs/heads/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/spec/support/gitlab-git-test.git/refs/heads/.gitkeep diff --git a/spec/support/gitlab-git-test.git/refs/tags/.gitkeep b/spec/support/gitlab-git-test.git/refs/tags/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/spec/support/gitlab-git-test.git/refs/tags/.gitkeep diff --git a/spec/support/matchers/access_matchers_for_controller.rb b/spec/support/matchers/access_matchers_for_controller.rb new file mode 100644 index 00000000000..fb43f51c70c --- /dev/null +++ b/spec/support/matchers/access_matchers_for_controller.rb @@ -0,0 +1,84 @@ +# AccessMatchersForController +# +# For testing authorize_xxx in controller. +module AccessMatchersForController + extend RSpec::Matchers::DSL + include Warden::Test::Helpers + + EXPECTED_STATUS_CODE_ALLOWED = [200, 201, 302].freeze + EXPECTED_STATUS_CODE_DENIED = [401, 404].freeze + + def emulate_user(role, membership = nil) + case role + when :admin + user = create(:admin) + sign_in(user) + when :user + user = create(:user) + sign_in(user) + when :external + user = create(:user, external: true) + sign_in(user) + when :visitor + user = nil + when User + user = role + sign_in(user) + when *Gitlab::Access.sym_options_with_owner.keys # owner, master, developer, reporter, guest + raise ArgumentError, "cannot emulate #{role} without membership parent" unless membership + + user = create_user_by_membership(role, membership) + sign_in(user) + else + raise ArgumentError, "cannot emulate user #{role}" + end + + user + end + + def create_user_by_membership(role, membership) + if role == :owner && membership.owner + user = membership.owner + else + user = create(:user) + membership.public_send(:"add_#{role}", user) + end + user + end + + def description_for(role, type, expected, result) + "be #{type} for #{role}. Expected: #{expected.join(',')} Got: #{result}" + end + + matcher :be_allowed_for do |role| + match do |action| + emulate_user(role, @membership) + action.call + + EXPECTED_STATUS_CODE_ALLOWED.include?(response.status) + end + + chain :of do |membership| + @membership = membership + end + + description { description_for(role, 'allowed', EXPECTED_STATUS_CODE_ALLOWED, response.status) } + supports_block_expectations + end + + matcher :be_denied_for do |role| + match do |action| + emulate_user(role, @membership) + action.call + + EXPECTED_STATUS_CODE_DENIED.include?(response.status) + end + + chain :of do |membership| + @membership = membership + end + + description { description_for(role, 'denied', EXPECTED_STATUS_CODE_DENIED, response.status) } + supports_block_expectations + end +end diff --git a/spec/support/prepare-gitlab-git-test-for-commit b/spec/support/prepare-gitlab-git-test-for-commit new file mode 100755 index 00000000000..3047786a599 --- /dev/null +++ b/spec/support/prepare-gitlab-git-test-for-commit @@ -0,0 +1,17 @@ +#!/usr/bin/env ruby + +abort unless [ + system('spec/support/generate-seed-repo-rb', out: 'spec/support/seed_repo.rb'), + system('spec/support/unpack-gitlab-git-test') +].all? + +exit if ARGV.first != '--check-for-changes' + +git_status = IO.popen(%w[git status --porcelain], &:read) +abort unless $?.success? + +puts git_status + +if git_status.lines.grep(%r{^.. spec/support/gitlab-git-test.git}).any? + abort "error: detected changes in gitlab-git-test.git" +end diff --git a/spec/support/seed_helper.rb b/spec/support/seed_helper.rb index 47b5f556e66..8731847592b 100644 --- a/spec/support/seed_helper.rb +++ b/spec/support/seed_helper.rb @@ -9,7 +9,7 @@ TEST_MUTABLE_REPO_PATH = 'mutable-repo.git'.freeze TEST_BROKEN_REPO_PATH = 'broken-repo.git'.freeze module SeedHelper - GITLAB_GIT_TEST_REPO_URL = ENV.fetch('GITLAB_GIT_TEST_REPO_URL', 'https://gitlab.com/gitlab-org/gitlab-git-test.git').freeze + GITLAB_GIT_TEST_REPO_URL = File.expand_path('../gitlab-git-test.git', __FILE__).freeze def ensure_seeds if File.exist?(SEED_STORAGE_PATH) diff --git a/spec/support/protected_branches/access_control_ce_shared_examples.rb b/spec/support/shared_examples/features/protected_branches_access_control_ce.rb index 287d6bb13c3..b6341127a76 100644 --- a/spec/support/protected_branches/access_control_ce_shared_examples.rb +++ b/spec/support/shared_examples/features/protected_branches_access_control_ce.rb @@ -1,4 +1,4 @@ -RSpec.shared_examples "protected branches > access control > CE" do +shared_examples "protected branches > access control > CE" do ProtectedBranch::PushAccessLevel.human_access_levels.each do |(access_type_id, access_type_name)| it "allows creating protected branches that #{access_type_name} can push to" do visit namespace_project_protected_branches_path(project.namespace, project) diff --git a/spec/support/stub_env.rb b/spec/support/stub_env.rb index 2999bcd9fb1..b8928867174 100644 --- a/spec/support/stub_env.rb +++ b/spec/support/stub_env.rb @@ -1,15 +1,33 @@ +# Inspired by https://github.com/ljkbennett/stub_env/blob/master/lib/stub_env/helpers.rb module StubENV - def stub_env(key, value) - allow(ENV).to receive(:[]).and_call_original unless @env_already_stubbed - @env_already_stubbed ||= true + def stub_env(key_or_hash, value = nil) + init_stub unless env_stubbed? + if key_or_hash.is_a? Hash + key_or_hash.each { |k, v| add_stubbed_value(k, v) } + else + add_stubbed_value key_or_hash, value + end + end + + private + + STUBBED_KEY = '__STUBBED__'.freeze + + def add_stubbed_value(key, value) allow(ENV).to receive(:[]).with(key).and_return(value) + allow(ENV).to receive(:fetch).with(key).and_return(value) + allow(ENV).to receive(:fetch).with(key, anything()) do |_, default_val| + value || default_val + end + end + + def env_stubbed? + ENV[STUBBED_KEY] end -end -# It's possible that the state of the class variables are not reset across -# test runs. -RSpec.configure do |config| - config.after(:each) do - @env_already_stubbed = nil + def init_stub + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:fetch).and_call_original + add_stubbed_value(STUBBED_KEY, true) end end diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 1c5267c290b..32546abcad4 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -120,18 +120,21 @@ module TestEnv end def setup_gitlab_shell - unless File.directory?(Gitlab.config.gitlab_shell.path) - unless system('rake', 'gitlab:shell:install') - raise 'Can`t clone gitlab-shell' - end + shell_needs_update = component_needs_update?(Gitlab.config.gitlab_shell.path, + Gitlab::Shell.version_required) + + unless !shell_needs_update || system('rake', 'gitlab:shell:install') + raise 'Can`t clone gitlab-shell' end end def setup_gitaly socket_path = Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '') gitaly_dir = File.dirname(socket_path) + gitaly_needs_update = component_needs_update?(gitaly_dir, + Gitlab::GitalyClient.expected_server_version) - unless !gitaly_needs_update?(gitaly_dir) || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]") + unless !gitaly_needs_update || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]") raise "Can't clone gitaly" end @@ -261,13 +264,13 @@ module TestEnv end end - def gitaly_needs_update?(gitaly_dir) - gitaly_version = File.read(File.join(gitaly_dir, 'VERSION')).strip + def component_needs_update?(component_folder, expected_version) + version = File.read(File.join(component_folder, 'VERSION')).strip # Notice that this will always yield true when using branch versions # (`=branch_name`), but that actually makes sure the server is always based # on the latest branch revision. - gitaly_version != Gitlab::GitalyClient.expected_server_version + version != expected_version rescue Errno::ENOENT true end diff --git a/spec/support/unpack-gitlab-git-test b/spec/support/unpack-gitlab-git-test new file mode 100755 index 00000000000..d5b4912457d --- /dev/null +++ b/spec/support/unpack-gitlab-git-test @@ -0,0 +1,38 @@ +#!/usr/bin/env ruby +require 'fileutils' + +REPO = 'spec/support/gitlab-git-test.git'.freeze +PACK_DIR = REPO + '/objects/pack' +GIT = %W[git --git-dir=#{REPO}].freeze +BASE_PACK = 'pack-691247af2a6acb0b63b73ac0cb90540e93614043'.freeze + +def main + unpack + # We want to store the refs in a packed-refs file because if we don't + # they can get mangled by filesystems. + abort unless system(*GIT, *%w[pack-refs --all]) + abort unless system(*GIT, 'fsck') +end + +# We don't want contributors to commit new pack files because those +# create unnecessary churn. +def unpack + pack_files = Dir[File.join(PACK_DIR, '*')].reject do |pack| + pack.start_with?(File.join(PACK_DIR, BASE_PACK)) + end + return if pack_files.empty? + + pack_files.each do |pack| + unless pack.end_with?('.pack') + FileUtils.rm(pack) + next + end + + File.open(pack, 'rb') do |open_pack| + File.unlink(pack) + abort unless system(*GIT, 'unpack-objects', in: open_pack) + end + end +end + +main diff --git a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb index 4052dbf8df3..3e17fe2104b 100644 --- a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/merge_requests/show/_commits.html.haml' do +describe 'projects/merge_requests/_commits.html.haml' do include Devise::Test::ControllerHelpers let(:user) { create(:user) } diff --git a/spec/views/projects/merge_requests/_new_submit.html.haml_spec.rb b/spec/views/projects/merge_requests/creations/_new_submit.html.haml_spec.rb index 4f698a34ab5..1e9bdf9108f 100644 --- a/spec/views/projects/merge_requests/_new_submit.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/creations/_new_submit.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/merge_requests/_new_submit.html.haml', :view do +describe 'projects/merge_requests/creations/_new_submit.html.haml', :view do let(:merge_request) { create(:merge_request) } let!(:pipeline) { create(:ci_empty_pipeline) } diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index cc9bc29c6cc..a8f4bb72acf 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -4,7 +4,7 @@ describe PostReceive do let(:changes) { "123456 789012 refs/heads/tést\n654321 210987 refs/tags/tag" } let(:wrongly_encoded_changes) { changes.encode("ISO-8859-1").force_encoding("UTF-8") } let(:base64_changes) { Base64.encode64(wrongly_encoded_changes) } - let(:project_identifier) { "project-#{project.id}" } + let(:gl_repository) { "project-#{project.id}" } let(:key) { create(:key, user: project.owner) } let(:key_id) { key.shell_id } @@ -19,22 +19,14 @@ describe PostReceive do end context 'with a non-existing project' do - let(:project_identifier) { "project-123456789" } + let(:gl_repository) { "project-123456789" } let(:error_message) do - "Triggered hook for non-existing project with identifier \"#{project_identifier}\"" + "Triggered hook for non-existing project with gl_repository \"#{gl_repository}\"" end it "returns false and logs an error" do expect(Gitlab::GitLogger).to receive(:error).with("POST-RECEIVE: #{error_message}") - expect(described_class.new.perform(project_identifier, key_id, base64_changes)).to be(false) - end - end - - context "with an absolute path as the project identifier" do - it "searches the project by full path" do - expect(Project).to receive(:find_by_full_path).with(project.full_path, follow_redirects: true).and_call_original - - described_class.new.perform(pwd(project), key_id, base64_changes) + expect(described_class.new.perform(gl_repository, key_id, base64_changes)).to be(false) end end @@ -49,7 +41,7 @@ describe PostReceive do it "calls GitTagPushService" do expect_any_instance_of(GitPushService).to receive(:execute).and_return(true) expect_any_instance_of(GitTagPushService).not_to receive(:execute) - described_class.new.perform(project_identifier, key_id, base64_changes) + described_class.new.perform(gl_repository, key_id, base64_changes) end end @@ -59,7 +51,7 @@ describe PostReceive do it "calls GitTagPushService" do expect_any_instance_of(GitPushService).not_to receive(:execute) expect_any_instance_of(GitTagPushService).to receive(:execute).and_return(true) - described_class.new.perform(project_identifier, key_id, base64_changes) + described_class.new.perform(gl_repository, key_id, base64_changes) end end @@ -69,12 +61,12 @@ describe PostReceive do it "does not call any of the services" do expect_any_instance_of(GitPushService).not_to receive(:execute) expect_any_instance_of(GitTagPushService).not_to receive(:execute) - described_class.new.perform(project_identifier, key_id, base64_changes) + described_class.new.perform(gl_repository, key_id, base64_changes) end end context "gitlab-ci.yml" do - subject { described_class.new.perform(project_identifier, key_id, base64_changes) } + subject { described_class.new.perform(gl_repository, key_id, base64_changes) } context "creates a Ci::Pipeline for every change" do before do @@ -111,7 +103,7 @@ describe PostReceive do it 'calls SystemHooksService' do expect_any_instance_of(SystemHooksService).to receive(:execute_hooks).with(fake_hook_data, :repository_update_hooks).and_return(true) - described_class.new.perform(project_identifier, key_id, base64_changes) + described_class.new.perform(gl_repository, key_id, base64_changes) end end end @@ -119,7 +111,7 @@ describe PostReceive do context "webhook" do it "fetches the correct project" do expect(Project).to receive(:find_by).with(id: project.id.to_s) - described_class.new.perform(project_identifier, key_id, base64_changes) + described_class.new.perform(gl_repository, key_id, base64_changes) end it "does not run if the author is not in the project" do @@ -129,7 +121,7 @@ describe PostReceive do expect(project).not_to receive(:execute_hooks) - expect(described_class.new.perform(project_identifier, key_id, base64_changes)).to be_falsey + expect(described_class.new.perform(gl_repository, key_id, base64_changes)).to be_falsey end it "asks the project to trigger all hooks" do @@ -137,18 +129,14 @@ describe PostReceive do expect(project).to receive(:execute_hooks).twice expect(project).to receive(:execute_services).twice - described_class.new.perform(project_identifier, key_id, base64_changes) + described_class.new.perform(gl_repository, key_id, base64_changes) end it "enqueues a UpdateMergeRequestsWorker job" do allow(Project).to receive(:find_by).and_return(project) expect(UpdateMergeRequestsWorker).to receive(:perform_async).with(project.id, project.owner.id, any_args) - described_class.new.perform(project_identifier, key_id, base64_changes) + described_class.new.perform(gl_repository, key_id, base64_changes) end end - - def pwd(project) - File.join(Gitlab.config.repositories.storages.default['path'], project.path_with_namespace) - end end |