summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js.es62
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js.es63
-rw-r--r--app/assets/javascripts/issuable/issuable_bundle.js.es61
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es642
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es669
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es613
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/help_state.js.es624
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es611
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es613
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6118
-rw-r--r--app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es661
-rw-r--r--app/assets/javascripts/lib/utils/pretty_time.js.es626
-rw-r--r--app/assets/javascripts/lib/vue_resource.js.es62
-rw-r--r--app/assets/javascripts/project_new.js10
-rw-r--r--app/assets/javascripts/subbable_resource.js.es63
-rw-r--r--app/assets/javascripts/visibility_select.js.es627
-rw-r--r--app/assets/javascripts/vue_pipelines_index/stage.js.es624
-rw-r--r--app/assets/stylesheets/framework/buttons.scss2
-rw-r--r--app/assets/stylesheets/framework/filters.scss4
-rw-r--r--app/assets/stylesheets/framework/variables.scss6
-rw-r--r--app/assets/stylesheets/pages/issuable.scss99
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss10
-rw-r--r--app/assets/stylesheets/pages/projects.scss28
-rw-r--r--app/controllers/autocomplete_controller.rb2
-rw-r--r--app/controllers/concerns/cycle_analytics_params.rb4
-rw-r--r--app/controllers/dashboard/milestones_controller.rb4
-rw-r--r--app/controllers/projects/commit_controller.rb5
-rw-r--r--app/controllers/projects/cycle_analytics/events_controller.rb32
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb54
-rw-r--r--app/helpers/issuables_helper.rb9
-rw-r--r--app/helpers/projects_helper.rb11
-rw-r--r--app/helpers/todos_helper.rb3
-rw-r--r--app/models/concerns/issuable.rb1
-rw-r--r--app/models/concerns/time_trackable.rb72
-rw-r--r--app/models/cycle_analytics.rb60
-rw-r--r--app/models/cycle_analytics/summary.rb43
-rw-r--r--app/models/dashboard_milestone.rb5
-rw-r--r--app/models/generic_commit_status.rb10
-rw-r--r--app/models/merge_request.rb4
-rw-r--r--app/models/project_statistics.rb3
-rw-r--r--app/models/timelog.rb6
-rw-r--r--app/models/todo.rb8
-rw-r--r--app/serializers/analytics_stage_entity.rb10
-rw-r--r--app/serializers/analytics_stage_serializer.rb3
-rw-r--r--app/serializers/analytics_summary_entity.rb7
-rw-r--r--app/serializers/analytics_summary_serializer.rb3
-rw-r--r--app/serializers/issuable_entity.rb4
-rw-r--r--app/services/issuable_base_service.rb16
-rw-r--r--app/services/slash_commands/interpret_service.rb44
-rw-r--r--app/services/system_note_service.rb51
-rw-r--r--app/services/todo_service.rb30
-rw-r--r--app/views/dashboard/todos/_todo.html.haml2
-rw-r--r--app/views/projects/_visibility_select.html.haml7
-rw-r--r--app/views/projects/commit/_ci_menu.html.haml2
-rw-r--r--app/views/projects/commit/pipelines.html.haml2
-rw-r--r--app/views/projects/edit.html.haml110
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/_show.html.haml1
-rw-r--r--app/views/projects/merge_requests/conflicts.html.haml1
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_help.html.haml4
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml144
-rw-r--r--app/views/shared/icons/_icon_stopwatch.svg1
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml14
-rw-r--r--app/views/shared/milestones/_issuable.html.haml2
-rw-r--r--app/views/shared/milestones/_issuables.html.haml3
-rw-r--r--changelogs/unreleased/23524-notify-automerge-user-of-failed-build.yml4
-rw-r--r--changelogs/unreleased/24032-changed-visibility-level-to-public-but-project-is-not-public.yml4
-rw-r--r--changelogs/unreleased/26117-sort-pipeline-for-commit.yml4
-rw-r--r--changelogs/unreleased/26667-pipeline-width-for-huge-pipeline.yml4
-rw-r--r--changelogs/unreleased/26773-fix-project-statistics-repository-size.yml4
-rw-r--r--changelogs/unreleased/8623-correct-robots-txt.yml4
-rw-r--r--changelogs/unreleased/fix-external-status-badge-links.yml4
-rw-r--r--changelogs/unreleased/input-button-hover.yml4
-rw-r--r--changelogs/unreleased/speed-up-dashboard-milestone-index.yml5
-rw-r--r--changelogs/unreleased/time-tracking-api.yml4
-rw-r--r--config/application.rb2
-rw-r--r--db/migrate/20161223034433_add_time_estimate_to_issuables.rb30
-rw-r--r--db/migrate/20161223034646_create_timelogs.rb38
-rw-r--r--db/schema.rb14
-rw-r--r--doc/api/issues.md140
-rw-r--r--doc/api/merge_requests.md139
-rw-r--r--doc/development/frontend.md78
-rw-r--r--doc/integration/omniauth.md10
-rw-r--r--doc/project_services/project_services.md1
-rw-r--r--doc/user/project/slash_commands.md4
-rw-r--r--doc/workflow/README.md1
-rw-r--r--doc/workflow/time-tracking/time-tracking-example.pngbin0 -> 48350 bytes
-rw-r--r--doc/workflow/time-tracking/time-tracking-sidebar.pngbin0 -> 19467 bytes
-rw-r--r--doc/workflow/time_tracking.md73
-rw-r--r--features/steps/dashboard/dashboard.rb6
-rw-r--r--features/steps/dashboard/issues.rb4
-rw-r--r--features/steps/dashboard/merge_requests.rb4
-rw-r--r--features/steps/group/milestones.rb2
-rw-r--r--features/steps/groups.rb2
-rw-r--r--features/steps/profile/profile.rb2
-rw-r--r--features/steps/project/deploy_keys.rb4
-rw-r--r--features/steps/project/fork.rb4
-rw-r--r--features/steps/project/forked_merge_requests.rb2
-rw-r--r--features/steps/project/merge_requests/acceptance.rb2
-rw-r--r--features/steps/project/merge_requests/revert.rb2
-rw-r--r--features/steps/project/redirects.rb4
-rw-r--r--features/steps/project/source/browse_files.rb2
-rw-r--r--features/steps/project/source/markdown_render.rb2
-rw-r--r--features/steps/project/team_management.rb2
-rw-r--r--features/steps/shared/admin.rb2
-rw-r--r--features/steps/shared/group.rb2
-rw-r--r--features/steps/shared/project.rb18
-rw-r--r--features/steps/user.rb2
-rw-r--r--lib/api/commit_statuses.rb2
-rw-r--r--lib/api/entities.rb7
-rw-r--r--lib/api/helpers.rb4
-rw-r--r--lib/api/issues.rb2
-rw-r--r--lib/api/merge_requests.rb22
-rw-r--r--lib/api/time_tracking_endpoints.rb114
-rw-r--r--lib/banzai/filter/reference_filter.rb8
-rw-r--r--lib/gitlab/ci/status/external/common.rb22
-rw-r--r--lib/gitlab/ci/status/external/factory.rb13
-rw-r--r--lib/gitlab/cycle_analytics/base_event_fetcher.rb (renamed from lib/gitlab/cycle_analytics/base_event.rb)26
-rw-r--r--lib/gitlab/cycle_analytics/base_query.rb31
-rw-r--r--lib/gitlab/cycle_analytics/base_stage.rb54
-rw-r--r--lib/gitlab/cycle_analytics/code_event_fetcher.rb (renamed from lib/gitlab/cycle_analytics/review_event.rb)8
-rw-r--r--lib/gitlab/cycle_analytics/code_stage.rb21
-rw-r--r--lib/gitlab/cycle_analytics/event_fetcher.rb9
-rw-r--r--lib/gitlab/cycle_analytics/events.rb38
-rw-r--r--lib/gitlab/cycle_analytics/events_query.rb37
-rw-r--r--lib/gitlab/cycle_analytics/issue_event.rb27
-rw-r--r--lib/gitlab/cycle_analytics/issue_event_fetcher.rb (renamed from lib/gitlab/cycle_analytics/production_event.rb)5
-rw-r--r--lib/gitlab/cycle_analytics/issue_stage.rb22
-rw-r--r--lib/gitlab/cycle_analytics/metrics_fetcher.rb60
-rw-r--r--lib/gitlab/cycle_analytics/plan_event_fetcher.rb (renamed from lib/gitlab/cycle_analytics/plan_event.rb)10
-rw-r--r--lib/gitlab/cycle_analytics/plan_stage.rb22
-rw-r--r--lib/gitlab/cycle_analytics/production_event_fetcher.rb6
-rw-r--r--lib/gitlab/cycle_analytics/production_helper.rb9
-rw-r--r--lib/gitlab/cycle_analytics/production_stage.rb28
-rw-r--r--lib/gitlab/cycle_analytics/review_event_fetcher.rb (renamed from lib/gitlab/cycle_analytics/code_event.rb)8
-rw-r--r--lib/gitlab/cycle_analytics/review_stage.rb21
-rw-r--r--lib/gitlab/cycle_analytics/stage.rb9
-rw-r--r--lib/gitlab/cycle_analytics/stage_summary.rb23
-rw-r--r--lib/gitlab/cycle_analytics/staging_event_fetcher.rb (renamed from lib/gitlab/cycle_analytics/staging_event.rb)9
-rw-r--r--lib/gitlab/cycle_analytics/staging_stage.rb22
-rw-r--r--lib/gitlab/cycle_analytics/summary/base.rb20
-rw-r--r--lib/gitlab/cycle_analytics/summary/commit.rb39
-rw-r--r--lib/gitlab/cycle_analytics/summary/deploy.rb11
-rw-r--r--lib/gitlab/cycle_analytics/summary/issue.rb21
-rw-r--r--lib/gitlab/cycle_analytics/test_event.rb13
-rw-r--r--lib/gitlab/cycle_analytics/test_event_fetcher.rb6
-rw-r--r--lib/gitlab/cycle_analytics/test_stage.rb29
-rw-r--r--lib/gitlab/database/median.rb5
-rw-r--r--lib/gitlab/import_export/import_export.yml2
-rw-r--r--lib/gitlab/time_tracking_formatter.rb34
-rw-r--r--public/robots.txt5
-rw-r--r--spec/controllers/admin/services_controller_spec.rb26
-rw-r--r--spec/controllers/projects/commit_controller_spec.rb38
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb48
-rw-r--r--spec/factories/projects.rb4
-rw-r--r--spec/factories/timelogs.rb9
-rw-r--r--spec/factories/todos.rb4
-rw-r--r--spec/features/issues/filtered_search/dropdown_assignee_spec.rb6
-rw-r--r--spec/features/issues/filtered_search/dropdown_author_spec.rb6
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb39
-rw-r--r--spec/features/issues/user_uses_slash_commands_spec.rb52
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb61
-rw-r--r--spec/features/projects/settings/visibility_settings_spec.rb47
-rw-r--r--spec/javascripts/gfm_auto_complete_spec.js.es665
-rw-r--r--spec/javascripts/issuable_time_tracker_spec.js.es6201
-rw-r--r--spec/javascripts/pretty_time_spec.js.es622
-rw-r--r--spec/javascripts/visibility_select_spec.js.es6100
-rw-r--r--spec/lib/gitlab/ci/status/external/common_spec.rb39
-rw-r--r--spec/lib/gitlab/ci/status/external/factory_spec.rb38
-rw-r--r--spec/lib/gitlab/cycle_analytics/code_event_fetcher_spec.rb12
-rw-r--r--spec/lib/gitlab/cycle_analytics/code_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/code_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/events_spec.rb137
-rw-r--r--spec/lib/gitlab/cycle_analytics/issue_event_fetcher_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/issue_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/plan_event_fetcher_spec.rb (renamed from spec/lib/gitlab/cycle_analytics/plan_event_spec.rb)10
-rw-r--r--spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/production_event_fetcher_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/production_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/production_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/review_event_fetcher_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/review_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/review_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/shared_event_spec.rb11
-rw-r--r--spec/lib/gitlab/cycle_analytics/shared_stage_spec.rb30
-rw-r--r--spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb (renamed from spec/models/cycle_analytics/summary_spec.rb)20
-rw-r--r--spec/lib/gitlab/cycle_analytics/staging_event_fetcher_spec.rb12
-rw-r--r--spec/lib/gitlab/cycle_analytics/staging_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/staging_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/cycle_analytics/test_event_fetcher_spec.rb12
-rw-r--r--spec/lib/gitlab/cycle_analytics/test_event_spec.rb10
-rw-r--r--spec/lib/gitlab/cycle_analytics/test_stage_spec.rb8
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml5
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml10
-rw-r--r--spec/models/concerns/issuable_spec.rb38
-rw-r--r--spec/models/cycle_analytics/code_spec.rb22
-rw-r--r--spec/models/cycle_analytics/issue_spec.rb4
-rw-r--r--spec/models/cycle_analytics/plan_spec.rb4
-rw-r--r--spec/models/cycle_analytics/production_spec.rb6
-rw-r--r--spec/models/cycle_analytics/review_spec.rb4
-rw-r--r--spec/models/cycle_analytics/staging_spec.rb6
-rw-r--r--spec/models/cycle_analytics/test_spec.rb10
-rw-r--r--spec/models/generic_commit_status_spec.rb33
-rw-r--r--spec/models/merge_request_spec.rb10
-rw-r--r--spec/models/project_statistics_spec.rb2
-rw-r--r--spec/models/timelog_spec.rb10
-rw-r--r--spec/requests/api/commit_statuses_spec.rb28
-rw-r--r--spec/requests/api/issues_spec.rb6
-rw-r--r--spec/requests/api/merge_requests_spec.rb8
-rw-r--r--spec/serializers/analytics_stage_serializer_spec.rb24
-rw-r--r--spec/serializers/analytics_summary_serializer_spec.rb29
-rw-r--r--spec/services/notes/slash_commands_service_spec.rb12
-rw-r--r--spec/services/slash_commands/interpret_service_spec.rb85
-rw-r--r--spec/services/system_note_service_spec.rb65
-rw-r--r--spec/services/todo_service_spec.rb16
-rw-r--r--spec/support/api/time_tracking_shared_examples.rb132
-rw-r--r--spec/support/cycle_analytics_helpers/test_generation.rb13
-rw-r--r--spec/support/time_tracking_shared_examples.rb82
219 files changed, 3906 insertions, 867 deletions
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
index 840b5aa922e..1b3a57d0962 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
@@ -2,8 +2,6 @@
/* global Vue */
/* global ResolveCount */
-//= require vue
-//= require vue-resource
//= require_directory ./models
//= require_directory ./stores
//= require_directory ./services
diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6
index 6ca543c2b00..358c1e52178 100644
--- a/app/assets/javascripts/gfm_auto_complete.js.es6
+++ b/app/assets/javascripts/gfm_auto_complete.js.es6
@@ -48,8 +48,9 @@
},
DefaultOptions: {
sorter: function(query, items, searchKey) {
- this.setting.highlightFirst = query.length > 0;
+ this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
if (gl.GfmAutoComplete.isLoading(items)) {
+ this.setting.highlightFirst = false;
return items;
}
return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey);
diff --git a/app/assets/javascripts/issuable/issuable_bundle.js.es6 b/app/assets/javascripts/issuable/issuable_bundle.js.es6
new file mode 100644
index 00000000000..7d0465aa8b4
--- /dev/null
+++ b/app/assets/javascripts/issuable/issuable_bundle.js.es6
@@ -0,0 +1 @@
+//= require ./time_tracking/time_tracking_bundle
diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6
new file mode 100644
index 00000000000..72433df2818
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6
@@ -0,0 +1,42 @@
+/* global Vue */
+//= require lib/utils/pretty_time
+
+(() => {
+ Vue.component('time-tracking-collapsed-state', {
+ name: 'time-tracking-collapsed-state',
+ props: [
+ 'showComparisonState',
+ 'showSpentOnlyState',
+ 'showEstimateOnlyState',
+ 'showNoTimeTrackingState',
+ 'timeSpentHumanReadable',
+ 'timeEstimateHumanReadable',
+ 'stopwatchSvg',
+ ],
+ methods: {
+ abbreviateTime(timeStr) {
+ return gl.utils.prettyTime.abbreviateTime(timeStr);
+ },
+ },
+ template: `
+ <div class='sidebar-collapsed-icon'>
+ <div v-html='stopwatchSvg'></div>
+ <div class='time-tracking-collapsed-summary'>
+ <div class='compare' v-if='showComparisonState'>
+ <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
+ </div>
+ <div class='estimate-only' v-if='showEstimateOnlyState'>
+ <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
+ </div>
+ <div class='spend-only' v-if='showSpentOnlyState'>
+ <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span>
+ </div>
+ <div class='no-tracking' v-if='showNoTimeTrackingState'>
+ <span class='no-value'>None</span>
+ </div>
+ </div>
+ </div>
+ `,
+ });
+})();
+
diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6
new file mode 100644
index 00000000000..6abbd5dd167
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6
@@ -0,0 +1,69 @@
+/* global Vue */
+//= require lib/utils/pretty_time
+
+(() => {
+ const prettyTime = gl.utils.prettyTime;
+
+ Vue.component('time-tracking-comparison-pane', {
+ name: 'time-tracking-comparison-pane',
+ props: [
+ 'timeSpent',
+ 'timeEstimate',
+ 'timeSpentHumanReadable',
+ 'timeEstimateHumanReadable',
+ ],
+ computed: {
+ parsedRemaining() {
+ const diffSeconds = this.timeEstimate - this.timeSpent;
+ return prettyTime.parseSeconds(diffSeconds);
+ },
+ timeRemainingHumanReadable() {
+ return prettyTime.stringifyTime(this.parsedRemaining);
+ },
+ timeRemainingTooltip() {
+ const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
+ return `${prefix} ${this.timeRemainingHumanReadable}`;
+ },
+ /* Diff values for comparison meter */
+ timeRemainingMinutes() {
+ return this.timeEstimate - this.timeSpent;
+ },
+ timeRemainingPercent() {
+ return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
+ },
+ timeRemainingStatusClass() {
+ return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
+ },
+ /* Parsed time values */
+ parsedEstimate() {
+ return prettyTime.parseSeconds(this.timeEstimate);
+ },
+ parsedSpent() {
+ return prettyTime.parseSeconds(this.timeSpent);
+ },
+ },
+ template: `
+ <div class='time-tracking-comparison-pane'>
+ <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay'
+ :aria-valuenow='timeRemainingTooltip'
+ :title='timeRemainingTooltip'
+ :data-original-title='timeRemainingTooltip'
+ :class='timeRemainingStatusClass'>
+ <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'>
+ <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div>
+ </div>
+ <div class='compare-display-container'>
+ <div class='compare-display pull-left'>
+ <span class='compare-label'>Spent</span>
+ <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span>
+ </div>
+ <div class='compare-display estimated pull-right'>
+ <span class='compare-label'>Est</span>
+ <span class='compare-value'>{{ timeEstimateHumanReadable }}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6
new file mode 100644
index 00000000000..309e9f2f9ef
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6
@@ -0,0 +1,13 @@
+/* global Vue */
+(() => {
+ Vue.component('time-tracking-estimate-only-pane', {
+ name: 'time-tracking-estimate-only-pane',
+ props: ['timeEstimateHumanReadable'],
+ template: `
+ <div class='time-tracking-estimate-only-pane'>
+ <span class='bold'>Estimated:</span>
+ {{ timeEstimateHumanReadable }}
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6
new file mode 100644
index 00000000000..d7ced6d7151
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6
@@ -0,0 +1,24 @@
+/* global Vue */
+(() => {
+ Vue.component('time-tracking-help-state', {
+ name: 'time-tracking-help-state',
+ props: ['docsUrl'],
+ template: `
+ <div class='time-tracking-help-state'>
+ <div class='time-tracking-info'>
+ <h4>Track time with slash commands</h4>
+ <p>Slash commands can be used in the issues description and comment boxes.</p>
+ <p>
+ <code>/estimate</code>
+ will update the estimated time with the latest command.
+ </p>
+ <p>
+ <code>/spend</code>
+ will update the sum of the time spent.
+ </p>
+ <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a>
+ </div>
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6
new file mode 100644
index 00000000000..1d2ca643b5b
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6
@@ -0,0 +1,11 @@
+/* global Vue */
+(() => {
+ Vue.component('time-tracking-no-tracking-pane', {
+ name: 'time-tracking-no-tracking-pane',
+ template: `
+ <div class='time-tracking-no-tracking-pane'>
+ <span class='no-value'>No estimate or time spent</span>
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6
new file mode 100644
index 00000000000..ed283fec3c3
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6
@@ -0,0 +1,13 @@
+/* global Vue */
+(() => {
+ Vue.component('time-tracking-spent-only-pane', {
+ name: 'time-tracking-spent-only-pane',
+ props: ['timeSpentHumanReadable'],
+ template: `
+ <div class='time-tracking-spend-only-pane'>
+ <span class='bold'>Spent:</span>
+ {{ timeSpentHumanReadable }}
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6
new file mode 100644
index 00000000000..26563a7713b
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6
@@ -0,0 +1,118 @@
+/* global Vue */
+//= require ./help_state
+//= require ./collapsed_state
+//= require ./spent_only_pane
+//= require ./no_tracking_pane
+//= require ./estimate_only_pane
+//= require ./comparison_pane
+
+(() => {
+ Vue.component('issuable-time-tracker', {
+ name: 'issuable-time-tracker',
+ props: [
+ 'time_estimate',
+ 'time_spent',
+ 'human_time_estimate',
+ 'human_time_spent',
+ 'stopwatchSvg',
+ 'docsUrl',
+ ],
+ data() {
+ return {
+ showHelp: false,
+ };
+ },
+ computed: {
+ timeSpent() {
+ return this.time_spent;
+ },
+ timeEstimate() {
+ return this.time_estimate;
+ },
+ timeEstimateHumanReadable() {
+ return this.human_time_estimate;
+ },
+ timeSpentHumanReadable() {
+ return this.human_time_spent;
+ },
+ hasTimeSpent() {
+ return !!this.timeSpent;
+ },
+ hasTimeEstimate() {
+ return !!this.timeEstimate;
+ },
+ showComparisonState() {
+ return this.hasTimeEstimate && this.hasTimeSpent;
+ },
+ showEstimateOnlyState() {
+ return this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showSpentOnlyState() {
+ return this.hasTimeSpent && !this.hasTimeEstimate;
+ },
+ showNoTimeTrackingState() {
+ return !this.hasTimeEstimate && !this.hasTimeSpent;
+ },
+ showHelpState() {
+ return !!this.showHelp;
+ },
+ },
+ methods: {
+ toggleHelpState(show) {
+ this.showHelp = show;
+ },
+ },
+ template: `
+ <div class='time_tracker time-tracking-component-wrap' v-cloak>
+ <time-tracking-collapsed-state
+ :show-comparison-state='showComparisonState'
+ :show-help-state='showHelpState'
+ :show-spent-only-state='showSpentOnlyState'
+ :show-estimate-only-state='showEstimateOnlyState'
+ :time-spent-human-readable='timeSpentHumanReadable'
+ :time-estimate-human-readable='timeEstimateHumanReadable'
+ :stopwatch-svg='stopwatchSvg'>
+ </time-tracking-collapsed-state>
+ <div class='title hide-collapsed'>
+ Time tracking
+ <div class='help-button pull-right'
+ v-if='!showHelpState'
+ @click='toggleHelpState(true)'>
+ <i class='fa fa-question-circle'></i>
+ </div>
+ <div class='close-help-button pull-right'
+ v-if='showHelpState'
+ @click='toggleHelpState(false)'>
+ <i class='fa fa-close'></i>
+ </div>
+ </div>
+ <div class='time-tracking-content hide-collapsed'>
+ <time-tracking-estimate-only-pane
+ v-if='showEstimateOnlyState'
+ :time-estimate-human-readable='timeEstimateHumanReadable'>
+ </time-tracking-estimate-only-pane>
+ <time-tracking-spent-only-pane
+ v-if='showSpentOnlyState'
+ :time-spent-human-readable='timeSpentHumanReadable'>
+ </time-tracking-spent-only-pane>
+ <time-tracking-no-tracking-pane
+ v-if='showNoTimeTrackingState'>
+ </time-tracking-no-tracking-pane>
+ <time-tracking-comparison-pane
+ v-if='showComparisonState'
+ :time-estimate='timeEstimate'
+ :time-spent='timeSpent'
+ :time-spent-human-readable='timeSpentHumanReadable'
+ :time-estimate-human-readable='timeEstimateHumanReadable'>
+ </time-tracking-comparison-pane>
+ <transition name='help-state-toggle'>
+ <time-tracking-help-state
+ v-if='showHelpState'
+ :docs-url='docsUrl'>
+ </time-tracking-help-state>
+ </transition>
+ </div>
+ </div>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6
new file mode 100644
index 00000000000..0b8da2b1f4f
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6
@@ -0,0 +1,61 @@
+/* global Vue */
+//= require ./components/time_tracker
+//= require smart_interval
+//= require subbable_resource
+
+(() => {
+ /* This Vue instance represents what will become the parent instance for the
+ * sidebar. It will be responsible for managing `issuable` state and propagating
+ * changes to sidebar components. We will want to create a separate service to
+ * interface with the server at that point.
+ */
+
+ class IssuableTimeTracking {
+ constructor(issuableJSON) {
+ const parsedIssuable = JSON.parse(issuableJSON);
+ return this.initComponent(parsedIssuable);
+ }
+
+ initComponent(parsedIssuable) {
+ this.parentInstance = new Vue({
+ el: '#issuable-time-tracker',
+ data: {
+ issuable: parsedIssuable,
+ },
+ methods: {
+ fetchIssuable() {
+ return gl.IssuableResource.get.call(gl.IssuableResource, {
+ type: 'GET',
+ url: gl.IssuableResource.endpoint,
+ });
+ },
+ updateState(data) {
+ this.issuable = data;
+ },
+ subscribeToUpdates() {
+ gl.IssuableResource.subscribe(data => this.updateState(data));
+ },
+ listenForSlashCommands() {
+ $(document).on('ajax:success', '.gfm-form', (e, data) => {
+ const subscribedCommands = ['spend_time', 'time_estimate'];
+ const changedCommands = data.commands_changes;
+
+ if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
+ this.fetchIssuable();
+ }
+ });
+ },
+ },
+ created() {
+ this.fetchIssuable();
+ },
+ mounted() {
+ this.subscribeToUpdates();
+ this.listenForSlashCommands();
+ },
+ });
+ }
+ }
+
+ gl.IssuableTimeTracking = IssuableTimeTracking;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/lib/utils/pretty_time.js.es6 b/app/assets/javascripts/lib/utils/pretty_time.js.es6
index ccaf447eb0b..ae397212e55 100644
--- a/app/assets/javascripts/lib/utils/pretty_time.js.es6
+++ b/app/assets/javascripts/lib/utils/pretty_time.js.es6
@@ -4,13 +4,13 @@
* stringifyTime condensed or non-condensed, abbreviateTimelengths)
* */
- class PrettyTime {
-
+ const utils = window.gl.utils = gl.utils || {};
+ const prettyTime = utils.prettyTime = {
/*
* Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
* Seconds can be negative or positive, zero or non-zero.
*/
- static parseSeconds(seconds) {
+ parseSeconds(seconds) {
const DAYS_PER_WEEK = 5;
const HOURS_PER_DAY = 8;
const MINUTES_PER_HOUR = 60;
@@ -24,7 +24,7 @@
minutes: 1,
};
- let unorderedMinutes = PrettyTime.secondsToMinutes(seconds);
+ let unorderedMinutes = prettyTime.secondsToMinutes(seconds);
return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
@@ -33,35 +33,33 @@
return periodCount;
});
- }
+ },
/*
* Accepts a timeObject and returns a condensed string representation of it
* (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
*/
- static stringifyTime(timeObject) {
+ stringifyTime(timeObject) {
const reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => {
const isNonZero = !!unitValue;
return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
}, '').trim();
return reducedTime.length ? reducedTime : '0m';
- }
+ },
/*
* Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
* the first non-zero unit/value pair.
*/
- static abbreviateTime(timeStr) {
+ abbreviateTime(timeStr) {
return timeStr.split(' ')
.filter(unitStr => unitStr.charAt(0) !== '0')[0];
- }
+ },
- static secondsToMinutes(seconds) {
+ secondsToMinutes(seconds) {
return Math.abs(seconds / 60);
- }
- }
-
- gl.PrettyTime = PrettyTime;
+ },
+ };
})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/lib/vue_resource.js.es6 b/app/assets/javascripts/lib/vue_resource.js.es6
new file mode 100644
index 00000000000..eff1dcabfa2
--- /dev/null
+++ b/app/assets/javascripts/lib/vue_resource.js.es6
@@ -0,0 +1,2 @@
+//= require vue
+//= require vue-resource
diff --git a/app/assets/javascripts/project_new.js b/app/assets/javascripts/project_new.js
index 7fc611d0dad..86d57d97eae 100644
--- a/app/assets/javascripts/project_new.js
+++ b/app/assets/javascripts/project_new.js
@@ -14,11 +14,21 @@
return $('.save-project-loader').show();
};
})(this));
+
+ this.initVisibilitySelect();
+
this.toggleSettings();
this.toggleSettingsOnclick();
this.toggleRepoVisibility();
}
+ ProjectNew.prototype.initVisibilitySelect = function() {
+ const visibilityContainer = document.querySelector('.js-visibility-select');
+ if (!visibilityContainer) return;
+ const visibilitySelect = new gl.VisibilitySelect(visibilityContainer);
+ visibilitySelect.init();
+ };
+
ProjectNew.prototype.toggleSettings = function() {
var self = this;
diff --git a/app/assets/javascripts/subbable_resource.js.es6 b/app/assets/javascripts/subbable_resource.js.es6
index 932120157a3..d8191605128 100644
--- a/app/assets/javascripts/subbable_resource.js.es6
+++ b/app/assets/javascripts/subbable_resource.js.es6
@@ -1,6 +1,3 @@
-//= require vue
-//= require vue-resource
-
(() => {
/*
* SubbableResource can be extended to provide a pubsub-style service for one-off REST
diff --git a/app/assets/javascripts/visibility_select.js.es6 b/app/assets/javascripts/visibility_select.js.es6
new file mode 100644
index 00000000000..f712d7ba930
--- /dev/null
+++ b/app/assets/javascripts/visibility_select.js.es6
@@ -0,0 +1,27 @@
+(() => {
+ const gl = window.gl || (window.gl = {});
+
+ class VisibilitySelect {
+ constructor(container) {
+ if (!container) throw new Error('VisibilitySelect requires a container element as argument 1');
+ this.container = container;
+ this.helpBlock = this.container.querySelector('.help-block');
+ this.select = this.container.querySelector('select');
+ }
+
+ init() {
+ if (this.select) {
+ this.updateHelpText();
+ this.select.addEventListener('change', this.updateHelpText.bind(this));
+ } else {
+ this.helpBlock.textContent = this.container.querySelector('.js-locked').dataset.helpBlock;
+ }
+ }
+
+ updateHelpText() {
+ this.helpBlock.textContent = this.select.querySelector('option:checked').dataset.description;
+ }
+ }
+
+ gl.VisibilitySelect = VisibilitySelect;
+})();
diff --git a/app/assets/javascripts/vue_pipelines_index/stage.js.es6 b/app/assets/javascripts/vue_pipelines_index/stage.js.es6
index f075a995846..32973132174 100644
--- a/app/assets/javascripts/vue_pipelines_index/stage.js.es6
+++ b/app/assets/javascripts/vue_pipelines_index/stage.js.es6
@@ -5,18 +5,19 @@
gl.VueStage = Vue.extend({
data() {
return {
- count: 0,
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
};
},
props: ['stage', 'svgs', 'match'],
methods: {
- fetchBuilds() {
- if (this.count > 0) return null;
+ fetchBuilds(e) {
+ const areaExpanded = e.currentTarget.attributes['aria-expanded'];
+
+ if (areaExpanded && (areaExpanded.textContent === 'true')) return null;
+
return this.$http.get(this.stage.dropdown_path)
.then((response) => {
- this.count += 1;
this.builds = JSON.parse(response.body).html;
}, () => {
const flash = new Flash('Something went wrong on our end.');
@@ -39,7 +40,7 @@
return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
},
svg() {
- const icon = this.stage.status.icon;
+ const { icon } = this.stage.status;
const stageIcon = icon.replace(/icon/i, 'stage_icon');
return this.svgs[this.match(stageIcon)];
},
@@ -50,18 +51,25 @@
template: `
<div>
<button
- @click='fetchBuilds'
+ @click='fetchBuilds($event)'
:class="triggerButtonClass"
:title='stage.title'
data-placement="top"
data-toggle="dropdown"
- type="button">
+ type="button"
+ >
<span v-html="svg"></span>
<i class="fa fa-caret-down "></i>
</button>
<ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
<div class="arrow-up"></div>
- <div :class="dropdownClass" class="js-builds-dropdown-list scrollable-menu" v-html="buildsOrSpinner"></div>
+ <div
+ @click=''
+ :class="dropdownClass"
+ class="js-builds-dropdown-list scrollable-menu"
+ v-html="buildsOrSpinner"
+ >
+ </div>
</ul>
</div>
`,
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index e04a87a7327..bb6129158d9 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -324,7 +324,7 @@
&:focus {
cursor: text;
box-shadow: none;
- border-color: $border-color;
+ border-color: lighten($dropdown-input-focus-border, 20%);
color: $gray-darkest;
background-color: $gray-light;
}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index fee38b05023..d957ec64654 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -76,7 +76,7 @@
.filter-dropdown {
max-height: 215px;
- overflow-x: scroll;
+ overflow: auto;
}
.filter-dropdown-item {
@@ -86,7 +86,7 @@
text-align: left;
padding: 8px 16px;
text-overflow: ellipsis;
- overflow-y: hidden;
+ overflow: hidden;
border-radius: 0;
.fa {
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 349cd9c189e..07cb669a46e 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -5,7 +5,7 @@ $sidebar_collapsed_width: 62px;
$sidebar_width: 220px;
$gutter_collapsed_width: 62px;
$gutter_width: 290px;
-$gutter_inner_width: 258px;
+$gutter_inner_width: 250px;
$sidebar-transition-duration: .15s;
$sidebar-breakpoint: 1024px;
@@ -56,6 +56,7 @@ $black-transparent: rgba(0, 0, 0, 0.3);
$border-white-light: darken($white-light, $darken-border-factor);
$border-white-normal: darken($white-normal, $darken-border-factor);
+$border-gray-light: darken($gray-light, $darken-border-factor);
$border-gray-normal: darken($gray-normal, $darken-border-factor);
$border-gray-dark: darken($white-normal, $darken-border-factor);
@@ -85,6 +86,7 @@ $warning-message-border: #f0e2bb;
*/
$border-color: #e5e5e5;
$focus-border-color: #3aabf0;
+$sidebar-collapsed-icon-color: #999;
$well-expand-item: #e8f2f7;
$well-inner-border: #eef0f2;
$well-light-border: #f1f1f1;
@@ -280,6 +282,7 @@ $dropdown-hover-color: #3b86ff;
*/
$btn-active-gray: #ececec;
$btn-active-gray-light: e4e7ed;
+$btn-white-active: #848484;
/*
* Badges
@@ -433,6 +436,7 @@ $help-shortcut-header-color: #333;
*/
$issues-today-bg: #f3fff2;
$issues-today-border: #e1e8d5;
+$compare-display-color: #888;
/*
* jQuery UI
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 0ae5dc5c537..324c6cec96a 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -473,3 +473,102 @@
}
}
}
+
+.time_tracker {
+ padding-bottom: 0;
+ border-bottom: 0;
+
+
+ .sidebar-collapsed-icon {
+
+ > .stopwatch-svg {
+ display: inline-block;
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+ fill: $sidebar-collapsed-icon-color;
+ }
+
+ &:hover svg {
+ fill: $gl-text-color;
+ }
+ }
+
+ .help-button,
+ .close-help-button {
+ cursor: pointer;
+ }
+
+ .compare-meter {
+ &.within_estimate {
+ .meter-fill {
+ background: $gl-primary;
+ }
+ }
+
+ &.over_estimate {
+ .meter-fill {
+ background: $red-light;
+ }
+
+ .time-remaining,
+ .compare-value.spent {
+ color: $red-light;
+ }
+ }
+ }
+
+ .meter-container {
+ background: $border-gray-light;
+ border-radius: 3px;
+
+ .meter-fill {
+ max-width: 100%;
+ height: 5px;
+ border-radius: 3px;
+ background: $gl-primary;
+ }
+ }
+
+ .compare-display-container {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 5px;
+
+ .compare-display {
+ font-size: 13px;
+ color: $compare-display-color;
+
+ .compare-value {
+ color: $gl-text-color;
+ }
+ }
+ }
+
+ .time-tracking-help-state {
+ background: $white-light;
+ margin: 16px -20px 0;
+ padding: 16px 20px;
+ border-top: 1px solid $border-gray-light;
+ border-bottom: 1px solid $border-gray-light;
+
+ a:hover {
+ color: $btn-white-active;
+ }
+ }
+
+ .help-state-toggle-enter-active {
+ transition: all .8s ease;
+ }
+
+ .help-state-toggle-leave-active {
+ transition: all .5s ease;
+ }
+
+ .help-state-toggle-enter,
+ .help-state-toggle-leave-active {
+ opacity: 0;
+ }
+}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 8861315d776..8dff22e32bd 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -44,8 +44,8 @@
.pipeline-info,
.pipeline-commit,
- .pipeline-actions,
- .pipeline-stages {
+ .pipeline-stages,
+ .pipeline-actions {
width: 20%;
}
}
@@ -185,6 +185,7 @@
.stage-cell {
font-size: 0;
+ padding: 10px 4px;
> .stage-container > div > button > span > svg,
> .stage-container > button > svg {
@@ -202,8 +203,8 @@
position: relative;
margin-right: 6px;
- .tooltip {
- white-space: nowrap;
+ .tooltip-inner {
+ padding: 3px 4px;
}
&:not(:last-child) {
@@ -348,6 +349,7 @@
padding: $gl-padding;
white-space: nowrap;
transition: max-height 0.3s, padding 0.3s;
+ overflow: auto;
.stage-column-list,
.builds-container > ul {
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 9455ba3b98a..131cf23299a 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -9,17 +9,17 @@
.new_project,
.edit-project {
- fieldset {
-
- &.features {
+ .sharing-and-permissions {
+ .header {
+ padding-top: $gl-vert-padding;
+ }
- .label-light {
- margin-bottom: 0;
- }
+ .label-light {
+ margin-bottom: 0;
+ }
- .help-block {
- margin-top: 0;
- }
+ .help-block {
+ margin-top: 0;
}
.form-group {
@@ -905,10 +905,18 @@ pre.light-well {
}
}
-.project-feature-nested {
+.project-feature {
+ padding-top: 10px;
+
@media (min-width: $screen-sm-min) {
padding-left: 45px;
}
+
+ &.nested {
+ @media (min-width: $screen-sm-min) {
+ padding-left: 90px;
+ }
+ }
}
.project-repo-select {
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 5f13353baa1..6db4e1dc1bc 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -18,7 +18,7 @@ class AutocompleteController < ApplicationController
if params[:search].blank?
# Include current user if available to filter by "Me"
if params[:current_user].present? && current_user
- @users = [*@users, current_user]
+ @users = [current_user, *@users]
end
if params[:author_id].present?
diff --git a/app/controllers/concerns/cycle_analytics_params.rb b/app/controllers/concerns/cycle_analytics_params.rb
index 2aaf8f2b451..52e06f4945a 100644
--- a/app/controllers/concerns/cycle_analytics_params.rb
+++ b/app/controllers/concerns/cycle_analytics_params.rb
@@ -1,6 +1,10 @@
module CycleAnalyticsParams
extend ActiveSupport::Concern
+ def options(params)
+ @options ||= { from: start_date(params), current_user: current_user }
+ end
+
def start_date(params)
params[:start_date] == '30' ? 30.days.ago : 90.days.ago
end
diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb
index 7051652d109..7f506db583f 100644
--- a/app/controllers/dashboard/milestones_controller.rb
+++ b/app/controllers/dashboard/milestones_controller.rb
@@ -19,11 +19,11 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController
private
def milestones
- @milestones = GlobalMilestone.build_collection(@projects, params)
+ @milestones = DashboardMilestone.build_collection(@projects, params)
end
def milestone
- @milestone = GlobalMilestone.build(@projects, params[:title])
+ @milestone = DashboardMilestone.build(@projects, params[:title])
render_404 unless @milestone
end
end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 791ed88db30..bfc59bcc862 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -12,7 +12,6 @@ class Projects::CommitController < Projects::ApplicationController
before_action :authorize_read_pipeline!, only: [:pipelines]
before_action :commit
before_action :define_commit_vars, only: [:show, :diff_for_path, :pipelines]
- before_action :define_status_vars, only: [:show, :pipelines]
before_action :define_note_vars, only: [:show, :diff_for_path]
before_action :authorize_edit_tree!, only: [:revert, :cherry_pick]
@@ -106,10 +105,6 @@ class Projects::CommitController < Projects::ApplicationController
}
end
- def define_status_vars
- @ci_pipelines = project.pipelines.where(sha: commit.sha)
- end
-
def assign_change_commit_vars(mr_source_branch)
@commit = project.commit(params[:id])
@target_branch = params[:target_branch]
diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb
index 13b3eec761f..b69d46f2c41 100644
--- a/app/controllers/projects/cycle_analytics/events_controller.rb
+++ b/app/controllers/projects/cycle_analytics/events_controller.rb
@@ -9,56 +9,52 @@ module Projects
before_action :authorize_read_merge_request!, only: [:code, :review]
def issue
- render_events(events.issue_events)
+ render_events(cycle_analytics[:issue].events)
end
def plan
- render_events(events.plan_events)
+ render_events(cycle_analytics[:plan].events)
end
def code
- render_events(events.code_events)
+ render_events(cycle_analytics[:code].events)
end
def test
- options[:branch] = events_params[:branch_name]
+ options(events_params)[:branch] = events_params[:branch_name]
- render_events(events.test_events)
+ render_events(cycle_analytics[:test].events)
end
def review
- render_events(events.review_events)
+ render_events(cycle_analytics[:review].events)
end
def staging
- render_events(events.staging_events)
+ render_events(cycle_analytics[:staging].events)
end
def production
- render_events(events.production_events)
+ render_events(cycle_analytics[:production].events)
end
private
-
- def render_events(events_list)
+
+ def render_events(events)
respond_to do |format|
format.html
- format.json { render json: { events: events_list } }
+ format.json { render json: { events: events } }
end
end
- def events
- @events ||= Gitlab::CycleAnalytics::Events.new(project: project, options: options)
- end
-
- def options
- @options ||= { from: start_date(events_params), current_user: current_user }
+ def cycle_analytics
+ @cycle_analytics ||= ::CycleAnalytics.new(project, options(events_params))
end
def events_params
return {} unless params[:events].present?
- params[:events].slice(:start_date, :branch_name)
+ params[:events].permit(:start_date, :branch_name)
end
end
end
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index ac639ef015b..88ac3ad046b 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -6,11 +6,9 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
before_action :authorize_read_cycle_analytics!
def show
- @cycle_analytics = ::CycleAnalytics.new(@project, current_user, from: start_date(cycle_analytics_params))
+ @cycle_analytics = ::CycleAnalytics.new(@project, options(cycle_analytics_params))
- stats_values, cycle_analytics_json = generate_cycle_analytics_data
-
- @cycle_analytics_no_data = stats_values.blank?
+ @cycle_analytics_no_data = @cycle_analytics.no_stats?
respond_to do |format|
format.html
@@ -23,50 +21,14 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
def cycle_analytics_params
return {} unless params[:cycle_analytics].present?
- { start_date: params[:cycle_analytics][:start_date] }
+ params[:cycle_analytics].permit(:start_date)
end
- def generate_cycle_analytics_data
- stats_values = []
-
- cycle_analytics_view_data = [[:issue, "Issue", "Related Issues", "Time before an issue gets scheduled"],
- [:plan, "Plan", "Related Commits", "Time before an issue starts implementation"],
- [:code, "Code", "Related Merge Requests", "Time spent coding"],
- [:test, "Test", "Relative Builds Trigger by Commits", "The time taken to build and test the application"],
- [:review, "Review", "Relative Merged Requests", "The time taken to review the code"],
- [:staging, "Staging", "Relative Deployed Builds", "The time taken in staging"],
- [:production, "Production", "Related Issues", "The total time taken from idea to production"]]
-
- stats = cycle_analytics_view_data.reduce([]) do |stats, (stage_method, stage_text, stage_legend, stage_description)|
- value = @cycle_analytics.send(stage_method).presence
-
- stats_values << value.abs if value
-
- stats << {
- title: stage_text,
- description: stage_description,
- legend: stage_legend,
- value: value && !value.zero? ? distance_of_time_in_words(value) : nil
- }
-
- stats
- end
-
- issues = @cycle_analytics.summary.new_issues
- commits = @cycle_analytics.summary.commits
- deploys = @cycle_analytics.summary.deploys
-
- summary = [
- { title: "New Issue".pluralize(issues), value: issues },
- { title: "Commit".pluralize(commits), value: commits },
- { title: "Deploy".pluralize(deploys), value: deploys }
- ]
-
- cycle_analytics_hash = { summary: summary,
- stats: stats,
- permissions: @cycle_analytics.permissions(user: current_user)
+ def cycle_analytics_json
+ {
+ summary: @cycle_analytics.summary,
+ stats: @cycle_analytics.stats,
+ permissions: @cycle_analytics.permissions(user: current_user)
}
-
- [stats_values, cycle_analytics_hash]
end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 1c213983a5b..e5bb8b93e76 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -30,6 +30,15 @@ module IssuablesHelper
end
end
+ def serialize_issuable(issuable)
+ case issuable
+ when Issue
+ IssueSerializer.new.represent(issuable).to_json
+ when MergeRequest
+ MergeRequestSerializer.new.represent(issuable).to_json
+ end
+ end
+
def template_dropdown_tag(issuable, &block)
title = selected_template(issuable) || "Choose a template"
options = {
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index c6c63918fa5..eb98204285d 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -409,4 +409,15 @@ module ProjectsHelper
def project_issues(project)
IssuesFinder.new(current_user, project_id: project.id).execute
end
+
+ def visibility_select_options(project, selected_level)
+ levels_options_array = Gitlab::VisibilityLevel.values.map do |level|
+ [
+ visibility_level_label(level),
+ { data: { description: visibility_level_description(level, project) } },
+ level
+ ]
+ end
+ options_for_select(levels_options_array, selected_level)
+ end
end
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 74de25acf9d..c568cca9e5e 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -11,9 +11,10 @@ module TodosHelper
case todo.action
when Todo::ASSIGNED then 'assigned you'
when Todo::MENTIONED then 'mentioned you on'
- when Todo::BUILD_FAILED then 'The build failed for your'
+ when Todo::BUILD_FAILED then 'The build failed for'
when Todo::MARKED then 'added a todo for'
when Todo::APPROVAL_REQUIRED then 'set you as an approver for'
+ when Todo::UNMERGEABLE then 'Could not merge'
end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 5e63825bf99..3517969eabc 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -13,6 +13,7 @@ module Issuable
include StripAttribute
include Awardable
include Taskable
+ include TimeTrackable
included do
cache_markdown_field :title, pipeline: :single_line
diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb
new file mode 100644
index 00000000000..040e3a2884e
--- /dev/null
+++ b/app/models/concerns/time_trackable.rb
@@ -0,0 +1,72 @@
+# == TimeTrackable concern
+#
+# Contains functionality related to objects that support time tracking.
+#
+# Used by Issue and MergeRequest.
+#
+
+module TimeTrackable
+ extend ActiveSupport::Concern
+
+ included do
+ attr_reader :time_spent, :time_spent_user
+
+ alias_method :time_spent?, :time_spent
+
+ default_value_for :time_estimate, value: 0, allows_nil: false
+
+ validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false
+ validate :check_negative_time_spent
+
+ has_many :timelogs, as: :trackable, dependent: :destroy
+ end
+
+ def spend_time(options)
+ @time_spent = options[:duration]
+ @time_spent_user = options[:user]
+ @original_total_time_spent = nil
+
+ return if @time_spent == 0
+
+ if @time_spent == :reset
+ reset_spent_time
+ else
+ add_or_subtract_spent_time
+ end
+ end
+ alias_method :spend_time=, :spend_time
+
+ def total_time_spent
+ timelogs.sum(:time_spent)
+ end
+
+ def human_total_time_spent
+ Gitlab::TimeTrackingFormatter.output(total_time_spent)
+ end
+
+ def human_time_estimate
+ Gitlab::TimeTrackingFormatter.output(time_estimate)
+ end
+
+ private
+
+ def reset_spent_time
+ timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user)
+ end
+
+ def add_or_subtract_spent_time
+ timelogs.new(time_spent: time_spent, user: @time_spent_user)
+ end
+
+ def check_negative_time_spent
+ return if time_spent.nil? || time_spent == :reset
+
+ # we need to cache the total time spent so multiple calls to #valid?
+ # doesn't give a false error
+ @original_total_time_spent ||= total_time_spent
+
+ if time_spent < 0 && (time_spent.abs > @original_total_time_spent)
+ errors.add(:time_spent, 'Time to subtract exceeds the total time spent')
+ end
+ end
+end
diff --git a/app/models/cycle_analytics.rb b/app/models/cycle_analytics.rb
index ba4ee6fcf9d..d2e626c22e8 100644
--- a/app/models/cycle_analytics.rb
+++ b/app/models/cycle_analytics.rb
@@ -1,62 +1,38 @@
class CycleAnalytics
STAGES = %i[issue plan code test review staging production].freeze
- def initialize(project, current_user, from:)
+ def initialize(project, options)
@project = project
- @current_user = current_user
- @from = from
- @fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: from, branch: nil)
+ @options = options
end
def summary
- @summary ||= Summary.new(@project, @current_user, from: @from)
+ @summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(@project,
+ from: @options[:from],
+ current_user: @options[:current_user]).data
end
- def permissions(user:)
- Gitlab::CycleAnalytics::Permissions.get(user: user, project: @project)
+ def stats
+ @stats ||= stats_per_stage
end
- def issue
- @fetcher.calculate_metric(:issue,
- Issue.arel_table[:created_at],
- [Issue::Metrics.arel_table[:first_associated_with_milestone_at],
- Issue::Metrics.arel_table[:first_added_to_board_at]])
+ def no_stats?
+ stats.all? { |hash| hash[:value].nil? }
end
- def plan
- @fetcher.calculate_metric(:plan,
- [Issue::Metrics.arel_table[:first_associated_with_milestone_at],
- Issue::Metrics.arel_table[:first_added_to_board_at]],
- Issue::Metrics.arel_table[:first_mentioned_in_commit_at])
- end
-
- def code
- @fetcher.calculate_metric(:code,
- Issue::Metrics.arel_table[:first_mentioned_in_commit_at],
- MergeRequest.arel_table[:created_at])
- end
-
- def test
- @fetcher.calculate_metric(:test,
- MergeRequest::Metrics.arel_table[:latest_build_started_at],
- MergeRequest::Metrics.arel_table[:latest_build_finished_at])
+ def permissions(user:)
+ Gitlab::CycleAnalytics::Permissions.get(user: user, project: @project)
end
- def review
- @fetcher.calculate_metric(:review,
- MergeRequest.arel_table[:created_at],
- MergeRequest::Metrics.arel_table[:merged_at])
+ def [](stage_name)
+ Gitlab::CycleAnalytics::Stage[stage_name].new(project: @project, options: @options)
end
- def staging
- @fetcher.calculate_metric(:staging,
- MergeRequest::Metrics.arel_table[:merged_at],
- MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
- end
+ private
- def production
- @fetcher.calculate_metric(:production,
- Issue.arel_table[:created_at],
- MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
+ def stats_per_stage
+ STAGES.map do |stage_name|
+ self[stage_name].as_json
+ end
end
end
diff --git a/app/models/cycle_analytics/summary.rb b/app/models/cycle_analytics/summary.rb
index c9910d8cd09..e69de29bb2d 100644
--- a/app/models/cycle_analytics/summary.rb
+++ b/app/models/cycle_analytics/summary.rb
@@ -1,43 +0,0 @@
-class CycleAnalytics
- class Summary
- def initialize(project, current_user, from:)
- @project = project
- @current_user = current_user
- @from = from
- end
-
- def new_issues
- IssuesFinder.new(@current_user, project_id: @project.id).execute.created_after(@from).count
- end
-
- def commits
- ref = @project.default_branch.presence
- count_commits_for(ref)
- end
-
- def deploys
- @project.deployments.where("created_at > ?", @from).count
- end
-
- private
-
- # Don't use the `Gitlab::Git::Repository#log` method, because it enforces
- # a limit. Since we need a commit count, we _can't_ enforce a limit, so
- # the easiest way forward is to replicate the relevant portions of the
- # `log` function here.
- def count_commits_for(ref)
- return unless ref
-
- repository = @project.repository.raw_repository
- sha = @project.repository.commit(ref).sha
-
- cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{repository.path} log)
- cmd << '--format=%H'
- cmd << "--after=#{@from.iso8601}"
- cmd << sha
-
- raw_output = IO.popen(cmd) { |io| io.read }
- raw_output.lines.count
- end
- end
-end
diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb
new file mode 100644
index 00000000000..646c1e5ce1a
--- /dev/null
+++ b/app/models/dashboard_milestone.rb
@@ -0,0 +1,5 @@
+class DashboardMilestone < GlobalMilestone
+ def issues_finder_params
+ { authorized_only: true }
+ end
+end
diff --git a/app/models/generic_commit_status.rb b/app/models/generic_commit_status.rb
index fa54e3540d0..8867ba0d2ff 100644
--- a/app/models/generic_commit_status.rb
+++ b/app/models/generic_commit_status.rb
@@ -1,6 +1,10 @@
class GenericCommitStatus < CommitStatus
before_validation :set_default_values
+ validates :target_url, addressable_url: true,
+ length: { maximum: 255 },
+ allow_nil: true
+
# GitHub compatible API
alias_attribute :context, :name
@@ -12,4 +16,10 @@ class GenericCommitStatus < CommitStatus
def tags
[:external]
end
+
+ def detailed_status(current_user)
+ Gitlab::Ci::Status::External::Factory
+ .new(self, current_user)
+ .fabricate!
+ end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 10251302db8..cd5b345bae5 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -91,6 +91,10 @@ class MergeRequest < ActiveRecord::Base
around_transition do |merge_request, transition, block|
Gitlab::Timeless.timeless(merge_request, &block)
end
+
+ after_transition unchecked: :cannot_be_merged do |merge_request, transition|
+ TodoService.new.merge_request_became_unmergeable(merge_request)
+ end
end
validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_without_fork?]
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 2270ac75071..06abd406523 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -25,8 +25,9 @@ class ProjectStatistics < ActiveRecord::Base
self.commit_count = project.repository.commit_count
end
+ # Repository#size needs to be converted from MB to Byte.
def update_repository_size
- self.repository_size = project.repository.size
+ self.repository_size = project.repository.size * 1.megabyte
end
def update_lfs_objects_size
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
new file mode 100644
index 00000000000..f768c4e3da5
--- /dev/null
+++ b/app/models/timelog.rb
@@ -0,0 +1,6 @@
+class Timelog < ActiveRecord::Base
+ validates :time_spent, :user, presence: true
+
+ belongs_to :trackable, polymorphic: true
+ belongs_to :user
+end
diff --git a/app/models/todo.rb b/app/models/todo.rb
index f5ade1cc293..4c99aa0d3be 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -6,13 +6,15 @@ class Todo < ActiveRecord::Base
BUILD_FAILED = 3
MARKED = 4
APPROVAL_REQUIRED = 5 # This is an EE-only feature
+ UNMERGEABLE = 6
ACTION_NAMES = {
ASSIGNED => :assigned,
MENTIONED => :mentioned,
BUILD_FAILED => :build_failed,
MARKED => :marked,
- APPROVAL_REQUIRED => :approval_required
+ APPROVAL_REQUIRED => :approval_required,
+ UNMERGEABLE => :unmergeable
}
belongs_to :author, class_name: "User"
@@ -66,6 +68,10 @@ class Todo < ActiveRecord::Base
end
end
+ def unmergeable?
+ action == UNMERGEABLE
+ end
+
def build_failed?
action == BUILD_FAILED
end
diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb
new file mode 100644
index 00000000000..a559d0850c4
--- /dev/null
+++ b/app/serializers/analytics_stage_entity.rb
@@ -0,0 +1,10 @@
+class AnalyticsStageEntity < Grape::Entity
+ include EntityDateHelper
+
+ expose :title
+ expose :description
+
+ expose :median, as: :value do |stage|
+ stage.median && !stage.median.zero? ? distance_of_time_in_words(stage.median) : nil
+ end
+end
diff --git a/app/serializers/analytics_stage_serializer.rb b/app/serializers/analytics_stage_serializer.rb
new file mode 100644
index 00000000000..613cf6874d8
--- /dev/null
+++ b/app/serializers/analytics_stage_serializer.rb
@@ -0,0 +1,3 @@
+class AnalyticsStageSerializer < BaseSerializer
+ entity AnalyticsStageEntity
+end
diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb
new file mode 100644
index 00000000000..91803ec07f5
--- /dev/null
+++ b/app/serializers/analytics_summary_entity.rb
@@ -0,0 +1,7 @@
+class AnalyticsSummaryEntity < Grape::Entity
+ expose :value, safe: true
+
+ expose :title do |object|
+ object.title.pluralize(object.value)
+ end
+end
diff --git a/app/serializers/analytics_summary_serializer.rb b/app/serializers/analytics_summary_serializer.rb
new file mode 100644
index 00000000000..c87a24aa47c
--- /dev/null
+++ b/app/serializers/analytics_summary_serializer.rb
@@ -0,0 +1,3 @@
+class AnalyticsSummarySerializer < BaseSerializer
+ entity AnalyticsSummaryEntity
+end
diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb
index 17c9160cb19..29aecb50849 100644
--- a/app/serializers/issuable_entity.rb
+++ b/app/serializers/issuable_entity.rb
@@ -13,4 +13,8 @@ class IssuableEntity < Grape::Entity
expose :created_at
expose :updated_at
expose :deleted_at
+ expose :time_estimate
+ expose :total_time_spent
+ expose :human_time_estimate
+ expose :human_total_time_spent
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 4ce5fd993d9..5f3ced49665 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -36,6 +36,14 @@ class IssuableBaseService < BaseService
end
end
+ def create_time_estimate_note(issuable)
+ SystemNoteService.change_time_estimate(issuable, issuable.project, current_user)
+ end
+
+ def create_time_spent_note(issuable)
+ SystemNoteService.change_time_spent(issuable, issuable.project, current_user)
+ end
+
def filter_params(issuable)
ability_name = :"admin_#{issuable.to_ability_name}"
@@ -272,6 +280,14 @@ class IssuableBaseService < BaseService
create_task_status_note(issuable)
end
+ if issuable.previous_changes.include?('time_estimate')
+ create_time_estimate_note(issuable)
+ end
+
+ if issuable.time_spent?
+ create_time_spent_note(issuable)
+ end
+
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end
end
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
index d18844ac0e3..3566a8ba92f 100644
--- a/app/services/slash_commands/interpret_service.rb
+++ b/app/services/slash_commands/interpret_service.rb
@@ -255,6 +255,50 @@ module SlashCommands
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
end
+ desc 'Set time estimate'
+ params '<1w 3d 2h 14m>'
+ condition do
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :estimate do |raw_duration|
+ time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration)
+
+ if time_estimate
+ @updates[:time_estimate] = time_estimate
+ end
+ end
+
+ desc 'Add or substract spent time'
+ params '<1h 30m | -1h 30m>'
+ condition do
+ current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
+ end
+ command :spend do |raw_duration|
+ time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration)
+
+ if time_spent
+ @updates[:spend_time] = { duration: time_spent, user: current_user }
+ end
+ end
+
+ desc 'Remove time estimate'
+ condition do
+ issuable.persisted? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :remove_estimate do
+ @updates[:time_estimate] = 0
+ end
+
+ desc 'Remove spent time'
+ condition do
+ issuable.persisted? &&
+ current_user.can?(:"admin_#{issuable.to_ability_name}", project)
+ end
+ command :remove_time_spent do
+ @updates[:spend_time] = { duration: :reset, user: current_user }
+ end
+
# This is a dummy command, so that it appears in the autocomplete commands
desc 'CC'
params '@user'
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 7613ecd5021..5ca2551ee61 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -109,6 +109,57 @@ module SystemNoteService
create_note(noteable: noteable, project: project, author: author, note: body)
end
+ # Called when the estimated time of a Noteable is changed
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # time_estimate - Estimated time
+ #
+ # Example Note text:
+ #
+ # "Changed estimate of this issue to 3d 5h"
+ #
+ # Returns the created Note object
+
+ def change_time_estimate(noteable, project, author)
+ parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
+ body = if noteable.time_estimate == 0
+ "Removed time estimate on this #{noteable.human_class_name}"
+ else
+ "Changed time estimate of this #{noteable.human_class_name} to #{parsed_time}"
+ end
+
+ create_note(noteable: noteable, project: project, author: author, note: body)
+ end
+
+ # Called when the spent time of a Noteable is changed
+ #
+ # noteable - Noteable object
+ # project - Project owning noteable
+ # author - User performing the change
+ # time_spent - Spent time
+ #
+ # Example Note text:
+ #
+ # "Added 2h 30m of time spent on this issue"
+ #
+ # Returns the created Note object
+
+ def change_time_spent(noteable, project, author)
+ time_spent = noteable.time_spent
+
+ if time_spent == :reset
+ body = "Removed time spent on this #{noteable.human_class_name}"
+ else
+ parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
+ action = time_spent > 0 ? 'Added' : 'Subtracted'
+ body = "#{action} #{parsed_time} of time spent on this #{noteable.human_class_name}"
+ end
+
+ create_note(noteable: noteable, project: project, author: author, note: body)
+ end
+
# Called when the status of a Noteable is changed
#
# noteable - Noteable object
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index f8e6b2ef094..1bd6ce416ab 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -98,10 +98,12 @@ class TodoService
# When a build fails on the HEAD of a merge request we should:
#
- # * create a todo for that user to fix it
+ # * create a todo for author of MR to fix it
+ # * create a todo for merge_user to keep an eye on it
#
def merge_request_build_failed(merge_request)
- create_build_failed_todo(merge_request)
+ create_build_failed_todo(merge_request, merge_request.author)
+ create_build_failed_todo(merge_request, merge_request.merge_user) if merge_request.merge_when_build_succeeds?
end
# When a new commit is pushed to a merge request we should:
@@ -115,11 +117,21 @@ class TodoService
# When a build is retried to a merge request we should:
#
# * mark all pending todos related to the merge request for the author as done
+ # * mark all pending todos related to the merge request for the merge_user as done
#
def merge_request_build_retried(merge_request)
mark_pending_todos_as_done(merge_request, merge_request.author)
+ mark_pending_todos_as_done(merge_request, merge_request.merge_user) if merge_request.merge_when_build_succeeds?
end
-
+
+ # When a merge request could not be automatically merged due to its unmergeable state we should:
+ #
+ # * create a todo for a merge_user
+ #
+ def merge_request_became_unmergeable(merge_request)
+ create_unmergeable_todo(merge_request, merge_request.merge_user) if merge_request.merge_when_build_succeeds?
+ end
+
# When create a note we should:
#
# * mark all pending todos related to the noteable for the note author as done
@@ -236,10 +248,14 @@ class TodoService
create_todos(mentioned_users, attributes)
end
- def create_build_failed_todo(merge_request)
- author = merge_request.author
- attributes = attributes_for_todo(merge_request.project, merge_request, author, Todo::BUILD_FAILED)
- create_todos(author, attributes)
+ def create_build_failed_todo(merge_request, todo_author)
+ attributes = attributes_for_todo(merge_request.project, merge_request, todo_author, Todo::BUILD_FAILED)
+ create_todos(todo_author, attributes)
+ end
+
+ def create_unmergeable_todo(merge_request, merge_user)
+ attributes = attributes_for_todo(merge_request.project, merge_request, merge_user, Todo::UNMERGEABLE)
+ create_todos(merge_user, attributes)
end
def attributes_for_target(target)
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index 9849b31d7e2..9d7bcdb9d16 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -3,7 +3,7 @@
.todo-item.todo-block
.todo-title.title
- - unless todo.build_failed?
+ - unless todo.build_failed? || todo.unmergeable?
= todo_target_state_pill(todo)
%span.author-name
diff --git a/app/views/projects/_visibility_select.html.haml b/app/views/projects/_visibility_select.html.haml
new file mode 100644
index 00000000000..65fc0a36ca9
--- /dev/null
+++ b/app/views/projects/_visibility_select.html.haml
@@ -0,0 +1,7 @@
+- if can_change_visibility_level?(@project, current_user)
+ = form.select(model_method, visibility_select_options(@project, selected_level), {}, class: 'form-control visibility-select')
+- else
+ .info.js-locked{ data: { help_block: visibility_level_description(@project.visibility_level, @project) } }
+ = visibility_level_icon(@project.visibility_level)
+ %strong
+ = visibility_level_label(@project.visibility_level)
diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml
index 13ab2253733..8aed88da38b 100644
--- a/app/views/projects/commit/_ci_menu.html.haml
+++ b/app/views/projects/commit/_ci_menu.html.haml
@@ -7,4 +7,4 @@
= nav_link(path: 'commit#pipelines') do
= link_to pipelines_namespace_project_commit_path(@project.namespace, @project, @commit.id) do
Pipelines
- %span.badge= @ci_pipelines.count
+ %span.badge= @commit.pipelines.size
diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml
index 8233e26e4e7..00e7cdd1729 100644
--- a/app/views/projects/commit/pipelines.html.haml
+++ b/app/views/projects/commit/pipelines.html.haml
@@ -3,4 +3,4 @@
= render "commit_box"
= render "ci_menu"
-= render "pipelines_list", pipelines: @ci_pipelines
+= render "pipelines_list", pipelines: @commit.pipelines.order(id: :desc)
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 38e7fc4279c..1d7fdf68cb3 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -21,76 +21,68 @@
.form-group
= f.label :default_branch, "Default Branch", class: 'label-light'
= f.select(:default_branch, @project.repository.branch_names, {}, {class: 'select2 select-wide'})
- .form-group.project-visibility-level-holder
- = f.label :visibility_level, class: 'label-light' do
- Visibility Level
- = link_to "(?)", help_page_path("public_access/public_access")
- - if can_change_visibility_level?(@project, current_user)
- = render('shared/visibility_radios', model_method: :visibility_level, form: f, selected_level: @project.visibility_level, form_model: @project)
- - else
- .info
- = visibility_level_icon(@project.visibility_level)
- %strong
- = visibility_level_label(@project.visibility_level)
- .light= visibility_level_description(@project.visibility_level, @project)
-
- .form-group
- = render 'shared/allow_request_access', form: f
-
.form-group
= f.label :tag_list, "Tags", class: 'label-light'
= f.text_field :tag_list, value: @project.tag_list.to_s, maxlength: 2000, class: "form-control"
%p.help-block Separate tags with commas.
%hr
- %fieldset.features.append-bottom-0
+ %fieldset.append-bottom-0
%h5.prepend-top-0
- Feature Visibility
-
- = f.fields_for :project_feature do |feature_fields|
- .form_group.prepend-top-20
- .row
- .col-md-9
- = feature_fields.label :repository_access_level, "Repository", class: 'label-light'
- %span.help-block Push files to be stored in this project
- .col-md-3.js-repo-access-level
- = project_feature_access_select(:repository_access_level)
-
- .col-sm-12
- .row
- .col-md-9.project-feature-nested
- = feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light'
- %span.help-block Submit changes to be merged upstream
- .col-md-3
- = project_feature_access_select(:merge_requests_access_level)
+ Sharing &amp; Permissions
+ .form_group.prepend-top-20.sharing-and-permissions
+ .row.js-visibility-select
+ .col-md-9
+ %label.label-light
+ = label_tag :project_visibility, 'Project Visibility', class: 'label-light'
+ = link_to "(?)", help_page_path("public_access/public_access")
+ %span.help-block
+ .col-md-3.visibility-select-container
+ = render('projects/visibility_select', model_method: :visibility_level, form: f, selected_level: @project.visibility_level)
+ = f.fields_for :project_feature do |feature_fields|
+ %fieldset.features
+ .row
+ .col-md-9.project-feature
+ = feature_fields.label :repository_access_level, "Repository", class: 'label-light'
+ %span.help-block View and edit files in this project
+ .col-md-3.js-repo-access-level
+ = project_feature_access_select(:repository_access_level)
- .row
- .col-md-9.project-feature-nested
- = feature_fields.label :builds_access_level, "Builds", class: 'label-light'
- %span.help-block Submit, test and deploy your changes before merge
- .col-md-3
- = project_feature_access_select(:builds_access_level)
+ .row
+ .col-md-9.project-feature.nested
+ = feature_fields.label :merge_requests_access_level, "Merge requests", class: 'label-light'
+ %span.help-block Submit changes to be merged upstream
+ .col-md-3
+ = project_feature_access_select(:merge_requests_access_level)
- .row
- .col-md-9
- = feature_fields.label :snippets_access_level, "Snippets", class: 'label-light'
- %span.help-block Share code pastes with others out of Git repository
- .col-md-3
- = project_feature_access_select(:snippets_access_level)
+ .row
+ .col-md-9.project-feature.nested
+ = feature_fields.label :builds_access_level, "Builds", class: 'label-light'
+ %span.help-block Submit, test and deploy your changes before merge
+ .col-md-3
+ = project_feature_access_select(:builds_access_level)
- .row
- .col-md-9
- = feature_fields.label :issues_access_level, "Issues", class: 'label-light'
- %span.help-block Lightweight issue tracking system for this project
- .col-md-3
- = project_feature_access_select(:issues_access_level)
+ .row
+ .col-md-9.project-feature
+ = feature_fields.label :snippets_access_level, "Snippets", class: 'label-light'
+ %span.help-block Share code pastes with others out of Git repository
+ .col-md-3
+ = project_feature_access_select(:snippets_access_level)
- .row
- .col-md-9
- = feature_fields.label :wiki_access_level, "Wiki", class: 'label-light'
- %span.help-block Pages for project documentation
- .col-md-3
- = project_feature_access_select(:wiki_access_level)
+ .row
+ .col-md-9.project-feature
+ = feature_fields.label :issues_access_level, "Issues", class: 'label-light'
+ %span.help-block Lightweight issue tracking system for this project
+ .col-md-3
+ = project_feature_access_select(:issues_access_level)
+ .row
+ .col-md-9.project-feature
+ = feature_fields.label :wiki_access_level, "Wiki", class: 'label-light'
+ %span.help-block Pages for project documentation
+ .col-md-3
+ = project_feature_access_select(:wiki_access_level)
+ .form-group
+ = render 'shared/allow_request_access', form: f
- if Gitlab.config.lfs.enabled && current_user.admin?
.row
.col-md-9
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 43141971231..9fa00811af0 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -2,6 +2,8 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description
- page_card_attributes @issue.card_attributes
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/vue_resource.js')
.clearfix.detail-page-header
.issuable-header
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
index 0e14b9d4bf4..2a7cd3a19d0 100644
--- a/app/views/projects/merge_requests/_show.html.haml
+++ b/app/views/projects/merge_requests/_show.html.haml
@@ -3,6 +3,7 @@
- page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes
- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/vue_resource.js')
= page_specific_javascript_tag('diff_notes/diff_notes_bundle.js')
.merge-request{ 'data-url' => merge_request_path(@merge_request) }
diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
index b8b87dcdcaf..ebef2157d34 100644
--- a/app/views/projects/merge_requests/conflicts.html.haml
+++ b/app/views/projects/merge_requests/conflicts.html.haml
@@ -1,5 +1,6 @@
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/vue_resource.js')
= page_specific_javascript_tag('merge_conflicts/merge_conflicts_bundle.js')
= page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/show/mr_title"
diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml
index 63b797cd391..c1e576b42fc 100644
--- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml
@@ -8,8 +8,8 @@
by entering
%code /&lt;command_trigger_word&gt; help
- - unless enabled
+ - unless enabled || @service.template?
= render 'projects/services/mattermost_slash_commands/detailed_help', subject: @service
-- if enabled
+- if enabled && !@service.template?
= render 'projects/services/mattermost_slash_commands/installation_info', subject: @service
diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml
index 6d7c2defe2b..04b9100acc6 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -1,4 +1,5 @@
-- run_actions_text = "Perform common operations on this project: #{@project.name_with_namespace}"
+- pretty_name = defined?(@project) ? @project.name_with_namespace : "namespace / path"
+- run_actions_text = "Perform common operations on this project: #{pretty_name}"
.well
This service allows GitLab users to perform common operations on this
@@ -9,85 +10,86 @@
%code /&lt;command&gt; help
%br
%br
- To setup this service:
- %ul.list-unstyled
- %li
- 1.
- = link_to 'Add a slash command', 'https://my.slack.com/services/new/slash-commands'
- in your Slack team with these options:
+ - unless @service.template?
+ To setup this service:
+ %ul.list-unstyled
+ %li
+ 1.
+ = link_to 'Add a slash command', 'https://my.slack.com/services/new/slash-commands'
+ in your Slack team with these options:
- %hr
+ %hr
- .help-form
- .form-group
- = label_tag nil, 'Command', class: 'col-sm-2 col-xs-12 control-label'
- .col-sm-10.col-xs-12.text-block
- %p Fill in the word that works best for your team.
- %p
- Suggestions:
- %code= 'gitlab'
- %code= @project.path # Path contains no spaces, but dashes
- %code= @project.path_with_namespace
+ .help-form
+ .form-group
+ = label_tag nil, 'Command', class: 'col-sm-2 col-xs-12 control-label'
+ .col-sm-10.col-xs-12.text-block
+ %p Fill in the word that works best for your team.
+ %p
+ Suggestions:
+ %code= 'gitlab'
+ %code= @project.path # Path contains no spaces, but dashes
+ %code= @project.path_with_namespace
- .form-group
- = label_tag :url, 'URL', class: 'col-sm-2 col-xs-12 control-label'
- .col-sm-10.col-xs-12.input-group
- = text_field_tag :url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly'
- .input-group-btn
- = clipboard_button(clipboard_target: '#url')
+ .form-group
+ = label_tag :url, 'URL', class: 'col-sm-2 col-xs-12 control-label'
+ .col-sm-10.col-xs-12.input-group
+ = text_field_tag :url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly'
+ .input-group-btn
+ = clipboard_button(clipboard_target: '#url')
- .form-group
- = label_tag nil, 'Method', class: 'col-sm-2 col-xs-12 control-label'
- .col-sm-10.col-xs-12.text-block POST
+ .form-group
+ = label_tag nil, 'Method', class: 'col-sm-2 col-xs-12 control-label'
+ .col-sm-10.col-xs-12.text-block POST
- .form-group
- = label_tag :customize_name, 'Customize name', class: 'col-sm-2 col-xs-12 control-label'
- .col-sm-10.col-xs-12.input-group
- = text_field_tag :customize_name, 'GitLab', class: 'form-control input-sm', readonly: 'readonly'
- .input-group-btn
- = clipboard_button(clipboard_target: '#customize_name')
+ .form-group
+ = label_tag :customize_name, 'Customize name', class: 'col-sm-2 col-xs-12 control-label'
+ .col-sm-10.col-xs-12.input-group
+ = text_field_tag :customize_name, 'GitLab', class: 'form-control input-sm', readonly: 'readonly'
+ .input-group-btn
+ = clipboard_button(clipboard_target: '#customize_name')
- .form-group
- = label_tag nil, 'Customize icon', class: 'col-sm-2 col-xs-12 control-label'
- .col-sm-10.col-xs-12.text-block
- = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36)
- = link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank')
+ .form-group
+ = label_tag nil, 'Customize icon', class: 'col-sm-2 col-xs-12 control-label'
+ .col-sm-10.col-xs-12.text-block
+ = image_tag(asset_url('slash-command-logo.png'), width: 36, height: 36)
+ = link_to('Download image', asset_url('gitlab_logo.png'), class: 'btn btn-sm', target: '_blank')
- .form-group
- = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-xs-12 control-label'
- .col-sm-10.col-xs-12.text-block Show this command in the autocomplete list
+ .form-group
+ = label_tag nil, 'Autocomplete', class: 'col-sm-2 col-xs-12 control-label'
+ .col-sm-10.col-xs-12.text-block Show this command in the autocomplete list
- .form-group
- = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-xs-12 control-label'
- .col-sm-10.col-xs-12.input-group
- = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
- .input-group-btn
- = clipboard_button(clipboard_target: '#autocomplete_description')
+ .form-group
+ = label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-xs-12 control-label'
+ .col-sm-10.col-xs-12.input-group
+ = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
+ .input-group-btn
+ = clipboard_button(clipboard_target: '#autocomplete_description')
- .form-group
- = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-xs-12 control-label'
- .col-sm-10.col-xs-12.input-group
- = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly'
- .input-group-btn
- = clipboard_button(clipboard_target: '#autocomplete_usage_hint')
+ .form-group
+ = label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-xs-12 control-label'
+ .col-sm-10.col-xs-12.input-group
+ = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly'
+ .input-group-btn
+ = clipboard_button(clipboard_target: '#autocomplete_usage_hint')
- .form-group
- = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-xs-12 control-label'
- .col-sm-10.col-xs-12.input-group
- = text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control input-sm', readonly: 'readonly'
- .input-group-btn
- = clipboard_button(clipboard_target: '#descriptive_label')
+ .form-group
+ = label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-xs-12 control-label'
+ .col-sm-10.col-xs-12.input-group
+ = text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control input-sm', readonly: 'readonly'
+ .input-group-btn
+ = clipboard_button(clipboard_target: '#descriptive_label')
- %hr
+ %hr
- %ul.list-unstyled
- %li
- 2. Paste the
- %strong Token
- into the field below
- %li
- 3. Select the
- %strong Active
- checkbox, press
- %strong Save changes
- and start using GitLab inside Slack!
+ %ul.list-unstyled
+ %li
+ 2. Paste the
+ %strong Token
+ into the field below
+ %li
+ 3. Select the
+ %strong Active
+ checkbox, press
+ %strong Save changes
+ and start using GitLab inside Slack!
diff --git a/app/views/shared/icons/_icon_stopwatch.svg b/app/views/shared/icons/_icon_stopwatch.svg
new file mode 100644
index 00000000000..f20de04538e
--- /dev/null
+++ b/app/views/shared/icons/_icon_stopwatch.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14" enable-background="new 0 0 12 14"><path d="m11.5 2.4l-1.3-1.1-1 1.1 1.4 1.1.9-1.1"/><path d="m6.8 2v-.5h.5v-1.5h-2.6v1.5h.5v.5c-2.9.4-5.2 2.9-5.2 6 0 3.3 2.7 6 6 6s6-2.7 6-6c0-3-2.3-5.6-5.2-6m-.8 10.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5"/><path d="m6.2 8.9h-.5c-.1 0-.2-.1-.2-.2v-3.5c0-.1.1-.2.2-.2h.5c.1 0 .2.1.2.2v3.5c0 .1-.1.2-.2.2"/></svg> \ No newline at end of file
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index a02b815e3cd..ec9bcaf63dd 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -1,5 +1,7 @@
- todo = issuable_todo(issuable)
-%aside.right-sidebar{ class: sidebar_gutter_collapsed_class }
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('issuable/issuable_bundle.js')
+%aside.right-sidebar{ class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header
@@ -72,7 +74,13 @@
.selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil
= dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }})
-
+ - if issuable.has_attribute?(:time_estimate)
+ #issuable-time-tracker.block
+ %issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'stopwatch-svg' => custom_icon('icon_stopwatch'), 'docs-url' => help_page_path('workflow/time_tracking.md') }
+ // Fallback while content is loading
+ .title.hide-collapsed
+ Time tracking
+ = icon('spinner spin')
- if issuable.has_attribute?(:due_date)
.block.due_date
.sidebar-collapsed-icon
@@ -162,6 +170,8 @@
= clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left")
:javascript
+ gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}');
+ new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}");
new MilestoneSelect('{"namespace":"#{@project.namespace.path}","path":"#{@project.path}"}');
new LabelsSelect();
new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index 51c195ffbcd..28935c8b598 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -16,7 +16,7 @@
= link_to_gfm issuable.title, [project.namespace.becomes(Namespace), project, issuable], title: issuable.title
.issuable-detail
= link_to [project.namespace.becomes(Namespace), project, issuable] do
- %span.issuable-number >= issuable.to_reference
+ %span.issuable-number= issuable.to_reference
- issuable.labels.each do |label|
= link_to polymorphic_path(base_url_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do
diff --git a/app/views/shared/milestones/_issuables.html.haml b/app/views/shared/milestones/_issuables.html.haml
index c8fd45c4319..31eb07ca666 100644
--- a/app/views/shared/milestones/_issuables.html.haml
+++ b/app/views/shared/milestones/_issuables.html.haml
@@ -8,8 +8,7 @@
= title
- if show_counter
.right
- = issuables.size
- .pull-right= number_with_delimiter(issuables.size)
+ = number_with_delimiter(issuables.size)
- class_prefix = dom_class(issuables).pluralize
%ul{ class: "well-list #{class_prefix}-sortable-list", id: "#{class_prefix}-list-#{id}", "data-state" => id }
diff --git a/changelogs/unreleased/23524-notify-automerge-user-of-failed-build.yml b/changelogs/unreleased/23524-notify-automerge-user-of-failed-build.yml
new file mode 100644
index 00000000000..268be6b9b83
--- /dev/null
+++ b/changelogs/unreleased/23524-notify-automerge-user-of-failed-build.yml
@@ -0,0 +1,4 @@
+---
+title: Create a TODO for user who set auto-merge when a build fails, merge conflict occurs
+merge_request: 8056
+author: twonegatives
diff --git a/changelogs/unreleased/24032-changed-visibility-level-to-public-but-project-is-not-public.yml b/changelogs/unreleased/24032-changed-visibility-level-to-public-but-project-is-not-public.yml
new file mode 100644
index 00000000000..875106d7dd5
--- /dev/null
+++ b/changelogs/unreleased/24032-changed-visibility-level-to-public-but-project-is-not-public.yml
@@ -0,0 +1,4 @@
+---
+title: Updated project visibility settings UX
+merge_request: 7645
+author:
diff --git a/changelogs/unreleased/26117-sort-pipeline-for-commit.yml b/changelogs/unreleased/26117-sort-pipeline-for-commit.yml
new file mode 100644
index 00000000000..b2f5294d380
--- /dev/null
+++ b/changelogs/unreleased/26117-sort-pipeline-for-commit.yml
@@ -0,0 +1,4 @@
+---
+title: Add sorting pipeline for a commit
+merge_request: 8319
+author: Takuya Noguchi
diff --git a/changelogs/unreleased/26667-pipeline-width-for-huge-pipeline.yml b/changelogs/unreleased/26667-pipeline-width-for-huge-pipeline.yml
new file mode 100644
index 00000000000..08dcc5c3e8c
--- /dev/null
+++ b/changelogs/unreleased/26667-pipeline-width-for-huge-pipeline.yml
@@ -0,0 +1,4 @@
+---
+title: Fixes big pipeline and small pipeline width problems and tooltips text being outside the tooltip
+merge_request: 8593
+author:
diff --git a/changelogs/unreleased/26773-fix-project-statistics-repository-size.yml b/changelogs/unreleased/26773-fix-project-statistics-repository-size.yml
new file mode 100644
index 00000000000..8ce9bbcb3a9
--- /dev/null
+++ b/changelogs/unreleased/26773-fix-project-statistics-repository-size.yml
@@ -0,0 +1,4 @@
+---
+title: Adjust ProjectStatistic#repository_size with values saved as MB
+merge_request: 8616
+author:
diff --git a/changelogs/unreleased/8623-correct-robots-txt.yml b/changelogs/unreleased/8623-correct-robots-txt.yml
new file mode 100644
index 00000000000..00ed80511cc
--- /dev/null
+++ b/changelogs/unreleased/8623-correct-robots-txt.yml
@@ -0,0 +1,4 @@
+---
+title: "Correct User-agent placement in robots.txt"
+merge_request: 8623
+author: Eric Sabelhaus
diff --git a/changelogs/unreleased/fix-external-status-badge-links.yml b/changelogs/unreleased/fix-external-status-badge-links.yml
new file mode 100644
index 00000000000..2287a9b76c4
--- /dev/null
+++ b/changelogs/unreleased/fix-external-status-badge-links.yml
@@ -0,0 +1,4 @@
+---
+title: Link external build badge to its target URL
+merge_request: 8611
+author:
diff --git a/changelogs/unreleased/input-button-hover.yml b/changelogs/unreleased/input-button-hover.yml
new file mode 100644
index 00000000000..cbb35adb769
--- /dev/null
+++ b/changelogs/unreleased/input-button-hover.yml
@@ -0,0 +1,4 @@
+---
+title: Add hover state to MR comment reply button
+merge_request:
+author:
diff --git a/changelogs/unreleased/speed-up-dashboard-milestone-index.yml b/changelogs/unreleased/speed-up-dashboard-milestone-index.yml
new file mode 100644
index 00000000000..ba4ff931ea8
--- /dev/null
+++ b/changelogs/unreleased/speed-up-dashboard-milestone-index.yml
@@ -0,0 +1,5 @@
+---
+title: Speed up dashboard milestone index by scoping IssuesFinder to user authorized
+ projects
+merge_request: 8524
+author:
diff --git a/changelogs/unreleased/time-tracking-api.yml b/changelogs/unreleased/time-tracking-api.yml
new file mode 100644
index 00000000000..b58d73bef81
--- /dev/null
+++ b/changelogs/unreleased/time-tracking-api.yml
@@ -0,0 +1,4 @@
+---
+title: Add new endpoints for Time Tracking.
+merge_request: 8483
+author:
diff --git a/config/application.rb b/config/application.rb
index 8ce549cebf6..f00e58a36ca 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -88,6 +88,7 @@ module Gitlab
config.assets.precompile << "print.css"
config.assets.precompile << "notify.css"
config.assets.precompile << "mailers/*.css"
+ config.assets.precompile << "lib/vue_resource.js"
config.assets.precompile << "katex.css"
config.assets.precompile << "katex.js"
config.assets.precompile << "xterm/xterm.css"
@@ -98,6 +99,7 @@ module Gitlab
config.assets.precompile << "protected_branches/protected_branches_bundle.js"
config.assets.precompile << "diff_notes/diff_notes_bundle.js"
config.assets.precompile << "merge_request_widget/ci_bundle.js"
+ config.assets.precompile << "issuable/issuable_bundle.js"
config.assets.precompile << "boards/boards_bundle.js"
config.assets.precompile << "cycle_analytics/cycle_analytics_bundle.js"
config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js"
diff --git a/db/migrate/20161223034433_add_time_estimate_to_issuables.rb b/db/migrate/20161223034433_add_time_estimate_to_issuables.rb
new file mode 100644
index 00000000000..8d89756a9bc
--- /dev/null
+++ b/db/migrate/20161223034433_add_time_estimate_to_issuables.rb
@@ -0,0 +1,30 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class AddTimeEstimateToIssuables < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ # When a migration requires downtime you **must** uncomment the following
+ # constant and define a short and easy to understand explanation as to why the
+ # migration requires downtime.
+ # DOWNTIME_REASON = ''
+
+ # When using the methods "add_concurrent_index" or "add_column_with_default"
+ # you must disable the use of transactions as these methods can not run in an
+ # existing transaction. When using "add_concurrent_index" make sure that this
+ # method is the _only_ method called in the migration, any other changes
+ # should go in a separate migration. This ensures that upon failure _only_ the
+ # index creation fails and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ # disable_ddl_transaction!
+
+ def change
+ add_column :issues, :time_estimate, :integer
+ add_column :merge_requests, :time_estimate, :integer
+ end
+end
diff --git a/db/migrate/20161223034646_create_timelogs.rb b/db/migrate/20161223034646_create_timelogs.rb
new file mode 100644
index 00000000000..d3353a67eec
--- /dev/null
+++ b/db/migrate/20161223034646_create_timelogs.rb
@@ -0,0 +1,38 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class CreateTimelogs < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ # Set this constant to true if this migration requires downtime.
+ DOWNTIME = false
+
+ # When a migration requires downtime you **must** uncomment the following
+ # constant and define a short and easy to understand explanation as to why the
+ # migration requires downtime.
+ # DOWNTIME_REASON = ''
+
+ # When using the methods "add_concurrent_index" or "add_column_with_default"
+ # you must disable the use of transactions as these methods can not run in an
+ # existing transaction. When using "add_concurrent_index" make sure that this
+ # method is the _only_ method called in the migration, any other changes
+ # should go in a separate migration. This ensures that upon failure _only_ the
+ # index creation fails and can be retried or reverted easily.
+ #
+ # To disable transactions uncomment the following line and remove these
+ # comments:
+ # disable_ddl_transaction!
+
+ def change
+ create_table :timelogs do |t|
+ t.integer :time_spent, null: false
+ t.references :trackable, polymorphic: true
+ t.references :user
+
+ t.timestamps null: false
+ end
+
+ add_index :timelogs, [:trackable_type, :trackable_id]
+ add_index :timelogs, :user_id
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c58a886b0fa..7815392c1c3 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -506,6 +506,7 @@ ActiveRecord::Schema.define(version: 20170106172224) do
t.integer "lock_version"
t.text "title_html"
t.text "description_html"
+ t.integer "time_estimate"
end
add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
@@ -685,6 +686,7 @@ ActiveRecord::Schema.define(version: 20170106172224) do
t.integer "lock_version"
t.text "title_html"
t.text "description_html"
+ t.integer "time_estimate"
end
add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree
@@ -1128,6 +1130,18 @@ ActiveRecord::Schema.define(version: 20170106172224) do
add_index "tags", ["name"], name: "index_tags_on_name", unique: true, using: :btree
+ create_table "timelogs", force: :cascade do |t|
+ t.integer "time_spent", null: false
+ t.integer "trackable_id"
+ t.string "trackable_type"
+ t.integer "user_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "timelogs", ["trackable_type", "trackable_id"], name: "index_timelogs_on_trackable_type_and_trackable_id", using: :btree
+ add_index "timelogs", ["user_id"], name: "index_timelogs_on_user_id", using: :btree
+
create_table "todos", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "project_id", null: false
diff --git a/doc/api/issues.md b/doc/api/issues.md
index dd84afd7c73..b276d1ad918 100644
--- a/doc/api/issues.md
+++ b/doc/api/issues.md
@@ -712,6 +712,146 @@ Example response:
}
```
+## Set a time estimate for an issue
+
+Sets an estimated time of work for this issue.
+
+```
+POST /projects/:id/issues/:issue_id/time_estimate
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `issue_id` | integer | yes | The ID of a project's issue |
+| `duration` | string | yes | The duration in human format. e.g: 3h30m |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/time_estimate?duration=3h30m
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": "3h 30m",
+ "human_total_time_spent": null,
+ "time_estimate": 12600,
+ "total_time_spent": 0
+}
+```
+
+## Reset the time estimate for an issue
+
+Resets the estimated time for this issue to 0 seconds.
+
+```
+POST /projects/:id/issues/:issue_id/reset_time_estimate
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `issue_id` | integer | yes | The ID of a project's issue |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/reset_time_estimate
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": null,
+ "human_total_time_spent": null,
+ "time_estimate": 0,
+ "total_time_spent": 0
+}
+```
+
+## Add spent time for an issue
+
+Adds spent time for this issue
+
+```
+POST /projects/:id/issues/:issue_id/add_spent_time
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `issue_id` | integer | yes | The ID of a project's issue |
+| `duration` | string | yes | The duration in human format. e.g: 3h30m |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/add_spent_time?duration=1h
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": null,
+ "human_total_time_spent": "1h",
+ "time_estimate": 0,
+ "total_time_spent": 3600
+}
+```
+
+## Reset spent time for an issue
+
+Resets the total spent time for this issue to 0 seconds.
+
+```
+POST /projects/:id/issues/:issue_id/reset_spent_time
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `issue_id` | integer | yes | The ID of a project's issue |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/reset_spent_time
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": null,
+ "human_total_time_spent": null,
+ "time_estimate": 0,
+ "total_time_spent": 0
+}
+```
+
+## Get time tracking stats
+
+```
+GET /projects/:id/issues/:issue_id/time_stats
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `issue_id` | integer | yes | The ID of a project's issue |
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/time_stats
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": "2h",
+ "human_total_time_spent": "1h",
+ "time_estimate": 7200,
+ "total_time_spent": 3600
+}
+```
+
## Comments on issues
Comments are done via the [notes](notes.md) resource.
diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md
index 662cc9da733..7b005591545 100644
--- a/doc/api/merge_requests.md
+++ b/doc/api/merge_requests.md
@@ -1018,3 +1018,142 @@ Example response:
}]
}
```
+## Set a time estimate for a merge request
+
+Sets an estimated time of work for this merge request.
+
+```
+POST /projects/:id/merge_requests/:merge_request_id/time_estimate
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `merge_request_id` | integer | yes | The ID of a project's merge request |
+| `duration` | string | yes | The duration in human format. e.g: 3h30m |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/time_estimate?duration=3h30m
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": "3h 30m",
+ "human_total_time_spent": null,
+ "time_estimate": 12600,
+ "total_time_spent": 0
+}
+```
+
+## Reset the time estimate for a merge request
+
+Resets the estimated time for this merge request to 0 seconds.
+
+```
+POST /projects/:id/merge_requests/:merge_request_id/reset_time_estimate
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `merge_request_id` | integer | yes | The ID of a project's merge_request |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/reset_time_estimate
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": null,
+ "human_total_time_spent": null,
+ "time_estimate": 0,
+ "total_time_spent": 0
+}
+```
+
+## Add spent time for a merge request
+
+Adds spent time for this merge request
+
+```
+POST /projects/:id/merge_requests/:merge_request_id/add_spent_time
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `merge_request_id` | integer | yes | The ID of a project's merge request |
+| `duration` | string | yes | The duration in human format. e.g: 3h30m |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/add_spent_time?duration=1h
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": null,
+ "human_total_time_spent": "1h",
+ "time_estimate": 0,
+ "total_time_spent": 3600
+}
+```
+
+## Reset spent time for a merge request
+
+Resets the total spent time for this merge request to 0 seconds.
+
+```
+POST /projects/:id/merge_requests/:merge_request_id/reset_spent_time
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `merge_request_id` | integer | yes | The ID of a project's merge_request |
+
+```bash
+curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/reset_spent_time
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": null,
+ "human_total_time_spent": null,
+ "time_estimate": 0,
+ "total_time_spent": 0
+}
+```
+
+## Get time tracking stats
+
+```
+GET /projects/:id/merge_requests/:merge_request_id/time_stats
+```
+
+| Attribute | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `id` | integer | yes | The ID of a project |
+| `merge_request_id` | integer | yes | The ID of a project's merge request |
+
+```bash
+curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/merge_requests/93/time_stats
+```
+
+Example response:
+
+```json
+{
+ "human_time_estimate": "2h",
+ "human_total_time_spent": "1h",
+ "time_estimate": 7200,
+ "total_time_spent": 3600
+}
+```
diff --git a/doc/development/frontend.md b/doc/development/frontend.md
index 9e782ab977f..f79bd23dc90 100644
--- a/doc/development/frontend.md
+++ b/doc/development/frontend.md
@@ -297,16 +297,74 @@ For our currently-supported browsers, see our [requirements][requirements].
## Gotchas
-### Phantom.JS (used by Teaspoon & Rspec) chokes, returning vague JavaScript errors
-
-If you see very generic JavaScript errors (e.g. `jQuery is undefined`) being thrown in tests, but
-can't reproduce them manually, you may have included `ES6`-style JavaScript in files that don't
-have the `.js.es6` file extension. Either use ES5-friendly JavaScript or rename the file you're
-working in (`git mv <file.js> <file.js.es6>`).
-
-Similar errors will be thrown if you're using
-any of the [array methods introduced in ES6](http://www.2ality.com/2014/05/es6-array-methods.html)
-whether or not you've updated the file extension.
+### Spec errors due to use of ES6 features in `.js` files
+
+If you see very generic JavaScript errors (e.g. `jQuery is undefined`) being
+thrown in Teaspoon, Spinach, or Rspec tests but can't reproduce them manually,
+you may have included `ES6`-style JavaScript in files that don't have the
+`.js.es6` file extension. Either use ES5-friendly JavaScript or rename the file
+you're working in (`git mv <file.js> <file.js.es6>`).
+
+### Spec errors due to use of unsupported JavaScript
+
+Similar errors will be thrown if you're using JavaScript features not yet
+supported by our test runner's version of webkit, whether or not you've updated
+the file extension. Examples of unsupported JavaScript features are:
+
+- Array.from
+- Array.find
+- Array.first
+- Object.assign
+- Async functions
+- Generators
+- Array destructuring
+- For Of
+- Symbol/Symbol.iterator
+- Spread
+
+Until these are polyfilled or transpiled appropriately, they should not be used.
+Please update this list with additional unsupported features or when any of
+these are made usable.
+
+### Spec errors due to JavaScript not enabled
+
+If, as a result of a change you've made, a feature now depends on JavaScript to
+run correctly, you need to make sure a JavaScript web driver is enabled when
+specs are run. If you don't you'll see vague error messages from the spec
+runner, and an explosion of vague console errors in the HTML snapshot.
+
+To enable a JavaScript driver in an `rspec` test, add `js: true` to the
+individual spec or the context block containing multiple specs that need
+JavaScript enabled:
+
+```ruby
+
+# For one spec
+it 'presents information about abuse report', js: true do
+ # assertions...
+end
+
+describe "Admin::AbuseReports", js: true do
+ it 'presents information about abuse report' do
+ # assertions...
+ end
+ it 'shows buttons for adding to abuse report' do
+ # assertions...
+ end
+end
+```
+In Spinach, the JavaScript driver is enabled differently. In the `*.feature`
+file for the failing spec, add the `@javascript` flag above the Scenario:
+```
+@javascript
+Scenario: Developer can approve merge request
+ Given I am a "Shop" developer
+ And I visit project "Shop" merge requests page
+ And merge request 'Bug NS-04' must be approved
+ And I click link "Bug NS-04"
+ When I click link "Approve"
+ Then I should see approved merge request "Bug NS-04"
+```
diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md
index 4c933cef9b7..98a680d0dbe 100644
--- a/doc/integration/omniauth.md
+++ b/doc/integration/omniauth.md
@@ -41,6 +41,9 @@ that are in common for all providers that we need to consider.
- `allow_single_sign_on` allows you to specify the providers you want to allow to
automatically create an account. It defaults to `false`. If `false` users must
be created manually or they will not be able to sign in via OmniAuth.
+- `auto_link_ldap_user` can be used if you have [LDAP / ActiveDirectory](ldap.md)
+ integration enabled. It defaults to false. When enabled, users automatically
+ created through OmniAuth will be linked to their LDAP entry as well.
- `block_auto_created_users` defaults to `true`. If `true` auto created users will
be blocked by default and will have to be unblocked by an administrator before
they are able to sign in.
@@ -52,6 +55,10 @@ SAML, Shibboleth, Crowd or Google, or set it to `false` otherwise any user on
the Internet will be able to successfully sign in to your GitLab without
administrative approval.
+>**Note:**
+`auto_link_ldap_user` requires the `uid` of the user to be the same in both LDAP
+and the OmniAuth provider.
+
To change these settings:
* **For omnibus package**
@@ -72,6 +79,7 @@ To change these settings:
# using an array, e.g. ["saml", "twitter"], or as true/false to allow all providers or none.
# User accounts will be created automatically when authentication was successful.
gitlab_rails['omniauth_allow_single_sign_on'] = ['saml', 'twitter']
+ gitlab_rails['omniauth_auto_link_ldap_user'] = true
gitlab_rails['omniauth_block_auto_created_users'] = true
```
@@ -99,6 +107,8 @@ To change these settings:
# User accounts will be created automatically when authentication was successful.
allow_single_sign_on: ["saml", "twitter"]
+ auto_link_ldap_user: true
+
# Locks down those users until they have been cleared by the admin (default: true).
block_auto_created_users: true
```
diff --git a/doc/project_services/project_services.md b/doc/project_services/project_services.md
index 0f398874b8f..547d855d777 100644
--- a/doc/project_services/project_services.md
+++ b/doc/project_services/project_services.md
@@ -46,6 +46,7 @@ further configuration instructions and details. Contributions are welcome.
| [Mattermost slash commands](mattermost_slash_commands.md) | Mattermost chat and ChatOps slash commands |
| [Mattermost Notifications](mattermost.md) | Receive event notifications in Mattermost |
| [Slack Notifications](slack.md) | Receive event notifications in Slack |
+| [Slack slash commands](slack_slash_commands.md) | Slack chat and ChatOps slash commands |
| PivotalTracker | Project Management Software (Source Commits Endpoint) |
| Pushover | Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop |
| [Redmine](redmine.md) | Redmine issue tracker |
diff --git a/doc/user/project/slash_commands.md b/doc/user/project/slash_commands.md
index 0a66e9a3e15..a6546cffce2 100644
--- a/doc/user/project/slash_commands.md
+++ b/doc/user/project/slash_commands.md
@@ -30,3 +30,7 @@ do.
| <code>/due &lt;in 2 days &#124; this Friday &#124; December 31st&gt;</code> | Set due date |
| `/remove_due_date` | Remove due date |
| `/wip` | Toggle the Work In Progress status |
+| <code>/estimate &lt;1w 3d 2h 14m&gt;</code> | Set time estimate |
+| `/remove_estimate` | Remove estimated time |
+| <code>/spend &lt;1h 30m &#124; -1h 5m&gt;</code> | Add or substract spent time |
+| `/remove_time_spent` | Remove time spent |
diff --git a/doc/workflow/README.md b/doc/workflow/README.md
index 59a806de210..b317bd79ded 100644
--- a/doc/workflow/README.md
+++ b/doc/workflow/README.md
@@ -19,6 +19,7 @@
- [Slash commands](../user/project/slash_commands.md)
- [Sharing a project with a group](share_with_group.md)
- [Share projects with other groups](share_projects_with_other_groups.md)
+- [Time tracking](time_tracking.md)
- [Web Editor](../user/project/repository/web_editor.md)
- [Releases](releases.md)
- [Milestones](milestones.md)
diff --git a/doc/workflow/time-tracking/time-tracking-example.png b/doc/workflow/time-tracking/time-tracking-example.png
new file mode 100644
index 00000000000..bbcabb602d6
--- /dev/null
+++ b/doc/workflow/time-tracking/time-tracking-example.png
Binary files differ
diff --git a/doc/workflow/time-tracking/time-tracking-sidebar.png b/doc/workflow/time-tracking/time-tracking-sidebar.png
new file mode 100644
index 00000000000..d1ff5571f95
--- /dev/null
+++ b/doc/workflow/time-tracking/time-tracking-sidebar.png
Binary files differ
diff --git a/doc/workflow/time_tracking.md b/doc/workflow/time_tracking.md
new file mode 100644
index 00000000000..de12994c516
--- /dev/null
+++ b/doc/workflow/time_tracking.md
@@ -0,0 +1,73 @@
+# Time Tracking
+
+> Introduced in GitLab 8.14.
+
+Time Tracking allows you to track estimates and time spent on issues and merge
+requests within GitLab.
+
+## Overview
+
+Time Tracking lets you:
+* record the time spent working on an issue or a merge request,
+* add an estimate of the amount of time needed to complete an issue or a merge
+request.
+
+You don't have to indicate an estimate to enter the time spent, and vice versa.
+
+Data about time tracking is shown on the issue/merge request sidebar, as shown
+below.
+
+![Time tracking in the sidebar](time-tracking/time-tracking-sidebar.png)
+
+## How to enter data
+
+Time Tracking uses two [slash commands] that GitLab introduced with this new
+feature: `/spend` and `/estimate`.
+
+Slash commands can be used in the body of an issue or a merge request, but also
+in a comment in both an issue or a merge request.
+
+Below is an example of how you can use those new slash commands inside a comment.
+
+![Time tracking example in a comment](time-tracking/time-tracking-example.png)
+
+Adding time entries (time spent or estimates) is limited to project members.
+
+### Estimates
+
+To enter an estimate, write `/estimate`, followed by the time. For example, if
+you need to enter an estimate of 3 days, 5 hours and 10 minutes, you would write
+`/estimate 3d 5h 10m`.
+
+Every time you enter a new time estimate, any previous time estimates will be
+overridden by this new value. There should only be one valid estimate in an
+issue or a merge request.
+
+To remove an estimation entirely, use `/remove_estimation`.
+
+### Time spent
+
+To enter a time spent, use `/spend 3d 5h 10m`.
+
+Every new time spent entry will be added to the current total time spent for the
+issue or the merge request.
+
+You can remove time by entering a negative amount: `/spend -3d` will remove 3
+days from the total time spent. You can't go below 0 minutes of time spent,
+so GitLab will automatically reset the time spent if you remove a larger amount
+of time compared to the time that was entered already.
+
+To remove all the time spent at once, use `/remove_time_spent`.
+
+## Configuration
+
+The following time units are available:
+* weeks (w)
+* days (d)
+* hours (h)
+* minutes (m)
+
+Default conversion rates are 1w = 5d and 1d = 8h.
+
+[landing]: https://about.gitlab.com/features/time-tracking
+[slash-commands]: ../user/project/slash_commands.md
diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb
index b2bec369e0f..33a1c88e33c 100644
--- a/features/steps/dashboard/dashboard.rb
+++ b/features/steps/dashboard/dashboard.rb
@@ -35,7 +35,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps
step 'I have group with projects' do
@group = create(:group)
- @project = create(:project, namespace: @group)
+ @project = create(:empty_project, namespace: @group)
@event = create(:closed_issue_event, project: @project)
@project.team << [current_user, :master]
@@ -54,8 +54,8 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps
end
step 'group has a projects that does not belongs to me' do
- @forbidden_project1 = create(:project, group: @group)
- @forbidden_project2 = create(:project, group: @group)
+ @forbidden_project1 = create(:empty_project, group: @group)
+ @forbidden_project2 = create(:empty_project, group: @group)
end
step 'I should see 1 project at group list' do
diff --git a/features/steps/dashboard/issues.rb b/features/steps/dashboard/issues.rb
index 39c65bb6cde..4e15d79ae74 100644
--- a/features/steps/dashboard/issues.rb
+++ b/features/steps/dashboard/issues.rb
@@ -79,13 +79,13 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps
def project
@project ||= begin
- project = create :project
+ project = create(:empty_project)
project.team << [current_user, :master]
project
end
end
def public_project
- @public_project ||= create :project, :public
+ @public_project ||= create(:empty_project, :public)
end
end
diff --git a/features/steps/dashboard/merge_requests.rb b/features/steps/dashboard/merge_requests.rb
index 6777101fb15..909ffec3646 100644
--- a/features/steps/dashboard/merge_requests.rb
+++ b/features/steps/dashboard/merge_requests.rb
@@ -105,14 +105,14 @@ class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps
def project
@project ||= begin
- project = create :project
+ project = create(:project, :repository)
project.team << [current_user, :master]
project
end
end
def public_project
- @public_project ||= create :project, :public
+ @public_project ||= create(:project, :public, :repository)
end
def forked_project
diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb
index c1d1eca9116..70e23098dde 100644
--- a/features/steps/group/milestones.rb
+++ b/features/steps/group/milestones.rb
@@ -104,7 +104,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
group = owned_group
%w(gitlabhq gitlab-ci cookbook-gitlab).each do |path|
- project = create :project, path: path, group: group
+ project = create(:empty_project, path: path, group: group)
milestone = create :milestone, title: "Version 7.2", project: project
create(:label, project: project, title: 'bug')
diff --git a/features/steps/groups.rb b/features/steps/groups.rb
index 0c88838767c..4dc87dc4d9c 100644
--- a/features/steps/groups.rb
+++ b/features/steps/groups.rb
@@ -109,7 +109,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
step 'Group "Owned" has archived project' do
group = Group.find_by(name: 'Owned')
- @archived_project = create(:project, namespace: group, archived: true, path: "archived-project")
+ @archived_project = create(:empty_project, :archived, namespace: group, path: "archived-project")
end
step 'I should see "archived" label' do
diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb
index ea480d2ad68..24cfbaad7fe 100644
--- a/features/steps/profile/profile.rb
+++ b/features/steps/profile/profile.rb
@@ -162,7 +162,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
step 'I have group with projects' do
@group = create(:group)
@group.add_owner(current_user)
- @project = create(:project, namespace: @group)
+ @project = create(:project, :repository, namespace: @group)
@event = create(:closed_issue_event, project: @project)
@project.team << [current_user, :master]
diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb
index 83b9ef48392..edf78f62f9a 100644
--- a/features/steps/project/deploy_keys.rb
+++ b/features/steps/project/deploy_keys.rb
@@ -46,11 +46,11 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end
step 'other projects have deploy keys' do
- @second_project = create(:project, namespace: create(:group))
+ @second_project = create(:empty_project, namespace: create(:group))
@second_project.team << [current_user, :master]
create(:deploy_keys_project, project: @second_project)
- @third_project = create(:project, namespace: create(:group))
+ @third_project = create(:empty_project, namespace: create(:group))
@third_project.team << [current_user, :master]
create(:deploy_keys_project, project: @third_project, deploy_key: @second_project.deploy_keys.first)
end
diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb
index 70dbd030003..9a6c04fba7a 100644
--- a/features/steps/project/fork.rb
+++ b/features/steps/project/fork.rb
@@ -9,7 +9,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end
step 'I am a member of project "Shop"' do
- @project = create(:project, name: "Shop")
+ @project = create(:project, :repository, name: "Shop")
@project.team << [@user, :reporter]
end
@@ -18,7 +18,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end
step 'I already have a project named "Shop" in my namespace' do
- @my_project = create(:project, name: "Shop", namespace: current_user.namespace)
+ @my_project = create(:project, :repository, name: "Shop", namespace: current_user.namespace)
end
step 'I should see a "Name has already been taken" warning' do
diff --git a/features/steps/project/forked_merge_requests.rb b/features/steps/project/forked_merge_requests.rb
index 6c14d835004..c0827ff8fc7 100644
--- a/features/steps/project/forked_merge_requests.rb
+++ b/features/steps/project/forked_merge_requests.rb
@@ -7,7 +7,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
step 'I am a member of project "Shop"' do
@project = Project.find_by(name: "Shop")
- @project ||= create(:project, name: "Shop")
+ @project ||= create(:project, :repository, name: "Shop")
@project.team << [@user, :reporter]
end
diff --git a/features/steps/project/merge_requests/acceptance.rb b/features/steps/project/merge_requests/acceptance.rb
index 4fda0731e2f..0a3f4649870 100644
--- a/features/steps/project/merge_requests/acceptance.rb
+++ b/features/steps/project/merge_requests/acceptance.rb
@@ -28,7 +28,7 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps
step 'There is an open Merge Request' do
@user = create(:user)
- @project = create(:project, :public)
+ @project = create(:project, :public, :repository)
@project_member = create(:project_member, :developer, user: @user, project: @project)
@merge_request = create(:merge_request, :with_diffs, :simple, source_project: @project)
end
diff --git a/features/steps/project/merge_requests/revert.rb b/features/steps/project/merge_requests/revert.rb
index efbc4831ce1..3cc4fe9dafb 100644
--- a/features/steps/project/merge_requests/revert.rb
+++ b/features/steps/project/merge_requests/revert.rb
@@ -35,7 +35,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps
step 'There is an open Merge Request' do
@user = create(:user)
- @project = create(:project, :public)
+ @project = create(:project, :public, :repository)
@project_member = create(:project_member, :developer, user: @user, project: @project)
@merge_request = create(:merge_request, :with_diffs, :simple, source_project: @project)
end
diff --git a/features/steps/project/redirects.rb b/features/steps/project/redirects.rb
index 1ffd5cb9de5..92936f27c20 100644
--- a/features/steps/project/redirects.rb
+++ b/features/steps/project/redirects.rb
@@ -4,11 +4,11 @@ class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps
include SharedProject
step 'public project "Community"' do
- create :project, :public, name: 'Community'
+ create(:empty_project, :public, name: 'Community')
end
step 'private project "Enterprise"' do
- create :project, name: 'Enterprise'
+ create(:empty_project, :private, name: 'Enterprise')
end
step 'I visit project "Community" page' do
diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb
index 1cc9e37b075..f18adcadcce 100644
--- a/features/steps/project/source/browse_files.rb
+++ b/features/steps/project/source/browse_files.rb
@@ -6,7 +6,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps
include RepoHelpers
step "I don't have write access" do
- @project = create(:project, name: "Other Project", path: "other-project")
+ @project = create(:project, :repository, name: "Other Project", path: "other-project")
@project.team << [@user, :reporter]
visit namespace_project_tree_path(@project.namespace, @project, root_ref)
end
diff --git a/features/steps/project/source/markdown_render.rb b/features/steps/project/source/markdown_render.rb
index dee6a8a5558..9183de76881 100644
--- a/features/steps/project/source/markdown_render.rb
+++ b/features/steps/project/source/markdown_render.rb
@@ -8,7 +8,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps
step 'I own project "Delta"' do
@project = Project.find_by(name: "Delta")
- @project ||= create(:project, name: "Delta", namespace: @user.namespace)
+ @project ||= create(:project, :repository, name: "Delta", namespace: @user.namespace)
@project.team << [@user, :master]
end
diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb
index c89f587f14d..6986c7ede56 100644
--- a/features/steps/project/team_management.rb
+++ b/features/steps/project/team_management.rb
@@ -137,7 +137,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps
step 'I share project with group "OpenSource"' do
project = Project.find_by(name: 'Shop')
os_group = create(:group, name: 'OpenSource')
- create(:project, group: os_group)
+ create(:empty_project, group: os_group)
@os_user1 = create(:user)
@os_user2 = create(:user)
os_group.add_owner(@os_user1)
diff --git a/features/steps/shared/admin.rb b/features/steps/shared/admin.rb
index fbaa408226e..ac0a1764147 100644
--- a/features/steps/shared/admin.rb
+++ b/features/steps/shared/admin.rb
@@ -2,7 +2,7 @@ module SharedAdmin
include Spinach::DSL
step 'there are projects in system' do
- 2.times { create(:project) }
+ 2.times { create(:project, :repository) }
end
step 'system has users' do
diff --git a/features/steps/shared/group.rb b/features/steps/shared/group.rb
index fe6736dacd4..de119f2c6c0 100644
--- a/features/steps/shared/group.rb
+++ b/features/steps/shared/group.rb
@@ -40,7 +40,7 @@ module SharedGroup
user = User.find_by(name: username) || create(:user, name: username)
group = Group.find_by(name: groupname) || create(:group, name: groupname)
group.add_user(user, role)
- project ||= create(:project, namespace: group, path: "project#{@project_count}")
+ project ||= create(:project, :repository, namespace: group, path: "project#{@project_count}")
create(:closed_issue_event, project: project)
project.team << [user, :master]
@project_count += 1
diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb
index b51152c79c6..7a6707a7dfb 100644
--- a/features/steps/shared/project.rb
+++ b/features/steps/shared/project.rb
@@ -3,19 +3,19 @@ module SharedProject
# Create a project without caring about what it's called
step "I own a project" do
- @project = create(:project, namespace: @user.namespace)
+ @project = create(:project, :repository, namespace: @user.namespace)
@project.team << [@user, :master]
end
step "project exists in some group namespace" do
@group = create(:group, name: 'some group')
- @project = create(:project, namespace: @group, public_builds: false)
+ @project = create(:project, :repository, namespace: @group, public_builds: false)
end
# Create a specific project called "Shop"
step 'I own project "Shop"' do
@project = Project.find_by(name: "Shop")
- @project ||= create(:project, name: "Shop", namespace: @user.namespace)
+ @project ||= create(:project, :repository, name: "Shop", namespace: @user.namespace)
@project.team << [@user, :master]
end
@@ -40,7 +40,7 @@ module SharedProject
# Create another specific project called "Forum"
step 'I own project "Forum"' do
@project = Project.find_by(name: "Forum")
- @project ||= create(:project, name: "Forum", namespace: @user.namespace, path: 'forum_project')
+ @project ||= create(:project, :repository, name: "Forum", namespace: @user.namespace, path: 'forum_project')
@project.build_project_feature
@project.project_feature.save
@project.team << [@user, :master]
@@ -97,7 +97,7 @@ module SharedProject
step 'I should see project settings' do
expect(current_path).to eq edit_namespace_project_path(@project.namespace, @project)
expect(page).to have_content("Project name")
- expect(page).to have_content("Feature Visibility")
+ expect(page).to have_content("Sharing & Permissions")
end
def current_project
@@ -121,7 +121,7 @@ module SharedProject
# ----------------------------------------
step 'archived project "Archive"' do
- create :project, :public, archived: true, name: 'Archive'
+ create(:project, :archived, :public, :repository, name: 'Archive')
end
step 'I should not see project "Archive"' do
@@ -144,7 +144,7 @@ module SharedProject
# ----------------------------------------
step 'private project "Enterprise"' do
- create :project, name: 'Enterprise'
+ create(:project, :private, :repository, name: 'Enterprise')
end
step 'I should see project "Enterprise"' do
@@ -156,7 +156,7 @@ module SharedProject
end
step 'internal project "Internal"' do
- create :project, :internal, name: 'Internal'
+ create(:project, :internal, :repository, name: 'Internal')
end
step 'I should see project "Internal"' do
@@ -168,7 +168,7 @@ module SharedProject
end
step 'public project "Community"' do
- create :project, :public, name: 'Community'
+ create(:project, :public, :repository, name: 'Community')
end
step 'I should see project "Community"' do
diff --git a/features/steps/user.rb b/features/steps/user.rb
index 59385a6ab59..271c9b097d4 100644
--- a/features/steps/user.rb
+++ b/features/steps/user.rb
@@ -38,6 +38,6 @@ class Spinach::Features::User < Spinach::FeatureSteps
end
def contributed_project
- @contributed_project ||= create(:project, :public)
+ @contributed_project ||= create(:empty_project, :public)
end
end
diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb
index 4bbdf06a49c..b6e6820c3f4 100644
--- a/lib/api/commit_statuses.rb
+++ b/lib/api/commit_statuses.rb
@@ -78,6 +78,8 @@ module API
description: params[:description]
)
+ render_validation_error!(status) if status.invalid?
+
begin
case params[:state]
when 'pending'
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 885ce7d44bc..9f59939e9ae 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -268,6 +268,13 @@ module API
end
end
+ class IssuableTimeStats < Grape::Entity
+ expose :time_estimate
+ expose :total_time_spent
+ expose :human_time_estimate
+ expose :human_total_time_spent
+ end
+
class ExternalIssue < Grape::Entity
expose :title
expose :id
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index eb2d370c68e..6b81fbf294e 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -86,6 +86,10 @@ module API
IssuesFinder.new(current_user, project_id: user_project.id).find(id)
end
+ def find_project_merge_request(id)
+ MergeRequestsFinder.new(current_user, project_id: user_project.id).find(id)
+ end
+
def authenticate!
unauthorized! unless current_user
end
diff --git a/lib/api/issues.rb b/lib/api/issues.rb
index 161269cbd41..fe016c1ec0a 100644
--- a/lib/api/issues.rb
+++ b/lib/api/issues.rb
@@ -89,6 +89,8 @@ module API
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects do
+ include TimeTrackingEndpoints
+
desc 'Get a list of project issues' do
success Entities::Issue
end
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index 5d1fe22f2df..e77af4b7a0d 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -10,6 +10,8 @@ module API
requires :id, type: String, desc: 'The ID of a project'
end
resource :projects do
+ include TimeTrackingEndpoints
+
helpers do
def handle_merge_request_errors!(errors)
if errors[:project_access].any?
@@ -96,7 +98,7 @@ module API
requires :merge_request_id, type: Integer, desc: 'The ID of a merge request'
end
delete ":id/merge_requests/:merge_request_id" do
- merge_request = user_project.merge_requests.find_by(id: params[:merge_request_id])
+ merge_request = find_project_merge_request(params[:merge_request_id])
authorize!(:destroy_merge_request, merge_request)
merge_request.destroy
@@ -116,7 +118,7 @@ module API
success Entities::MergeRequest
end
get path do
- merge_request = user_project.merge_requests.find(params[:merge_request_id])
+ merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :read_merge_request, merge_request
present merge_request, with: Entities::MergeRequest, current_user: current_user, project: user_project
end
@@ -125,7 +127,7 @@ module API
success Entities::RepoCommit
end
get "#{path}/commits" do
- merge_request = user_project.merge_requests.find(params[:merge_request_id])
+ merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :read_merge_request, merge_request
present merge_request.commits, with: Entities::RepoCommit
end
@@ -134,7 +136,7 @@ module API
success Entities::MergeRequestChanges
end
get "#{path}/changes" do
- merge_request = user_project.merge_requests.find(params[:merge_request_id])
+ merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :read_merge_request, merge_request
present merge_request, with: Entities::MergeRequestChanges, current_user: current_user
end
@@ -153,7 +155,7 @@ module API
:remove_source_branch
end
put path do
- merge_request = user_project.merge_requests.find(params.delete(:merge_request_id))
+ merge_request = find_project_merge_request(params.delete(:merge_request_id))
authorize! :update_merge_request, merge_request
mr_params = declared_params(include_missing: false)
@@ -180,7 +182,7 @@ module API
optional :sha, type: String, desc: 'When present, must have the HEAD SHA of the source branch'
end
put "#{path}/merge" do
- merge_request = user_project.merge_requests.find(params[:merge_request_id])
+ merge_request = find_project_merge_request(params[:merge_request_id])
# Merge request can not be merged
# because user dont have permissions to push into target branch
@@ -216,7 +218,7 @@ module API
success Entities::MergeRequest
end
post "#{path}/cancel_merge_when_build_succeeds" do
- merge_request = user_project.merge_requests.find(params[:merge_request_id])
+ merge_request = find_project_merge_request(params[:merge_request_id])
unauthorized! unless merge_request.can_cancel_merge_when_build_succeeds?(current_user)
@@ -233,7 +235,7 @@ module API
use :pagination
end
get "#{path}/comments" do
- merge_request = user_project.merge_requests.find(params[:merge_request_id])
+ merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :read_merge_request, merge_request
@@ -248,7 +250,7 @@ module API
requires :note, type: String, desc: 'The text of the comment'
end
post "#{path}/comments" do
- merge_request = user_project.merge_requests.find(params[:merge_request_id])
+ merge_request = find_project_merge_request(params[:merge_request_id])
authorize! :create_note, merge_request
opts = {
@@ -273,7 +275,7 @@ module API
use :pagination
end
get "#{path}/closes_issues" do
- merge_request = user_project.merge_requests.find(params[:merge_request_id])
+ merge_request = find_project_merge_request(params[:merge_request_id])
issues = ::Kaminari.paginate_array(merge_request.closes_issues(current_user))
present paginate(issues), with: issue_entity(user_project), current_user: current_user
end
diff --git a/lib/api/time_tracking_endpoints.rb b/lib/api/time_tracking_endpoints.rb
new file mode 100644
index 00000000000..85b5f7d98b8
--- /dev/null
+++ b/lib/api/time_tracking_endpoints.rb
@@ -0,0 +1,114 @@
+module API
+ module TimeTrackingEndpoints
+ extend ActiveSupport::Concern
+
+ included do
+ helpers do
+ def issuable_name
+ declared_params.has_key?(:issue_id) ? 'issue' : 'merge_request'
+ end
+
+ def issuable_key
+ "#{issuable_name}_id".to_sym
+ end
+
+ def update_issuable_key
+ "update_#{issuable_name}".to_sym
+ end
+
+ def read_issuable_key
+ "read_#{issuable_name}".to_sym
+ end
+
+ def load_issuable
+ @issuable ||= begin
+ case issuable_name
+ when 'issue'
+ find_project_issue(params.delete(issuable_key))
+ when 'merge_request'
+ find_project_merge_request(params.delete(issuable_key))
+ end
+ end
+ end
+
+ def update_issuable(attrs)
+ custom_params = declared_params(include_missing: false)
+ custom_params.merge!(attrs)
+
+ issuable = update_service.new(user_project, current_user, custom_params).execute(load_issuable)
+ if issuable.valid?
+ present issuable, with: Entities::IssuableTimeStats
+ else
+ render_validation_error!(issuable)
+ end
+ end
+
+ def update_service
+ issuable_name == 'issue' ? ::Issues::UpdateService : ::MergeRequests::UpdateService
+ end
+ end
+
+ issuable_name = name.end_with?('Issues') ? 'issue' : 'merge_request'
+ issuable_collection_name = issuable_name.pluralize
+ issuable_key = "#{issuable_name}_id".to_sym
+
+ desc "Set a time estimate for a project #{issuable_name}"
+ params do
+ requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}"
+ requires :duration, type: String, desc: 'The duration to be parsed'
+ end
+ post ":id/#{issuable_collection_name}/:#{issuable_key}/time_estimate" do
+ authorize! update_issuable_key, load_issuable
+
+ status :ok
+ update_issuable(time_estimate: Gitlab::TimeTrackingFormatter.parse(params.delete(:duration)))
+ end
+
+ desc "Reset the time estimate for a project #{issuable_name}"
+ params do
+ requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}"
+ end
+ post ":id/#{issuable_collection_name}/:#{issuable_key}/reset_time_estimate" do
+ authorize! update_issuable_key, load_issuable
+
+ status :ok
+ update_issuable(time_estimate: 0)
+ end
+
+ desc "Add spent time for a project #{issuable_name}"
+ params do
+ requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}"
+ requires :duration, type: String, desc: 'The duration to be parsed'
+ end
+ post ":id/#{issuable_collection_name}/:#{issuable_key}/add_spent_time" do
+ authorize! update_issuable_key, load_issuable
+
+ update_issuable(spend_time: {
+ duration: Gitlab::TimeTrackingFormatter.parse(params.delete(:duration)),
+ user: current_user
+ })
+ end
+
+ desc "Reset spent time for a project #{issuable_name}"
+ params do
+ requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}"
+ end
+ post ":id/#{issuable_collection_name}/:#{issuable_key}/reset_spent_time" do
+ authorize! update_issuable_key, load_issuable
+
+ status :ok
+ update_issuable(spend_time: { duration: :reset, user: current_user })
+ end
+
+ desc "Show time stats for a project #{issuable_name}"
+ params do
+ requires issuable_key, type: Integer, desc: "The ID of a project #{issuable_name}"
+ end
+ get ":id/#{issuable_collection_name}/:#{issuable_key}/time_stats" do
+ authorize! read_issuable_key, load_issuable
+
+ present load_issuable, with: Entities::IssuableTimeStats
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb
index 84bfeac8041..ab7af1cad21 100644
--- a/lib/banzai/filter/reference_filter.rb
+++ b/lib/banzai/filter/reference_filter.rb
@@ -20,10 +20,10 @@ module Banzai
# Examples:
#
# data_attribute(project: 1, issue: 2)
- # # => "data-reference-filter=\"SomeReferenceFilter\" data-project=\"1\" data-issue=\"2\""
+ # # => "data-reference-type=\"SomeReferenceFilter\" data-project=\"1\" data-issue=\"2\""
#
# data_attribute(project: 3, merge_request: 4)
- # # => "data-reference-filter=\"SomeReferenceFilter\" data-project=\"3\" data-merge-request=\"4\""
+ # # => "data-reference-type=\"SomeReferenceFilter\" data-project=\"3\" data-merge-request=\"4\""
#
# Returns a String
def data_attribute(attributes = {})
@@ -31,7 +31,9 @@ module Banzai
attributes[:reference_type] ||= self.class.reference_type
attributes.delete(:original) if context[:no_original_data]
- attributes.map { |key, value| %Q(data-#{key.to_s.dasherize}="#{escape_once(value)}") }.join(" ")
+ attributes.map do |key, value|
+ %Q(data-#{key.to_s.dasherize}="#{escape_once(value)}")
+ end.join(' ')
end
def escape_once(html)
diff --git a/lib/gitlab/ci/status/external/common.rb b/lib/gitlab/ci/status/external/common.rb
new file mode 100644
index 00000000000..4969a350862
--- /dev/null
+++ b/lib/gitlab/ci/status/external/common.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module Ci
+ module Status
+ module External
+ module Common
+ def has_details?
+ subject.target_url.present? &&
+ can?(user, :read_commit_status, subject)
+ end
+
+ def details_path
+ subject.target_url
+ end
+
+ def has_action?
+ false
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/status/external/factory.rb b/lib/gitlab/ci/status/external/factory.rb
new file mode 100644
index 00000000000..07b15bd8d97
--- /dev/null
+++ b/lib/gitlab/ci/status/external/factory.rb
@@ -0,0 +1,13 @@
+module Gitlab
+ module Ci
+ module Status
+ module External
+ class Factory < Status::Factory
+ def self.common_helpers
+ Status::External::Common
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/base_event.rb b/lib/gitlab/cycle_analytics/base_event_fetcher.rb
index 53a148ad703..0d8791d396b 100644
--- a/lib/gitlab/cycle_analytics/base_event.rb
+++ b/lib/gitlab/cycle_analytics/base_event_fetcher.rb
@@ -1,13 +1,13 @@
module Gitlab
module CycleAnalytics
- class BaseEvent
- include MetricsTables
+ class BaseEventFetcher
+ include BaseQuery
- attr_reader :stage, :start_time_attrs, :end_time_attrs, :projections, :query
+ attr_reader :projections, :query, :stage, :order
- def initialize(project:, options:)
- @query = EventsQuery.new(project: project, options: options)
+ def initialize(project:, stage:, options:)
@project = project
+ @stage = stage
@options = options
end
@@ -19,10 +19,8 @@ module Gitlab
end.compact
end
- def custom_query(_base_query); end
-
def order
- @order || @start_time_attrs
+ @order || default_order
end
private
@@ -34,7 +32,17 @@ module Gitlab
end
def event_result
- @event_result ||= @query.execute(self).to_a
+ @event_result ||= ActiveRecord::Base.connection.exec_query(events_query.to_sql).to_a
+ end
+
+ def events_query
+ diff_fn = subtract_datetimes_diff(base_query, @options[:start_time_attrs], @options[:end_time_attrs])
+
+ base_query.project(extract_diff_epoch(diff_fn).as('total_time'), *projections).order(order.desc)
+ end
+
+ def default_order
+ [@options[:start_time_attrs]].flatten.first
end
def serialize(_event)
diff --git a/lib/gitlab/cycle_analytics/base_query.rb b/lib/gitlab/cycle_analytics/base_query.rb
new file mode 100644
index 00000000000..d560dca45c8
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/base_query.rb
@@ -0,0 +1,31 @@
+module Gitlab
+ module CycleAnalytics
+ module BaseQuery
+ include MetricsTables
+ include Gitlab::Database::Median
+ include Gitlab::Database::DateTime
+
+ private
+
+ def base_query
+ @base_query ||= stage_query
+ end
+
+ def stage_query
+ query = mr_closing_issues_table.join(issue_table).on(issue_table[:id].eq(mr_closing_issues_table[:issue_id])).
+ join(issue_metrics_table).on(issue_table[:id].eq(issue_metrics_table[:issue_id])).
+ where(issue_table[:project_id].eq(@project.id)).
+ where(issue_table[:deleted_at].eq(nil)).
+ where(issue_table[:created_at].gteq(@options[:from]))
+
+ # Load merge_requests
+ query = query.join(mr_table, Arel::Nodes::OuterJoin).
+ on(mr_table[:id].eq(mr_closing_issues_table[:merge_request_id])).
+ join(mr_metrics_table).
+ on(mr_table[:id].eq(mr_metrics_table[:merge_request_id]))
+
+ query
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/base_stage.rb b/lib/gitlab/cycle_analytics/base_stage.rb
new file mode 100644
index 00000000000..74bbcdcb3dd
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/base_stage.rb
@@ -0,0 +1,54 @@
+module Gitlab
+ module CycleAnalytics
+ class BaseStage
+ include BaseQuery
+
+ def initialize(project:, options:)
+ @project = project
+ @options = options
+ end
+
+ def events
+ event_fetcher.fetch
+ end
+
+ def as_json
+ AnalyticsStageSerializer.new.represent(self).as_json
+ end
+
+ def title
+ name.to_s.capitalize
+ end
+
+ def median
+ cte_table = Arel::Table.new("cte_table_for_#{name}")
+
+ # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time).
+ # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time).
+ # We compute the (end_time - start_time) interval, and give it an alias based on the current
+ # cycle analytics stage.
+ interval_query = Arel::Nodes::As.new(
+ cte_table,
+ subtract_datetimes(base_query.dup, start_time_attrs, end_time_attrs, name.to_s))
+
+ median_datetime(cte_table, interval_query, name)
+ end
+
+ def name
+ raise NotImplementedError.new("Expected #{self.name} to implement name")
+ end
+
+ private
+
+ def event_fetcher
+ @event_fetcher ||= Gitlab::CycleAnalytics::EventFetcher[name].new(project: @project,
+ stage: name,
+ options: event_options)
+ end
+
+ def event_options
+ @options.merge(start_time_attrs: start_time_attrs, end_time_attrs: end_time_attrs)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/review_event.rb b/lib/gitlab/cycle_analytics/code_event_fetcher.rb
index b394a02cc52..5245b9ca8fc 100644
--- a/lib/gitlab/cycle_analytics/review_event.rb
+++ b/lib/gitlab/cycle_analytics/code_event_fetcher.rb
@@ -1,22 +1,22 @@
module Gitlab
module CycleAnalytics
- class ReviewEvent < BaseEvent
+ class CodeEventFetcher < BaseEventFetcher
include MergeRequestAllowed
def initialize(*args)
- @stage = :review
- @start_time_attrs = mr_table[:created_at]
- @end_time_attrs = mr_metrics_table[:merged_at]
@projections = [mr_table[:title],
mr_table[:iid],
mr_table[:id],
mr_table[:created_at],
mr_table[:state],
mr_table[:author_id]]
+ @order = mr_table[:created_at]
super(*args)
end
+ private
+
def serialize(event)
AnalyticsMergeRequestSerializer.new(project: @project).represent(event).as_json
end
diff --git a/lib/gitlab/cycle_analytics/code_stage.rb b/lib/gitlab/cycle_analytics/code_stage.rb
new file mode 100644
index 00000000000..d1bc2055ba8
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/code_stage.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module CycleAnalytics
+ class CodeStage < BaseStage
+ def start_time_attrs
+ @start_time_attrs ||= issue_metrics_table[:first_mentioned_in_commit_at]
+ end
+
+ def end_time_attrs
+ @end_time_attrs ||= mr_table[:created_at]
+ end
+
+ def name
+ :code
+ end
+
+ def description
+ "Time until first merge request"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/event_fetcher.rb b/lib/gitlab/cycle_analytics/event_fetcher.rb
new file mode 100644
index 00000000000..50e126cf00b
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/event_fetcher.rb
@@ -0,0 +1,9 @@
+module Gitlab
+ module CycleAnalytics
+ module EventFetcher
+ def self.[](stage_name)
+ CycleAnalytics.const_get("#{stage_name.to_s.camelize}EventFetcher")
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/events.rb b/lib/gitlab/cycle_analytics/events.rb
deleted file mode 100644
index 2d703d76cbb..00000000000
--- a/lib/gitlab/cycle_analytics/events.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-module Gitlab
- module CycleAnalytics
- class Events
- def initialize(project:, options:)
- @project = project
- @options = options
- end
-
- def issue_events
- IssueEvent.new(project: @project, options: @options).fetch
- end
-
- def plan_events
- PlanEvent.new(project: @project, options: @options).fetch
- end
-
- def code_events
- CodeEvent.new(project: @project, options: @options).fetch
- end
-
- def test_events
- TestEvent.new(project: @project, options: @options).fetch
- end
-
- def review_events
- ReviewEvent.new(project: @project, options: @options).fetch
- end
-
- def staging_events
- StagingEvent.new(project: @project, options: @options).fetch
- end
-
- def production_events
- ProductionEvent.new(project: @project, options: @options).fetch
- end
- end
- end
-end
diff --git a/lib/gitlab/cycle_analytics/events_query.rb b/lib/gitlab/cycle_analytics/events_query.rb
deleted file mode 100644
index 2418832ccc2..00000000000
--- a/lib/gitlab/cycle_analytics/events_query.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-module Gitlab
- module CycleAnalytics
- class EventsQuery
- attr_reader :project
-
- def initialize(project:, options: {})
- @project = project
- @from = options[:from]
- @branch = options[:branch]
- @fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: @from, branch: @branch)
- end
-
- def execute(stage_class)
- @stage_class = stage_class
-
- ActiveRecord::Base.connection.exec_query(query.to_sql)
- end
-
- private
-
- def query
- base_query = @fetcher.base_query_for(@stage_class.stage)
- diff_fn = @fetcher.subtract_datetimes_diff(base_query, @stage_class.start_time_attrs, @stage_class.end_time_attrs)
-
- @stage_class.custom_query(base_query)
-
- base_query.project(extract_epoch(diff_fn).as('total_time'), *@stage_class.projections).order(@stage_class.order.desc)
- end
-
- def extract_epoch(arel_attribute)
- return arel_attribute unless Gitlab::Database.postgresql?
-
- Arel.sql(%Q{EXTRACT(EPOCH FROM (#{arel_attribute.to_sql}))})
- end
- end
- end
-end
diff --git a/lib/gitlab/cycle_analytics/issue_event.rb b/lib/gitlab/cycle_analytics/issue_event.rb
deleted file mode 100644
index 705b7e5ce24..00000000000
--- a/lib/gitlab/cycle_analytics/issue_event.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-module Gitlab
- module CycleAnalytics
- class IssueEvent < BaseEvent
- include IssueAllowed
-
- def initialize(*args)
- @stage = :issue
- @start_time_attrs = issue_table[:created_at]
- @end_time_attrs = [issue_metrics_table[:first_associated_with_milestone_at],
- issue_metrics_table[:first_added_to_board_at]]
- @projections = [issue_table[:title],
- issue_table[:iid],
- issue_table[:id],
- issue_table[:created_at],
- issue_table[:author_id]]
-
- super(*args)
- end
-
- private
-
- def serialize(event)
- AnalyticsIssueSerializer.new(project: @project).represent(event).as_json
- end
- end
- end
-end
diff --git a/lib/gitlab/cycle_analytics/production_event.rb b/lib/gitlab/cycle_analytics/issue_event_fetcher.rb
index 4868c3c6237..0d8da99455e 100644
--- a/lib/gitlab/cycle_analytics/production_event.rb
+++ b/lib/gitlab/cycle_analytics/issue_event_fetcher.rb
@@ -1,12 +1,9 @@
module Gitlab
module CycleAnalytics
- class ProductionEvent < BaseEvent
+ class IssueEventFetcher < BaseEventFetcher
include IssueAllowed
def initialize(*args)
- @stage = :production
- @start_time_attrs = issue_table[:created_at]
- @end_time_attrs = mr_metrics_table[:first_deployed_to_production_at]
@projections = [issue_table[:title],
issue_table[:iid],
issue_table[:id],
diff --git a/lib/gitlab/cycle_analytics/issue_stage.rb b/lib/gitlab/cycle_analytics/issue_stage.rb
new file mode 100644
index 00000000000..d2068fbc38f
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/issue_stage.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module CycleAnalytics
+ class IssueStage < BaseStage
+ def start_time_attrs
+ @start_time_attrs ||= issue_table[:created_at]
+ end
+
+ def end_time_attrs
+ @end_time_attrs ||= [issue_metrics_table[:first_associated_with_milestone_at],
+ issue_metrics_table[:first_added_to_board_at]]
+ end
+
+ def name
+ :issue
+ end
+
+ def description
+ "Time before an issue gets scheduled"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/metrics_fetcher.rb b/lib/gitlab/cycle_analytics/metrics_fetcher.rb
deleted file mode 100644
index b71e8735e27..00000000000
--- a/lib/gitlab/cycle_analytics/metrics_fetcher.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-module Gitlab
- module CycleAnalytics
- class MetricsFetcher
- include Gitlab::Database::Median
- include Gitlab::Database::DateTime
- include MetricsTables
-
- DEPLOYMENT_METRIC_STAGES = %i[production staging]
-
- def initialize(project:, from:, branch:)
- @project = project
- @project = project
- @from = from
- @branch = branch
- end
-
- def calculate_metric(name, start_time_attrs, end_time_attrs)
- cte_table = Arel::Table.new("cte_table_for_#{name}")
-
- # Build a `SELECT` query. We find the first of the `end_time_attrs` that isn't `NULL` (call this end_time).
- # Next, we find the first of the start_time_attrs that isn't `NULL` (call this start_time).
- # We compute the (end_time - start_time) interval, and give it an alias based on the current
- # cycle analytics stage.
- interval_query = Arel::Nodes::As.new(
- cte_table,
- subtract_datetimes(base_query_for(name), start_time_attrs, end_time_attrs, name.to_s))
-
- median_datetime(cte_table, interval_query, name)
- end
-
- # Join table with a row for every <issue,merge_request> pair (where the merge request
- # closes the given issue) with issue and merge request metrics included. The metrics
- # are loaded with an inner join, so issues / merge requests without metrics are
- # automatically excluded.
- def base_query_for(name)
- # Load issues
- query = mr_closing_issues_table.join(issue_table).on(issue_table[:id].eq(mr_closing_issues_table[:issue_id])).
- join(issue_metrics_table).on(issue_table[:id].eq(issue_metrics_table[:issue_id])).
- where(issue_table[:project_id].eq(@project.id)).
- where(issue_table[:deleted_at].eq(nil)).
- where(issue_table[:created_at].gteq(@from))
-
- query = query.where(build_table[:ref].eq(@branch)) if name == :test && @branch
-
- # Load merge_requests
- query = query.join(mr_table, Arel::Nodes::OuterJoin).
- on(mr_table[:id].eq(mr_closing_issues_table[:merge_request_id])).
- join(mr_metrics_table).
- on(mr_table[:id].eq(mr_metrics_table[:merge_request_id]))
-
- if DEPLOYMENT_METRIC_STAGES.include?(name)
- # Limit to merge requests that have been deployed to production after `@from`
- query.where(mr_metrics_table[:first_deployed_to_production_at].gteq(@from))
- end
-
- query
- end
- end
- end
-end
diff --git a/lib/gitlab/cycle_analytics/plan_event.rb b/lib/gitlab/cycle_analytics/plan_event_fetcher.rb
index 7c3f0e9989f..88a8710dbe6 100644
--- a/lib/gitlab/cycle_analytics/plan_event.rb
+++ b/lib/gitlab/cycle_analytics/plan_event_fetcher.rb
@@ -1,19 +1,17 @@
module Gitlab
module CycleAnalytics
- class PlanEvent < BaseEvent
+ class PlanEventFetcher < BaseEventFetcher
def initialize(*args)
- @stage = :plan
- @start_time_attrs = issue_metrics_table[:first_associated_with_milestone_at]
- @end_time_attrs = [issue_metrics_table[:first_added_to_board_at],
- issue_metrics_table[:first_mentioned_in_commit_at]]
@projections = [mr_diff_table[:st_commits].as('commits'),
issue_metrics_table[:first_mentioned_in_commit_at]]
super(*args)
end
- def custom_query(base_query)
+ def events_query
base_query.join(mr_diff_table).on(mr_diff_table[:merge_request_id].eq(mr_table[:id]))
+
+ super
end
private
diff --git a/lib/gitlab/cycle_analytics/plan_stage.rb b/lib/gitlab/cycle_analytics/plan_stage.rb
new file mode 100644
index 00000000000..3b4dfc6a30e
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/plan_stage.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module CycleAnalytics
+ class PlanStage < BaseStage
+ def start_time_attrs
+ @start_time_attrs ||= [issue_metrics_table[:first_associated_with_milestone_at],
+ issue_metrics_table[:first_added_to_board_at]]
+ end
+
+ def end_time_attrs
+ @end_time_attrs ||= issue_metrics_table[:first_mentioned_in_commit_at]
+ end
+
+ def name
+ :plan
+ end
+
+ def description
+ "Time before an issue starts implementation"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/production_event_fetcher.rb b/lib/gitlab/cycle_analytics/production_event_fetcher.rb
new file mode 100644
index 00000000000..0fa2e87f673
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/production_event_fetcher.rb
@@ -0,0 +1,6 @@
+module Gitlab
+ module CycleAnalytics
+ class ProductionEventFetcher < IssueEventFetcher
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/production_helper.rb b/lib/gitlab/cycle_analytics/production_helper.rb
new file mode 100644
index 00000000000..d693443bfa4
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/production_helper.rb
@@ -0,0 +1,9 @@
+module Gitlab
+ module CycleAnalytics
+ module ProductionHelper
+ def stage_query
+ super.where(mr_metrics_table[:first_deployed_to_production_at].gteq(@options[:from]))
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/production_stage.rb b/lib/gitlab/cycle_analytics/production_stage.rb
new file mode 100644
index 00000000000..2a6bcc80116
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/production_stage.rb
@@ -0,0 +1,28 @@
+module Gitlab
+ module CycleAnalytics
+ class ProductionStage < BaseStage
+ include ProductionHelper
+
+ def start_time_attrs
+ @start_time_attrs ||= issue_table[:created_at]
+ end
+
+ def end_time_attrs
+ @end_time_attrs ||= mr_metrics_table[:first_deployed_to_production_at]
+ end
+
+ def name
+ :production
+ end
+
+ def description
+ "From issue creation until deploy to production"
+ end
+
+ def query
+ # Limit to merge requests that have been deployed to production after `@from`
+ query.where(mr_metrics_table[:first_deployed_to_production_at].gteq(@from))
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/code_event.rb b/lib/gitlab/cycle_analytics/review_event_fetcher.rb
index 2afdf0b8518..4df0bd06393 100644
--- a/lib/gitlab/cycle_analytics/code_event.rb
+++ b/lib/gitlab/cycle_analytics/review_event_fetcher.rb
@@ -1,25 +1,19 @@
module Gitlab
module CycleAnalytics
- class CodeEvent < BaseEvent
+ class ReviewEventFetcher < BaseEventFetcher
include MergeRequestAllowed
def initialize(*args)
- @stage = :code
- @start_time_attrs = issue_metrics_table[:first_mentioned_in_commit_at]
- @end_time_attrs = mr_table[:created_at]
@projections = [mr_table[:title],
mr_table[:iid],
mr_table[:id],
mr_table[:created_at],
mr_table[:state],
mr_table[:author_id]]
- @order = mr_table[:created_at]
super(*args)
end
- private
-
def serialize(event)
AnalyticsMergeRequestSerializer.new(project: @project).represent(event).as_json
end
diff --git a/lib/gitlab/cycle_analytics/review_stage.rb b/lib/gitlab/cycle_analytics/review_stage.rb
new file mode 100644
index 00000000000..fbaa3010d81
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/review_stage.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module CycleAnalytics
+ class ReviewStage < BaseStage
+ def start_time_attrs
+ @start_time_attrs ||= mr_table[:created_at]
+ end
+
+ def end_time_attrs
+ @end_time_attrs ||= mr_metrics_table[:merged_at]
+ end
+
+ def name
+ :review
+ end
+
+ def description
+ "Time between merge request creation and merge/close"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage.rb b/lib/gitlab/cycle_analytics/stage.rb
new file mode 100644
index 00000000000..28e0455df59
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage.rb
@@ -0,0 +1,9 @@
+module Gitlab
+ module CycleAnalytics
+ module Stage
+ def self.[](stage_name)
+ CycleAnalytics.const_get("#{stage_name.to_s.camelize}Stage")
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/stage_summary.rb b/lib/gitlab/cycle_analytics/stage_summary.rb
new file mode 100644
index 00000000000..b34baf5b081
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/stage_summary.rb
@@ -0,0 +1,23 @@
+module Gitlab
+ module CycleAnalytics
+ class StageSummary
+ def initialize(project, from:, current_user:)
+ @project = project
+ @from = from
+ @current_user = current_user
+ end
+
+ def data
+ [serialize(Summary::Issue.new(project: @project, from: @from, current_user: @current_user)),
+ serialize(Summary::Commit.new(project: @project, from: @from)),
+ serialize(Summary::Deploy.new(project: @project, from: @from))]
+ end
+
+ private
+
+ def serialize(summary_object)
+ AnalyticsSummarySerializer.new.represent(summary_object).as_json
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/staging_event.rb b/lib/gitlab/cycle_analytics/staging_event_fetcher.rb
index a1f30b716f6..a34731a5fcd 100644
--- a/lib/gitlab/cycle_analytics/staging_event.rb
+++ b/lib/gitlab/cycle_analytics/staging_event_fetcher.rb
@@ -1,10 +1,7 @@
module Gitlab
module CycleAnalytics
- class StagingEvent < BaseEvent
+ class StagingEventFetcher < BaseEventFetcher
def initialize(*args)
- @stage = :staging
- @start_time_attrs = mr_metrics_table[:merged_at]
- @end_time_attrs = mr_metrics_table[:first_deployed_to_production_at]
@projections = [build_table[:id]]
@order = build_table[:created_at]
@@ -17,8 +14,10 @@ module Gitlab
super
end
- def custom_query(base_query)
+ def events_query
base_query.join(build_table).on(mr_metrics_table[:pipeline_id].eq(build_table[:commit_id]))
+
+ super
end
private
diff --git a/lib/gitlab/cycle_analytics/staging_stage.rb b/lib/gitlab/cycle_analytics/staging_stage.rb
new file mode 100644
index 00000000000..945909a4d62
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/staging_stage.rb
@@ -0,0 +1,22 @@
+module Gitlab
+ module CycleAnalytics
+ class StagingStage < BaseStage
+ include ProductionHelper
+ def start_time_attrs
+ @start_time_attrs ||= mr_metrics_table[:merged_at]
+ end
+
+ def end_time_attrs
+ @end_time_attrs ||= mr_metrics_table[:first_deployed_to_production_at]
+ end
+
+ def name
+ :staging
+ end
+
+ def description
+ "From merge request merge until deploy to production"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/summary/base.rb b/lib/gitlab/cycle_analytics/summary/base.rb
new file mode 100644
index 00000000000..43fa3795e5c
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/summary/base.rb
@@ -0,0 +1,20 @@
+module Gitlab
+ module CycleAnalytics
+ module Summary
+ class Base
+ def initialize(project:, from:)
+ @project = project
+ @from = from
+ end
+
+ def title
+ self.class.name.demodulize
+ end
+
+ def value
+ raise NotImplementedError.new("Expected #{self.name} to implement value")
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/summary/commit.rb b/lib/gitlab/cycle_analytics/summary/commit.rb
new file mode 100644
index 00000000000..7b8faa4d854
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/summary/commit.rb
@@ -0,0 +1,39 @@
+module Gitlab
+ module CycleAnalytics
+ module Summary
+ class Commit < Base
+ def value
+ @value ||= count_commits
+ end
+
+ private
+
+ # Don't use the `Gitlab::Git::Repository#log` method, because it enforces
+ # a limit. Since we need a commit count, we _can't_ enforce a limit, so
+ # the easiest way forward is to replicate the relevant portions of the
+ # `log` function here.
+ def count_commits
+ return unless ref
+
+ repository = @project.repository.raw_repository
+ sha = @project.repository.commit(ref).sha
+
+ cmd = %W(git --git-dir=#{repository.path} log)
+ cmd << '--format=%H'
+ cmd << "--after=#{@from.iso8601}"
+ cmd << sha
+
+ output, status = Gitlab::Popen.popen(cmd)
+
+ raise IOError, output unless status.zero?
+
+ output.lines.count
+ end
+
+ def ref
+ @ref ||= @project.default_branch.presence
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/summary/deploy.rb b/lib/gitlab/cycle_analytics/summary/deploy.rb
new file mode 100644
index 00000000000..06032e9200e
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/summary/deploy.rb
@@ -0,0 +1,11 @@
+module Gitlab
+ module CycleAnalytics
+ module Summary
+ class Deploy < Base
+ def value
+ @value ||= @project.deployments.where("created_at > ?", @from).count
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/summary/issue.rb b/lib/gitlab/cycle_analytics/summary/issue.rb
new file mode 100644
index 00000000000..008468f24b9
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/summary/issue.rb
@@ -0,0 +1,21 @@
+module Gitlab
+ module CycleAnalytics
+ module Summary
+ class Issue < Base
+ def initialize(project:, from:, current_user:)
+ @project = project
+ @from = from
+ @current_user = current_user
+ end
+
+ def title
+ 'New Issue'
+ end
+
+ def value
+ @value ||= IssuesFinder.new(@current_user, project_id: @project.id).execute.created_after(@from).count
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/test_event.rb b/lib/gitlab/cycle_analytics/test_event.rb
deleted file mode 100644
index d553d0b5aec..00000000000
--- a/lib/gitlab/cycle_analytics/test_event.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-module Gitlab
- module CycleAnalytics
- class TestEvent < StagingEvent
- def initialize(*args)
- super(*args)
-
- @stage = :test
- @start_time_attrs = mr_metrics_table[:latest_build_started_at]
- @end_time_attrs = mr_metrics_table[:latest_build_finished_at]
- end
- end
- end
-end
diff --git a/lib/gitlab/cycle_analytics/test_event_fetcher.rb b/lib/gitlab/cycle_analytics/test_event_fetcher.rb
new file mode 100644
index 00000000000..a2589c6601a
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/test_event_fetcher.rb
@@ -0,0 +1,6 @@
+module Gitlab
+ module CycleAnalytics
+ class TestEventFetcher < StagingEventFetcher
+ end
+ end
+end
diff --git a/lib/gitlab/cycle_analytics/test_stage.rb b/lib/gitlab/cycle_analytics/test_stage.rb
new file mode 100644
index 00000000000..0079d56e0e4
--- /dev/null
+++ b/lib/gitlab/cycle_analytics/test_stage.rb
@@ -0,0 +1,29 @@
+module Gitlab
+ module CycleAnalytics
+ class TestStage < BaseStage
+ def start_time_attrs
+ @start_time_attrs ||= mr_metrics_table[:latest_build_started_at]
+ end
+
+ def end_time_attrs
+ @end_time_attrs ||= mr_metrics_table[:latest_build_finished_at]
+ end
+
+ def name
+ :test
+ end
+
+ def description
+ "Total test time for all commits/merges"
+ end
+
+ def stage_query
+ if @options[:branch]
+ super.where(build_table[:ref].eq(@options[:branch]))
+ else
+ super
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/database/median.rb b/lib/gitlab/database/median.rb
index 1444d25ebc7..08607c27c09 100644
--- a/lib/gitlab/database/median.rb
+++ b/lib/gitlab/database/median.rb
@@ -103,6 +103,11 @@ module Gitlab
Arel.sql(%Q{EXTRACT(EPOCH FROM "#{arel_attribute.relation.name}"."#{arel_attribute.name}")})
end
+ def extract_diff_epoch(diff)
+ return diff unless Gitlab::Database.postgresql?
+
+ Arel.sql(%Q{EXTRACT(EPOCH FROM (#{diff.to_sql}))})
+ end
# Need to cast '0' to an INTERVAL before we can check if the interval is positive
def zero_interval
Arel::Nodes::NamedFunction.new("CAST", [Arel.sql("'0' AS INTERVAL")])
diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index e6ecd118609..08ad3274b38 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -6,6 +6,7 @@ project_tree:
- :events
- issues:
- :events
+ - :timelogs
- notes:
- :author
- :events
@@ -27,6 +28,7 @@ project_tree:
- :events
- :merge_request_diff
- :events
+ - :timelogs
- label_links:
- label:
:priorities
diff --git a/lib/gitlab/time_tracking_formatter.rb b/lib/gitlab/time_tracking_formatter.rb
new file mode 100644
index 00000000000..d615c24149a
--- /dev/null
+++ b/lib/gitlab/time_tracking_formatter.rb
@@ -0,0 +1,34 @@
+module Gitlab
+ module TimeTrackingFormatter
+ extend self
+
+ def parse(string)
+ with_custom_config do
+ string.sub!(/\A-/, '')
+
+ seconds = ChronicDuration.parse(string, default_unit: 'hours') rescue nil
+ seconds *= -1 if seconds && Regexp.last_match
+ seconds
+ end
+ end
+
+ def output(seconds)
+ with_custom_config do
+ ChronicDuration.output(seconds, format: :short, limit_to_hours: false, weeks: true) rescue nil
+ end
+ end
+
+ def with_custom_config
+ # We may want to configure it through project settings in a future version.
+ ChronicDuration.hours_per_day = 8
+ ChronicDuration.days_per_week = 5
+
+ result = yield
+
+ ChronicDuration.hours_per_day = 24
+ ChronicDuration.days_per_week = 7
+
+ result
+ end
+ end
+end
diff --git a/public/robots.txt b/public/robots.txt
index 7d69fad59d1..123272a9834 100644
--- a/public/robots.txt
+++ b/public/robots.txt
@@ -4,13 +4,12 @@
# User-Agent: *
# Disallow: /
-User-Agent: *
-
# Add a 1 second delay between successive requests to the same server, limits resources used by crawler
# Only some crawlers respect this setting, e.g. Googlebot does not
# Crawl-delay: 1
# Based on details in https://gitlab.com/gitlab-org/gitlab-ce/blob/master/config/routes.rb, https://gitlab.com/gitlab-org/gitlab-ce/blob/master/spec/routing, and using application
+User-Agent: *
Disallow: /autocomplete/users
Disallow: /search
Disallow: /api
@@ -23,12 +22,14 @@ Disallow: /groups/*/edit
Disallow: /users
# Global snippets
+User-Agent: *
Disallow: /s/
Disallow: /snippets/new
Disallow: /snippets/*/edit
Disallow: /snippets/*/raw
# Project details
+User-Agent: *
Disallow: /*/*.git
Disallow: /*/*/fork/new
Disallow: /*/*/repository/archive*
diff --git a/spec/controllers/admin/services_controller_spec.rb b/spec/controllers/admin/services_controller_spec.rb
new file mode 100644
index 00000000000..e5cdd52307e
--- /dev/null
+++ b/spec/controllers/admin/services_controller_spec.rb
@@ -0,0 +1,26 @@
+require 'spec_helper'
+
+describe Admin::ServicesController do
+ let(:admin) { create(:admin) }
+
+ before { sign_in(admin) }
+
+ describe 'GET #edit' do
+ let!(:project) { create(:empty_project) }
+
+ Service.available_services_names.each do |service_name|
+ context "#{service_name}" do
+ let!(:service) do
+ service_template = service_name.concat("_service").camelize.constantize
+ service_template.where(template: true).first_or_create
+ end
+
+ it 'successfully displays the template' do
+ get :edit, id: service.id
+
+ expect(response).to have_http_status(200)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb
index 646b097d74e..0fa06a38d2a 100644
--- a/spec/controllers/projects/commit_controller_spec.rb
+++ b/spec/controllers/projects/commit_controller_spec.rb
@@ -1,9 +1,10 @@
require 'spec_helper'
describe Projects::CommitController do
- let(:project) { create(:project) }
- let(:user) { create(:user) }
- let(:commit) { project.commit("master") }
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:commit) { project.commit("master") }
+ let(:pipeline) { create(:ci_pipeline, project: project, commit: commit) }
let(:master_pickable_sha) { '7d3b0f7cff5f37573aea97cebfd5692ea1689924' }
let(:master_pickable_commit) { project.commit(master_pickable_sha) }
@@ -309,4 +310,35 @@ describe Projects::CommitController do
end
end
end
+
+ describe 'GET pipelines' do
+ def get_pipelines(extra_params = {})
+ params = {
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param
+ }
+
+ get :pipelines, params.merge(extra_params)
+ end
+
+ context 'when the commit exists' do
+ context 'when the commit has one or more pipelines' do
+ it 'shows pipelines' do
+ get_pipelines(id: commit.id)
+
+ expect(response).to be_ok
+ end
+ end
+ end
+
+ context 'when the commit does not exist' do
+ before do
+ get_pipelines(id: 'e7a412c8da9f6d0081a633a4a402dde1c4694ebd')
+ end
+
+ it 'returns a 404' do
+ expect(response).to have_http_status(404)
+ end
+ end
+ end
end
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index e2321f2034b..b5987a83df0 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -326,6 +326,20 @@ describe Projects::IssuesController do
end
describe 'POST #create' do
+ def post_new_issue(attrs = {})
+ sign_in(user)
+ project = create(:empty_project, :public)
+ project.team << [user, :developer]
+
+ post :create, {
+ namespace_id: project.namespace.to_param,
+ project_id: project.to_param,
+ issue: { title: 'Title', description: 'Description' }.merge(attrs)
+ }
+
+ project.issues.first
+ end
+
context 'resolving discussions in MergeRequest' do
let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
let(:merge_request) { discussion.noteable }
@@ -369,13 +383,7 @@ describe Projects::IssuesController do
end
def post_spam_issue
- sign_in(user)
- spam_project = create(:empty_project, :public)
- post :create, {
- namespace_id: spam_project.namespace.to_param,
- project_id: spam_project.to_param,
- issue: { title: 'Spam Title', description: 'Spam lives here' }
- }
+ post_new_issue(title: 'Spam Title', description: 'Spam lives here')
end
it 'rejects an issue recognized as spam' do
@@ -396,18 +404,26 @@ describe Projects::IssuesController do
request.env['action_dispatch.remote_ip'] = '127.0.0.1'
end
- def post_new_issue
+ it 'creates a user agent detail' do
+ expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1)
+ end
+ end
+
+ context 'when description has slash commands' do
+ before do
sign_in(user)
- project = create(:empty_project, :public)
- post :create, {
- namespace_id: project.namespace.to_param,
- project_id: project.to_param,
- issue: { title: 'Title', description: 'Description' }
- }
end
- it 'creates a user agent detail' do
- expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1)
+ it 'can add spent time' do
+ issue = post_new_issue(description: '/spend 1h')
+
+ expect(issue.total_time_spent).to eq(3600)
+ end
+
+ it 'can set the time estimate' do
+ issue = post_new_issue(description: '/estimate 2h')
+
+ expect(issue.time_estimate).to eq(7200)
end
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 1cdbe4fc9a5..992580a6b34 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -32,6 +32,10 @@ FactoryGirl.define do
request_access_enabled true
end
+ trait :repository do
+ # no-op... for now!
+ end
+
trait :empty_repo do
after(:create) do |project|
project.create_repository
diff --git a/spec/factories/timelogs.rb b/spec/factories/timelogs.rb
new file mode 100644
index 00000000000..12fc4ec4486
--- /dev/null
+++ b/spec/factories/timelogs.rb
@@ -0,0 +1,9 @@
+# Read about factories at https://github.com/thoughtbot/factory_girl
+
+FactoryGirl.define do
+ factory :timelog do
+ time_spent 3600
+ user
+ association :trackable, factory: :issue
+ end
+end
diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb
index 082b02116c0..85a8c263643 100644
--- a/spec/factories/todos.rb
+++ b/spec/factories/todos.rb
@@ -28,6 +28,10 @@ FactoryGirl.define do
action { Todo::APPROVAL_REQUIRED }
end
+ trait :unmergeable do
+ action { Todo::UNMERGEABLE }
+ end
+
trait :done do
state :done
end
diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
index 6f6a2532c04..3ac9b2e0ae0 100644
--- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb
@@ -66,6 +66,12 @@ describe 'Dropdown assignee', js: true, feature: true do
expect(dropdown_assignee_size).to eq(3)
end
+
+ it 'shows current user at top of dropdown' do
+ send_keys_to_filtered_search('assignee:')
+
+ expect(first('#js-dropdown-assignee .filter-dropdown li')).to have_content(user.name)
+ end
end
describe 'filtering' do
diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb
index 60a86cc93d4..464749d01e3 100644
--- a/spec/features/issues/filtered_search/dropdown_author_spec.rb
+++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb
@@ -66,6 +66,12 @@ describe 'Dropdown author', js: true, feature: true do
expect(dropdown_author_size).to eq(3)
end
+
+ it 'shows current user at top of dropdown' do
+ send_keys_to_filtered_search('author:')
+
+ expect(first('#js-dropdown-author li')).to have_content(user.name)
+ end
end
describe 'filtering' do
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index 82c9bd0e6e6..31156fcf994 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -33,6 +33,45 @@ feature 'GFM autocomplete', feature: true, js: true do
expect(page).not_to have_selector('.atwho-view')
end
+ it 'doesnt select the first item for non-assignee dropdowns' do
+ page.within '.timeline-content-form' do
+ find('#note_note').native.send_keys('')
+ find('#note_note').native.send_keys(':')
+ end
+
+ expect(page).to have_selector('.atwho-container')
+
+ wait_for_ajax
+
+ expect(find('#at-view-58')).not_to have_selector('.cur:first-of-type')
+ end
+
+ it 'selects the first item for assignee dropdowns' do
+ page.within '.timeline-content-form' do
+ find('#note_note').native.send_keys('')
+ find('#note_note').native.send_keys('@')
+ end
+
+ expect(page).to have_selector('.atwho-container')
+
+ wait_for_ajax
+
+ expect(find('#at-view-64')).to have_selector('.cur:first-of-type')
+ end
+
+ it 'selects the first item for non-assignee dropdowns if a query is entered' do
+ page.within '.timeline-content-form' do
+ find('#note_note').native.send_keys('')
+ find('#note_note').native.send_keys(':1')
+ end
+
+ expect(page).to have_selector('.atwho-container')
+
+ wait_for_ajax
+
+ expect(find('#at-view-58')).to have_selector('.cur:first-of-type')
+ end
+
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
note = find('#note_note')
diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb
index 31f75512f4a..0a9cd11ad6e 100644
--- a/spec/features/issues/user_uses_slash_commands_spec.rb
+++ b/spec/features/issues/user_uses_slash_commands_spec.rb
@@ -100,6 +100,58 @@ feature 'Issues > User uses slash commands', feature: true, js: true do
end
end
+ describe 'Issuable time tracking' do
+ let(:issue) { create(:issue, project: project) }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'Issue' do
+ before do
+ visit namespace_project_issue_path(project.namespace, project, issue)
+ end
+
+ it_behaves_like 'issuable time tracker'
+ end
+
+ context 'Merge Request' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ it_behaves_like 'issuable time tracker'
+ end
+ end
+
+ describe 'Issuable time tracking' do
+ let(:issue) { create(:issue, project: project) }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'Issue' do
+ before do
+ visit namespace_project_issue_path(project.namespace, project, issue)
+ end
+
+ it_behaves_like 'issuable time tracker'
+ end
+
+ context 'Merge Request' do
+ let(:merge_request) { create(:merge_request, source_project: project) }
+
+ before do
+ visit namespace_project_merge_request_path(project.namespace, project, merge_request)
+ end
+
+ it_behaves_like 'issuable time tracker'
+ end
+ end
+
describe 'toggling the WIP prefix from the title from note' do
let(:issue) { create(:issue, project: project) }
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index 14e009daba8..e673ece37c3 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -11,18 +11,42 @@ describe 'Pipeline', :feature, :js do
project.team << [user, :developer]
end
+ shared_context 'pipeline builds' do
+ let!(:build_passed) do
+ create(:ci_build, :success,
+ pipeline: pipeline, stage: 'build', name: 'build')
+ end
+
+ let!(:build_failed) do
+ create(:ci_build, :failed,
+ pipeline: pipeline, stage: 'test', name: 'test', commands: 'test')
+ end
+
+ let!(:build_running) do
+ create(:ci_build, :running,
+ pipeline: pipeline, stage: 'deploy', name: 'deploy')
+ end
+
+ let!(:build_manual) do
+ create(:ci_build, :manual,
+ pipeline: pipeline, stage: 'deploy', name: 'manual-build')
+ end
+
+ let!(:build_external) do
+ create(:generic_commit_status, status: 'success',
+ pipeline: pipeline,
+ name: 'jenkins',
+ stage: 'external',
+ target_url: 'http://gitlab.com/status')
+ end
+ end
+
describe 'GET /:project/pipelines/:id' do
+ include_context 'pipeline builds'
+
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
- before do
- @success = create(:ci_build, :success, pipeline: pipeline, stage: 'build', name: 'build')
- @failed = create(:ci_build, :failed, pipeline: pipeline, stage: 'test', name: 'test', commands: 'test')
- @running = create(:ci_build, :running, pipeline: pipeline, stage: 'deploy', name: 'deploy')
- @manual = create(:ci_build, :manual, pipeline: pipeline, stage: 'deploy', name: 'manual-build')
- @external = create(:generic_commit_status, status: 'success', pipeline: pipeline, name: 'jenkins', stage: 'external')
- end
-
before { visit namespace_project_pipeline_path(project.namespace, project, pipeline) }
it 'shows the pipeline graph' do
@@ -116,6 +140,7 @@ describe 'Pipeline', :feature, :js do
it 'shows the success icon and the generic comit status build' do
expect(page).to have_selector('.ci-status-icon-success')
expect(page).to have_content('jenkins')
+ expect(page).to have_link('jenkins', href: 'http://gitlab.com/status')
end
end
end
@@ -157,26 +182,22 @@ describe 'Pipeline', :feature, :js do
end
describe 'GET /:project/pipelines/:id/builds' do
+ include_context 'pipeline builds'
+
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
- @success = create(:ci_build, :success, pipeline: pipeline, stage: 'build', name: 'build')
- @failed = create(:ci_build, :failed, pipeline: pipeline, stage: 'test', name: 'test', commands: 'test')
- @running = create(:ci_build, :running, pipeline: pipeline, stage: 'deploy', name: 'deploy')
- @manual = create(:ci_build, :manual, pipeline: pipeline, stage: 'deploy', name: 'manual-build')
- @external = create(:generic_commit_status, status: 'success', pipeline: pipeline, name: 'jenkins', stage: 'external')
+ visit builds_namespace_project_pipeline_path(project.namespace, project, pipeline)
end
- before { visit builds_namespace_project_pipeline_path(project.namespace, project, pipeline)}
-
it 'shows a list of builds' do
expect(page).to have_content('Test')
- expect(page).to have_content(@success.id)
+ expect(page).to have_content(build_passed.id)
expect(page).to have_content('Deploy')
- expect(page).to have_content(@failed.id)
- expect(page).to have_content(@running.id)
- expect(page).to have_content(@external.id)
+ expect(page).to have_content(build_failed.id)
+ expect(page).to have_content(build_running.id)
+ expect(page).to have_content(build_external.id)
expect(page).to have_content('Retry failed')
expect(page).to have_content('Cancel running')
expect(page).to have_link('Play')
@@ -230,7 +251,7 @@ describe 'Pipeline', :feature, :js do
end
end
- it { expect(@manual.reload).to be_pending }
+ it { expect(build_manual.reload).to be_pending }
end
end
end
diff --git a/spec/features/projects/settings/visibility_settings_spec.rb b/spec/features/projects/settings/visibility_settings_spec.rb
new file mode 100644
index 00000000000..cef315ac9cd
--- /dev/null
+++ b/spec/features/projects/settings/visibility_settings_spec.rb
@@ -0,0 +1,47 @@
+require 'spec_helper'
+
+feature 'Visibility settings', feature: true, js: true do
+ let(:user) { create(:user) }
+ let(:project) { create(:project, namespace: user.namespace, visibility_level: 20) }
+
+ context 'as owner' do
+ before do
+ login_as(user)
+ visit edit_namespace_project_path(project.namespace, project)
+ end
+
+ scenario 'project visibility select is available' do
+ visibility_select_container = find('.js-visibility-select')
+
+ expect(visibility_select_container.find('.visibility-select').value).to eq project.visibility_level.to_s
+ expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.'
+ end
+
+ scenario 'project visibility description updates on change' do
+ visibility_select_container = find('.js-visibility-select')
+ visibility_select = visibility_select_container.find('.visibility-select')
+ visibility_select.select('Private')
+
+ expect(visibility_select.value).to eq '0'
+ expect(visibility_select_container).to have_content 'Project access must be granted explicitly to each user.'
+ end
+ end
+
+ context 'as master' do
+ let(:master_user) { create(:user) }
+
+ before do
+ project.team << [master_user, :master]
+ login_as(master_user)
+ visit edit_namespace_project_path(project.namespace, project)
+ end
+
+ scenario 'project visibility is locked' do
+ visibility_select_container = find('.js-visibility-select')
+
+ expect(visibility_select_container).not_to have_select '.visibility-select'
+ expect(visibility_select_container).to have_content 'Public'
+ expect(visibility_select_container).to have_content 'The project can be cloned without any authentication.'
+ end
+ end
+end
diff --git a/spec/javascripts/gfm_auto_complete_spec.js.es6 b/spec/javascripts/gfm_auto_complete_spec.js.es6
new file mode 100644
index 00000000000..6b48d82cb23
--- /dev/null
+++ b/spec/javascripts/gfm_auto_complete_spec.js.es6
@@ -0,0 +1,65 @@
+//= require gfm_auto_complete
+//= require jquery
+//= require jquery.atwho
+
+const global = window.gl || (window.gl = {});
+const GfmAutoComplete = global.GfmAutoComplete;
+
+describe('GfmAutoComplete', function () {
+ describe('DefaultOptions.sorter', function () {
+ describe('assets loading', function () {
+ beforeEach(function () {
+ spyOn(GfmAutoComplete, 'isLoading').and.returnValue(true);
+
+ this.atwhoInstance = { setting: {} };
+ this.items = [];
+
+ this.sorterValue = GfmAutoComplete.DefaultOptions.sorter
+ .call(this.atwhoInstance, '', this.items);
+ });
+
+ it('should disable highlightFirst', function () {
+ expect(this.atwhoInstance.setting.highlightFirst).toBe(false);
+ });
+
+ it('should return the passed unfiltered items', function () {
+ expect(this.sorterValue).toEqual(this.items);
+ });
+ });
+
+ describe('assets finished loading', function () {
+ beforeEach(function () {
+ spyOn(GfmAutoComplete, 'isLoading').and.returnValue(false);
+ spyOn($.fn.atwho.default.callbacks, 'sorter');
+ });
+
+ it('should enable highlightFirst if alwaysHighlightFirst is set', function () {
+ const atwhoInstance = { setting: { alwaysHighlightFirst: true } };
+
+ GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance);
+
+ expect(atwhoInstance.setting.highlightFirst).toBe(true);
+ });
+
+ it('should enable highlightFirst if a query is present', function () {
+ const atwhoInstance = { setting: {} };
+
+ GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance, 'query');
+
+ expect(atwhoInstance.setting.highlightFirst).toBe(true);
+ });
+
+ it('should call the default atwho sorter', function () {
+ const atwhoInstance = { setting: {} };
+
+ const query = 'query';
+ const items = [];
+ const searchKey = 'searchKey';
+
+ GfmAutoComplete.DefaultOptions.sorter.call(atwhoInstance, query, items, searchKey);
+
+ expect($.fn.atwho.default.callbacks.sorter).toHaveBeenCalledWith(query, items, searchKey);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/issuable_time_tracker_spec.js.es6 b/spec/javascripts/issuable_time_tracker_spec.js.es6
new file mode 100644
index 00000000000..a1e979e8d09
--- /dev/null
+++ b/spec/javascripts/issuable_time_tracker_spec.js.es6
@@ -0,0 +1,201 @@
+/* eslint-disable */
+//= require jquery
+//= require vue
+//= require issuable/time_tracking/components/time_tracker
+
+function initTimeTrackingComponent(opts) {
+ fixture.set(`
+ <div>
+ <div id="mock-container"></div>
+ </div>
+ `);
+
+ this.initialData = {
+ time_estimate: opts.timeEstimate,
+ time_spent: opts.timeSpent,
+ human_time_estimate: opts.timeEstimateHumanReadable,
+ human_time_spent: opts.timeSpentHumanReadable,
+ docsUrl: '/help/workflow/time_tracking.md',
+ };
+
+ const TimeTrackingComponent = Vue.component('issuable-time-tracker');
+ this.timeTracker = new TimeTrackingComponent({
+ el: '#mock-container',
+ propsData: this.initialData,
+ });
+}
+
+((gl) => {
+ describe('Issuable Time Tracker', function() {
+ describe('Initialization', function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
+ });
+
+ it('should return something defined', function() {
+ expect(this.timeTracker).toBeDefined();
+ });
+
+ it ('should correctly set timeEstimate', function(done) {
+ Vue.nextTick(() => {
+ expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate);
+ done();
+ });
+ });
+ it ('should correctly set time_spent', function(done) {
+ Vue.nextTick(() => {
+ expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent);
+ done();
+ });
+ });
+ });
+
+ describe('Content Display', function() {
+ describe('Panes', function() {
+ describe('Comparison pane', function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' });
+ });
+
+ it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) {
+ Vue.nextTick(() => {
+ const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane');
+ expect(this.timeTracker.showComparisonState).toBe(true);
+ done();
+ });
+ });
+
+ describe('Remaining meter', function() {
+ it('should display the remaining meter with the correct width', function(done) {
+ Vue.nextTick(() => {
+ const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width;
+ const correctWidth = '5%';
+
+ expect(meterWidth).toBe(correctWidth);
+ done();
+ })
+ });
+
+ it('should display the remaining meter with the correct background color when within estimate', function(done) {
+ Vue.nextTick(() => {
+ const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill');
+ expect(styledMeter.length).toBe(1);
+ done()
+ });
+ });
+
+ it('should display the remaining meter with the correct background color when over estimate', function(done) {
+ this.timeTracker.time_estimate = 100000;
+ this.timeTracker.time_spent = 20000000;
+ Vue.nextTick(() => {
+ const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill');
+ expect(styledMeter.length).toBe(1);
+ done();
+ });
+ });
+ });
+ });
+
+ describe("Estimate only pane", function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' });
+ });
+
+ it('should display the human readable version of time estimated', function(done) {
+ Vue.nextTick(() => {
+ const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText;
+ const correctText = 'Estimated: 2h 46m';
+
+ expect(estimateText).toBe(correctText);
+ done();
+ });
+ });
+ });
+
+ describe('Spent only pane', function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
+ });
+
+ it('should display the human readable version of time spent', function(done) {
+ Vue.nextTick(() => {
+ const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText;
+ const correctText = 'Spent: 1h 23m';
+
+ expect(spentText).toBe(correctText);
+ done();
+ });
+ });
+ });
+
+ describe('No time tracking pane', function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 });
+ });
+
+ it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) {
+ Vue.nextTick(() => {
+ const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane');
+ const noTrackingText =$noTrackingPane.innerText;
+ const correctText = 'No estimate or time spent';
+
+ expect(this.timeTracker.showNoTimeTrackingState).toBe(true);
+ expect($noTrackingPane).toBeVisible();
+ expect(noTrackingText).toBe(correctText);
+ done();
+ });
+ });
+ });
+
+ describe("Help pane", function() {
+ beforeEach(function() {
+ initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 });
+ });
+
+ it('should not show the "Help" pane by default', function(done) {
+ Vue.nextTick(() => {
+ const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
+
+ expect(this.timeTracker.showHelpState).toBe(false);
+ expect($helpPane).toBeNull();
+ done();
+ });
+ });
+
+ it('should show the "Help" pane when help button is clicked', function(done) {
+ Vue.nextTick(() => {
+ $(this.timeTracker.$el).find('.help-button').click();
+
+ setTimeout(() => {
+ const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
+ expect(this.timeTracker.showHelpState).toBe(true);
+ expect($helpPane).toBeVisible();
+ done();
+ }, 10);
+ });
+ });
+
+ it('should not show the "Help" pane when help button is clicked and then closed', function(done) {
+ Vue.nextTick(() => {
+ $(this.timeTracker.$el).find('.help-button').click();
+
+ setTimeout(() => {
+
+ $(this.timeTracker.$el).find('.close-help-button').click();
+
+ setTimeout(() => {
+ const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
+
+ expect(this.timeTracker.showHelpState).toBe(false);
+ expect($helpPane).toBeNull();
+
+ done();
+ }, 1000);
+ }, 1000);
+ });
+ });
+ });
+ });
+ });
+ });
+})(window.gl || (window.gl = {}));
diff --git a/spec/javascripts/pretty_time_spec.js.es6 b/spec/javascripts/pretty_time_spec.js.es6
index 2e12d45f7a7..7a04fba5f7f 100644
--- a/spec/javascripts/pretty_time_spec.js.es6
+++ b/spec/javascripts/pretty_time_spec.js.es6
@@ -1,12 +1,12 @@
//= require lib/utils/pretty_time
(() => {
- const PrettyTime = gl.PrettyTime;
+ const prettyTime = gl.utils.prettyTime;
- describe('PrettyTime methods', function () {
+ describe('prettyTime methods', function () {
describe('parseSeconds', function () {
it('should correctly parse a negative value', function () {
- const parser = PrettyTime.parseSeconds;
+ const parser = prettyTime.parseSeconds;
const zeroSeconds = parser(-1000);
@@ -17,7 +17,7 @@
});
it('should correctly parse a zero value', function () {
- const parser = PrettyTime.parseSeconds;
+ const parser = prettyTime.parseSeconds;
const zeroSeconds = parser(0);
@@ -28,7 +28,7 @@
});
it('should correctly parse a small non-zero second values', function () {
- const parser = PrettyTime.parseSeconds;
+ const parser = prettyTime.parseSeconds;
const subOneMinute = parser(10);
@@ -53,7 +53,7 @@
});
it('should correctly parse large second values', function () {
- const parser = PrettyTime.parseSeconds;
+ const parser = prettyTime.parseSeconds;
const aboveOneHour = parser(4800);
@@ -87,7 +87,7 @@
minutes: 20,
};
- const timeString = PrettyTime.stringifyTime(timeObject);
+ const timeString = prettyTime.stringifyTime(timeObject);
expect(timeString).toBe('1w 4d 7h 20m');
});
@@ -100,7 +100,7 @@
minutes: 20,
};
- const timeString = PrettyTime.stringifyTime(timeObject);
+ const timeString = prettyTime.stringifyTime(timeObject);
expect(timeString).toBe('4d 20m');
});
@@ -113,7 +113,7 @@
minutes: 0,
};
- const timeString = PrettyTime.stringifyTime(timeObject);
+ const timeString = prettyTime.stringifyTime(timeObject);
expect(timeString).toBe('0m');
});
@@ -122,12 +122,12 @@
describe('abbreviateTime', function () {
it('should abbreviate stringified times for weeks', function () {
const fullTimeString = '1w 3d 4h 5m';
- expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('1w');
+ expect(prettyTime.abbreviateTime(fullTimeString)).toBe('1w');
});
it('should abbreviate stringified times for non-weeks', function () {
const fullTimeString = '0w 3d 4h 5m';
- expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('3d');
+ expect(prettyTime.abbreviateTime(fullTimeString)).toBe('3d');
});
});
});
diff --git a/spec/javascripts/visibility_select_spec.js.es6 b/spec/javascripts/visibility_select_spec.js.es6
new file mode 100644
index 00000000000..b21f6912e06
--- /dev/null
+++ b/spec/javascripts/visibility_select_spec.js.es6
@@ -0,0 +1,100 @@
+/*= require visibility_select */
+
+(() => {
+ const VisibilitySelect = gl.VisibilitySelect;
+
+ describe('VisibilitySelect', function () {
+ const lockedElement = document.createElement('div');
+ lockedElement.dataset.helpBlock = 'lockedHelpBlock';
+
+ const checkedElement = document.createElement('div');
+ checkedElement.dataset.description = 'checkedDescription';
+
+ const mockElements = {
+ container: document.createElement('div'),
+ select: document.createElement('div'),
+ '.help-block': document.createElement('div'),
+ '.js-locked': lockedElement,
+ 'option:checked': checkedElement,
+ };
+
+ beforeEach(function () {
+ spyOn(Element.prototype, 'querySelector').and.callFake(selector => mockElements[selector]);
+ });
+
+ describe('#constructor', function () {
+ beforeEach(function () {
+ this.visibilitySelect = new VisibilitySelect(mockElements.container);
+ });
+
+ it('sets the container member', function () {
+ expect(this.visibilitySelect.container).toEqual(mockElements.container);
+ });
+
+ it('queries and sets the helpBlock member', function () {
+ expect(Element.prototype.querySelector).toHaveBeenCalledWith('.help-block');
+ expect(this.visibilitySelect.helpBlock).toEqual(mockElements['.help-block']);
+ });
+
+ it('queries and sets the select member', function () {
+ expect(Element.prototype.querySelector).toHaveBeenCalledWith('select');
+ expect(this.visibilitySelect.select).toEqual(mockElements.select);
+ });
+
+ describe('if there is no container element provided', function () {
+ it('throws an error', function () {
+ expect(() => new VisibilitySelect()).toThrowError('VisibilitySelect requires a container element as argument 1');
+ });
+ });
+ });
+
+ describe('#init', function () {
+ describe('if there is a select', function () {
+ beforeEach(function () {
+ this.visibilitySelect = new VisibilitySelect(mockElements.container);
+ });
+
+ it('calls updateHelpText', function () {
+ spyOn(VisibilitySelect.prototype, 'updateHelpText');
+ this.visibilitySelect.init();
+ expect(this.visibilitySelect.updateHelpText).toHaveBeenCalled();
+ });
+
+ it('adds a change event listener', function () {
+ spyOn(this.visibilitySelect.select, 'addEventListener');
+ this.visibilitySelect.init();
+ expect(this.visibilitySelect.select.addEventListener.calls.argsFor(0)).toContain('change');
+ });
+ });
+
+ describe('if there is no select', function () {
+ beforeEach(function () {
+ mockElements.select = undefined;
+ this.visibilitySelect = new VisibilitySelect(mockElements.container);
+ this.visibilitySelect.init();
+ });
+
+ it('updates the helpBlock text to the locked `data-help-block` messaged', function () {
+ expect(this.visibilitySelect.helpBlock.textContent)
+ .toEqual(lockedElement.dataset.helpBlock);
+ });
+
+ afterEach(function () {
+ mockElements.select = document.createElement('div');
+ });
+ });
+ });
+
+ describe('#updateHelpText', function () {
+ beforeEach(function () {
+ this.visibilitySelect = new VisibilitySelect(mockElements.container);
+ this.visibilitySelect.init();
+ });
+
+ it('updates the helpBlock text to the selected options `data-description`', function () {
+ expect(this.visibilitySelect.helpBlock.textContent)
+ .toEqual(checkedElement.dataset.description);
+ });
+ });
+ });
+})();
diff --git a/spec/lib/gitlab/ci/status/external/common_spec.rb b/spec/lib/gitlab/ci/status/external/common_spec.rb
new file mode 100644
index 00000000000..5a97d98b55f
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/external/common_spec.rb
@@ -0,0 +1,39 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::External::Common do
+ let(:user) { create(:user) }
+ let(:project) { external_status.project }
+ let(:external_target_url) { 'http://example.gitlab.com/status' }
+
+ let(:external_status) do
+ create(:generic_commit_status, target_url: external_target_url)
+ end
+
+ subject do
+ Gitlab::Ci::Status::Core
+ .new(external_status, user)
+ .extend(described_class)
+ end
+
+ describe '#has_action?' do
+ it { is_expected.not_to have_action }
+ end
+
+ describe '#has_details?' do
+ context 'when user has access to read commit status' do
+ before { project.team << [user, :developer] }
+
+ it { is_expected.to have_details }
+ end
+
+ context 'when user does not have access to read commit status' do
+ it { is_expected.not_to have_details }
+ end
+ end
+
+ describe '#details_path' do
+ it 'links to the external target URL' do
+ expect(subject.details_path).to eq external_target_url
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/status/external/factory_spec.rb b/spec/lib/gitlab/ci/status/external/factory_spec.rb
new file mode 100644
index 00000000000..c96fd53e730
--- /dev/null
+++ b/spec/lib/gitlab/ci/status/external/factory_spec.rb
@@ -0,0 +1,38 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Status::External::Factory do
+ let(:user) { create(:user) }
+ let(:project) { resource.project }
+ let(:status) { factory.fabricate! }
+ let(:factory) { described_class.new(resource, user) }
+ let(:external_url) { 'http://gitlab.com/status' }
+
+ before do
+ project.team << [user, :developer]
+ end
+
+ context 'when external status has a simple core status' do
+ HasStatus::AVAILABLE_STATUSES.each do |simple_status|
+ context "when core status is #{simple_status}" do
+ let(:resource) do
+ create(:generic_commit_status, status: simple_status,
+ target_url: external_url)
+ end
+
+ let(:expected_status) do
+ Gitlab::Ci::Status.const_get(simple_status.capitalize)
+ end
+
+ it "fabricates a core status #{simple_status}" do
+ expect(status).to be_a expected_status
+ end
+
+ it 'extends core status with common methods' do
+ expect(status).to have_details
+ expect(status).not_to have_action
+ expect(status.details_path).to eq external_url
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/code_event_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/code_event_fetcher_spec.rb
new file mode 100644
index 00000000000..0267e8c2f69
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/code_event_fetcher_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::CodeEventFetcher do
+ let(:stage_name) { :code }
+
+ it_behaves_like 'default query config' do
+ it 'has a default order' do
+ expect(event.order).not_to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/code_event_spec.rb b/spec/lib/gitlab/cycle_analytics/code_event_spec.rb
deleted file mode 100644
index 43f42d1bde8..00000000000
--- a/spec/lib/gitlab/cycle_analytics/code_event_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'spec_helper'
-require 'lib/gitlab/cycle_analytics/shared_event_spec'
-
-describe Gitlab::CycleAnalytics::CodeEvent do
- it_behaves_like 'default query config' do
- it 'does not have the default order' do
- expect(event.order).not_to eq(event.start_time_attrs)
- end
- end
-end
diff --git a/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb
new file mode 100644
index 00000000000..e8fc67acf05
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/code_stage_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_stage_spec'
+
+describe Gitlab::CycleAnalytics::CodeStage do
+ let(:stage_name) { :code }
+
+ it_behaves_like 'base stage'
+end
diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb
index 6062e7af4f5..9d2ba481919 100644
--- a/spec/lib/gitlab/cycle_analytics/events_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb
@@ -1,12 +1,14 @@
require 'spec_helper'
-describe Gitlab::CycleAnalytics::Events do
+describe 'cycle analytics events' do
let(:project) { create(:project) }
let(:from_date) { 10.days.ago }
let(:user) { create(:user, :admin) }
let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
- subject { described_class.new(project: project, options: { from: from_date, current_user: user }) }
+ let(:events) do
+ CycleAnalytics.new(project, { from: from_date, current_user: user })[stage].events
+ end
before do
allow_any_instance_of(Gitlab::ReferenceExtractor).to receive(:issues).and_return([context])
@@ -15,104 +17,112 @@ describe Gitlab::CycleAnalytics::Events do
end
describe '#issue_events' do
+ let(:stage) { :issue }
+
it 'has the total time' do
- expect(subject.issue_events.first[:total_time]).not_to be_empty
+ expect(events.first[:total_time]).not_to be_empty
end
it 'has a title' do
- expect(subject.issue_events.first[:title]).to eq(context.title)
+ expect(events.first[:title]).to eq(context.title)
end
it 'has the URL' do
- expect(subject.issue_events.first[:url]).not_to be_nil
+ expect(events.first[:url]).not_to be_nil
end
it 'has an iid' do
- expect(subject.issue_events.first[:iid]).to eq(context.iid.to_s)
+ expect(events.first[:iid]).to eq(context.iid.to_s)
end
it 'has a created_at timestamp' do
- expect(subject.issue_events.first[:created_at]).to end_with('ago')
+ expect(events.first[:created_at]).to end_with('ago')
end
it "has the author's URL" do
- expect(subject.issue_events.first[:author][:web_url]).not_to be_nil
+ expect(events.first[:author][:web_url]).not_to be_nil
end
it "has the author's avatar URL" do
- expect(subject.issue_events.first[:author][:avatar_url]).not_to be_nil
+ expect(events.first[:author][:avatar_url]).not_to be_nil
end
it "has the author's name" do
- expect(subject.issue_events.first[:author][:name]).to eq(context.author.name)
+ expect(events.first[:author][:name]).to eq(context.author.name)
end
end
describe '#plan_events' do
+ let(:stage) { :plan }
+
it 'has a title' do
- expect(subject.plan_events.first[:title]).not_to be_nil
+ expect(events.first[:title]).not_to be_nil
end
it 'has a sha short ID' do
- expect(subject.plan_events.first[:short_sha]).not_to be_nil
+ expect(events.first[:short_sha]).not_to be_nil
end
it 'has the URL' do
- expect(subject.plan_events.first[:commit_url]).not_to be_nil
+ expect(events.first[:commit_url]).not_to be_nil
end
it 'has the total time' do
- expect(subject.plan_events.first[:total_time]).not_to be_empty
+ expect(events.first[:total_time]).not_to be_empty
end
it "has the author's URL" do
- expect(subject.plan_events.first[:author][:web_url]).not_to be_nil
+ expect(events.first[:author][:web_url]).not_to be_nil
end
it "has the author's avatar URL" do
- expect(subject.plan_events.first[:author][:avatar_url]).not_to be_nil
+ expect(events.first[:author][:avatar_url]).not_to be_nil
end
it "has the author's name" do
- expect(subject.plan_events.first[:author][:name]).not_to be_nil
+ expect(events.first[:author][:name]).not_to be_nil
end
end
describe '#code_events' do
+ let(:stage) { :code }
+
before do
create_commit_referencing_issue(context)
end
it 'has the total time' do
- expect(subject.code_events.first[:total_time]).not_to be_empty
+ expect(events.first[:total_time]).not_to be_empty
end
it 'has a title' do
- expect(subject.code_events.first[:title]).to eq('Awesome merge_request')
+ expect(events.first[:title]).to eq('Awesome merge_request')
end
it 'has an iid' do
- expect(subject.code_events.first[:iid]).to eq(context.iid.to_s)
+ expect(events.first[:iid]).to eq(context.iid.to_s)
end
it 'has a created_at timestamp' do
- expect(subject.code_events.first[:created_at]).to end_with('ago')
+ expect(events.first[:created_at]).to end_with('ago')
end
it "has the author's URL" do
- expect(subject.code_events.first[:author][:web_url]).not_to be_nil
+ expect(events.first[:author][:web_url]).not_to be_nil
end
it "has the author's avatar URL" do
- expect(subject.code_events.first[:author][:avatar_url]).not_to be_nil
+ expect(events.first[:author][:avatar_url]).not_to be_nil
end
it "has the author's name" do
- expect(subject.code_events.first[:author][:name]).to eq(MergeRequest.first.author.name)
+ expect(events.first[:author][:name]).to eq(MergeRequest.first.author.name)
end
end
describe '#test_events' do
+ let(:stage) { :test }
+
let(:merge_request) { MergeRequest.first }
let!(:pipeline) do
create(:ci_pipeline,
@@ -130,83 +140,85 @@ describe Gitlab::CycleAnalytics::Events do
end
it 'has the name' do
- expect(subject.test_events.first[:name]).not_to be_nil
+ expect(events.first[:name]).not_to be_nil
end
it 'has the ID' do
- expect(subject.test_events.first[:id]).not_to be_nil
+ expect(events.first[:id]).not_to be_nil
end
it 'has the URL' do
- expect(subject.test_events.first[:url]).not_to be_nil
+ expect(events.first[:url]).not_to be_nil
end
it 'has the branch name' do
- expect(subject.test_events.first[:branch]).not_to be_nil
+ expect(events.first[:branch]).not_to be_nil
end
it 'has the branch URL' do
- expect(subject.test_events.first[:branch][:url]).not_to be_nil
+ expect(events.first[:branch][:url]).not_to be_nil
end
it 'has the short SHA' do
- expect(subject.test_events.first[:short_sha]).not_to be_nil
+ expect(events.first[:short_sha]).not_to be_nil
end
it 'has the commit URL' do
- expect(subject.test_events.first[:commit_url]).not_to be_nil
+ expect(events.first[:commit_url]).not_to be_nil
end
it 'has the date' do
- expect(subject.test_events.first[:date]).not_to be_nil
+ expect(events.first[:date]).not_to be_nil
end
it 'has the total time' do
- expect(subject.test_events.first[:total_time]).not_to be_empty
+ expect(events.first[:total_time]).not_to be_empty
end
end
describe '#review_events' do
+ let(:stage) { :review }
let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
it 'has the total time' do
- expect(subject.review_events.first[:total_time]).not_to be_empty
+ expect(events.first[:total_time]).not_to be_empty
end
it 'has a title' do
- expect(subject.review_events.first[:title]).to eq('Awesome merge_request')
+ expect(events.first[:title]).to eq('Awesome merge_request')
end
it 'has an iid' do
- expect(subject.review_events.first[:iid]).to eq(context.iid.to_s)
+ expect(events.first[:iid]).to eq(context.iid.to_s)
end
it 'has the URL' do
- expect(subject.review_events.first[:url]).not_to be_nil
+ expect(events.first[:url]).not_to be_nil
end
it 'has a state' do
- expect(subject.review_events.first[:state]).not_to be_nil
+ expect(events.first[:state]).not_to be_nil
end
it 'has a created_at timestamp' do
- expect(subject.review_events.first[:created_at]).not_to be_nil
+ expect(events.first[:created_at]).not_to be_nil
end
it "has the author's URL" do
- expect(subject.review_events.first[:author][:web_url]).not_to be_nil
+ expect(events.first[:author][:web_url]).not_to be_nil
end
it "has the author's avatar URL" do
- expect(subject.review_events.first[:author][:avatar_url]).not_to be_nil
+ expect(events.first[:author][:avatar_url]).not_to be_nil
end
it "has the author's name" do
- expect(subject.review_events.first[:author][:name]).to eq(MergeRequest.first.author.name)
+ expect(events.first[:author][:name]).to eq(MergeRequest.first.author.name)
end
end
describe '#staging_events' do
+ let(:stage) { :staging }
let(:merge_request) { MergeRequest.first }
let!(:pipeline) do
create(:ci_pipeline,
@@ -227,55 +239,56 @@ describe Gitlab::CycleAnalytics::Events do
end
it 'has the name' do
- expect(subject.staging_events.first[:name]).not_to be_nil
+ expect(events.first[:name]).not_to be_nil
end
it 'has the ID' do
- expect(subject.staging_events.first[:id]).not_to be_nil
+ expect(events.first[:id]).not_to be_nil
end
it 'has the URL' do
- expect(subject.staging_events.first[:url]).not_to be_nil
+ expect(events.first[:url]).not_to be_nil
end
it 'has the branch name' do
- expect(subject.staging_events.first[:branch]).not_to be_nil
+ expect(events.first[:branch]).not_to be_nil
end
it 'has the branch URL' do
- expect(subject.staging_events.first[:branch][:url]).not_to be_nil
+ expect(events.first[:branch][:url]).not_to be_nil
end
it 'has the short SHA' do
- expect(subject.staging_events.first[:short_sha]).not_to be_nil
+ expect(events.first[:short_sha]).not_to be_nil
end
it 'has the commit URL' do
- expect(subject.staging_events.first[:commit_url]).not_to be_nil
+ expect(events.first[:commit_url]).not_to be_nil
end
it 'has the date' do
- expect(subject.staging_events.first[:date]).not_to be_nil
+ expect(events.first[:date]).not_to be_nil
end
it 'has the total time' do
- expect(subject.staging_events.first[:total_time]).not_to be_empty
+ expect(events.first[:total_time]).not_to be_empty
end
it "has the author's URL" do
- expect(subject.staging_events.first[:author][:web_url]).not_to be_nil
+ expect(events.first[:author][:web_url]).not_to be_nil
end
it "has the author's avatar URL" do
- expect(subject.staging_events.first[:author][:avatar_url]).not_to be_nil
+ expect(events.first[:author][:avatar_url]).not_to be_nil
end
it "has the author's name" do
- expect(subject.staging_events.first[:author][:name]).to eq(MergeRequest.first.author.name)
+ expect(events.first[:author][:name]).to eq(MergeRequest.first.author.name)
end
end
describe '#production_events' do
+ let(:stage) { :production }
let!(:context) { create(:issue, project: project, created_at: 2.days.ago) }
before do
@@ -284,35 +297,35 @@ describe Gitlab::CycleAnalytics::Events do
end
it 'has the total time' do
- expect(subject.production_events.first[:total_time]).not_to be_empty
+ expect(events.first[:total_time]).not_to be_empty
end
it 'has a title' do
- expect(subject.production_events.first[:title]).to eq(context.title)
+ expect(events.first[:title]).to eq(context.title)
end
it 'has the URL' do
- expect(subject.production_events.first[:url]).not_to be_nil
+ expect(events.first[:url]).not_to be_nil
end
it 'has an iid' do
- expect(subject.production_events.first[:iid]).to eq(context.iid.to_s)
+ expect(events.first[:iid]).to eq(context.iid.to_s)
end
it 'has a created_at timestamp' do
- expect(subject.production_events.first[:created_at]).to end_with('ago')
+ expect(events.first[:created_at]).to end_with('ago')
end
it "has the author's URL" do
- expect(subject.production_events.first[:author][:web_url]).not_to be_nil
+ expect(events.first[:author][:web_url]).not_to be_nil
end
it "has the author's avatar URL" do
- expect(subject.production_events.first[:author][:avatar_url]).not_to be_nil
+ expect(events.first[:author][:avatar_url]).not_to be_nil
end
it "has the author's name" do
- expect(subject.production_events.first[:author][:name]).to eq(context.author.name)
+ expect(events.first[:author][:name]).to eq(context.author.name)
end
end
diff --git a/spec/lib/gitlab/cycle_analytics/issue_event_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/issue_event_fetcher_spec.rb
new file mode 100644
index 00000000000..fd9fa2fee49
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/issue_event_fetcher_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::IssueEventFetcher do
+ let(:stage_name) { :issue }
+
+ it_behaves_like 'default query config'
+end
diff --git a/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb b/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb
deleted file mode 100644
index 1c5c308da7d..00000000000
--- a/spec/lib/gitlab/cycle_analytics/issue_event_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'spec_helper'
-require 'lib/gitlab/cycle_analytics/shared_event_spec'
-
-describe Gitlab::CycleAnalytics::IssueEvent do
- it_behaves_like 'default query config' do
- it 'has the default order' do
- expect(event.order).to eq(event.start_time_attrs)
- end
- end
-end
diff --git a/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb
new file mode 100644
index 00000000000..3127f01989d
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/issue_stage_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_stage_spec'
+
+describe Gitlab::CycleAnalytics::IssueStage do
+ let(:stage_name) { :issue }
+
+ it_behaves_like 'base stage'
+end
diff --git a/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb b/spec/lib/gitlab/cycle_analytics/plan_event_fetcher_spec.rb
index 4a5604115ec..2e5dc5b5547 100644
--- a/spec/lib/gitlab/cycle_analytics/plan_event_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/plan_event_fetcher_spec.rb
@@ -1,15 +1,13 @@
require 'spec_helper'
require 'lib/gitlab/cycle_analytics/shared_event_spec'
-describe Gitlab::CycleAnalytics::PlanEvent do
- it_behaves_like 'default query config' do
- it 'has the default order' do
- expect(event.order).to eq(event.start_time_attrs)
- end
+describe Gitlab::CycleAnalytics::PlanEventFetcher do
+ let(:stage_name) { :plan }
+ it_behaves_like 'default query config' do
context 'no commits' do
it 'does not blow up if there are no commits' do
- allow_any_instance_of(Gitlab::CycleAnalytics::EventsQuery).to receive(:execute).and_return([{}])
+ allow(event).to receive(:event_result).and_return([{}])
expect { event.fetch }.not_to raise_error
end
diff --git a/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb
new file mode 100644
index 00000000000..4c715921ad6
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/plan_stage_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_stage_spec'
+
+describe Gitlab::CycleAnalytics::PlanStage do
+ let(:stage_name) { :plan }
+
+ it_behaves_like 'base stage'
+end
diff --git a/spec/lib/gitlab/cycle_analytics/production_event_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/production_event_fetcher_spec.rb
new file mode 100644
index 00000000000..74001181305
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/production_event_fetcher_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::ProductionEventFetcher do
+ let(:stage_name) { :production }
+
+ it_behaves_like 'default query config'
+end
diff --git a/spec/lib/gitlab/cycle_analytics/production_event_spec.rb b/spec/lib/gitlab/cycle_analytics/production_event_spec.rb
deleted file mode 100644
index ac17e3b4287..00000000000
--- a/spec/lib/gitlab/cycle_analytics/production_event_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'spec_helper'
-require 'lib/gitlab/cycle_analytics/shared_event_spec'
-
-describe Gitlab::CycleAnalytics::ProductionEvent do
- it_behaves_like 'default query config' do
- it 'has the default order' do
- expect(event.order).to eq(event.start_time_attrs)
- end
- end
-end
diff --git a/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb
new file mode 100644
index 00000000000..916684b81eb
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/production_stage_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_stage_spec'
+
+describe Gitlab::CycleAnalytics::ProductionStage do
+ let(:stage_name) { :production }
+
+ it_behaves_like 'base stage'
+end
diff --git a/spec/lib/gitlab/cycle_analytics/review_event_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/review_event_fetcher_spec.rb
new file mode 100644
index 00000000000..4f67c95ed4c
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/review_event_fetcher_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::ReviewEventFetcher do
+ let(:stage_name) { :review }
+
+ it_behaves_like 'default query config'
+end
diff --git a/spec/lib/gitlab/cycle_analytics/review_event_spec.rb b/spec/lib/gitlab/cycle_analytics/review_event_spec.rb
deleted file mode 100644
index 1ff53aa0227..00000000000
--- a/spec/lib/gitlab/cycle_analytics/review_event_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'spec_helper'
-require 'lib/gitlab/cycle_analytics/shared_event_spec'
-
-describe Gitlab::CycleAnalytics::ReviewEvent do
- it_behaves_like 'default query config' do
- it 'has the default order' do
- expect(event.order).to eq(event.start_time_attrs)
- end
- end
-end
diff --git a/spec/lib/gitlab/cycle_analytics/review_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/review_stage_spec.rb
new file mode 100644
index 00000000000..1412c8dfa08
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/review_stage_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_stage_spec'
+
+describe Gitlab::CycleAnalytics::ReviewStage do
+ let(:stage_name) { :review }
+
+ it_behaves_like 'base stage'
+end
diff --git a/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb
index 7019e4c3351..9c5e57342e9 100644
--- a/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/shared_event_spec.rb
@@ -1,20 +1,13 @@
require 'spec_helper'
shared_examples 'default query config' do
- let(:event) { described_class.new(project: double, options: {}) }
-
- it 'has the start attributes' do
- expect(event.start_time_attrs).not_to be_nil
- end
+ let(:project) { create(:empty_project) }
+ let(:event) { described_class.new(project: project, stage: stage_name, options: { from: 1.day.ago }) }
it 'has the stage attribute' do
expect(event.stage).not_to be_nil
end
- it 'has the end attributes' do
- expect(event.end_time_attrs).not_to be_nil
- end
-
it 'has the projection attributes' do
expect(event.projections).not_to be_nil
end
diff --git a/spec/lib/gitlab/cycle_analytics/shared_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/shared_stage_spec.rb
new file mode 100644
index 00000000000..08425acbfc8
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/shared_stage_spec.rb
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+shared_examples 'base stage' do
+ let(:stage) { described_class.new(project: double, options: {}) }
+
+ before do
+ allow(stage).to receive(:median).and_return(1.12)
+ allow_any_instance_of(Gitlab::CycleAnalytics::BaseEventFetcher).to receive(:event_result).and_return({})
+ end
+
+ it 'has the median data value' do
+ expect(stage.as_json[:value]).not_to be_nil
+ end
+
+ it 'has the median data stage' do
+ expect(stage.as_json[:title]).not_to be_nil
+ end
+
+ it 'has the median data description' do
+ expect(stage.as_json[:description]).not_to be_nil
+ end
+
+ it 'has the title' do
+ expect(stage.title).to eq(stage_name.to_s.capitalize)
+ end
+
+ it 'has the events' do
+ expect(stage.events).not_to be_nil
+ end
+end
diff --git a/spec/models/cycle_analytics/summary_spec.rb b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
index 725bc68b25f..fb6b6c4a8d2 100644
--- a/spec/models/cycle_analytics/summary_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/stage_summary_spec.rb
@@ -1,23 +1,23 @@
require 'spec_helper'
-describe CycleAnalytics::Summary, models: true do
+describe Gitlab::CycleAnalytics::StageSummary, models: true do
let(:project) { create(:project) }
- let(:from) { Time.now }
+ let(:from) { 1.day.ago }
let(:user) { create(:user, :admin) }
- subject { described_class.new(project, user, from: from) }
+ subject { described_class.new(project, from: Time.now, current_user: user).data }
describe "#new_issues" do
it "finds the number of issues created after the 'from date'" do
Timecop.freeze(5.days.ago) { create(:issue, project: project) }
Timecop.freeze(5.days.from_now) { create(:issue, project: project) }
- expect(subject.new_issues).to eq(1)
+ expect(subject.first[:value]).to eq(1)
end
it "doesn't find issues from other projects" do
Timecop.freeze(5.days.from_now) { create(:issue, project: create(:project)) }
- expect(subject.new_issues).to eq(0)
+ expect(subject.first[:value]).to eq(0)
end
end
@@ -26,19 +26,19 @@ describe CycleAnalytics::Summary, models: true do
Timecop.freeze(5.days.ago) { create_commit("Test message", project, user, 'master') }
Timecop.freeze(5.days.from_now) { create_commit("Test message", project, user, 'master') }
- expect(subject.commits).to eq(1)
+ expect(subject.second[:value]).to eq(1)
end
it "doesn't find commits from other projects" do
Timecop.freeze(5.days.from_now) { create_commit("Test message", create(:project), user, 'master') }
- expect(subject.commits).to eq(0)
+ expect(subject.second[:value]).to eq(0)
end
it "finds a large (> 100) snumber of commits if present" do
Timecop.freeze(5.days.from_now) { create_commit("Test message", project, user, 'master', count: 100) }
- expect(subject.commits).to eq(100)
+ expect(subject.second[:value]).to eq(100)
end
end
@@ -47,13 +47,13 @@ describe CycleAnalytics::Summary, models: true do
Timecop.freeze(5.days.ago) { create(:deployment, project: project) }
Timecop.freeze(5.days.from_now) { create(:deployment, project: project) }
- expect(subject.deploys).to eq(1)
+ expect(subject.third[:value]).to eq(1)
end
it "doesn't find commits from other projects" do
Timecop.freeze(5.days.from_now) { create(:deployment, project: create(:project)) }
- expect(subject.deploys).to eq(0)
+ expect(subject.third[:value]).to eq(0)
end
end
end
diff --git a/spec/lib/gitlab/cycle_analytics/staging_event_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/staging_event_fetcher_spec.rb
new file mode 100644
index 00000000000..bbc82496340
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/staging_event_fetcher_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::StagingEventFetcher do
+ let(:stage_name) { :staging }
+
+ it_behaves_like 'default query config' do
+ it 'has a default order' do
+ expect(event.order).not_to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb b/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb
deleted file mode 100644
index 4862d4765f2..00000000000
--- a/spec/lib/gitlab/cycle_analytics/staging_event_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'spec_helper'
-require 'lib/gitlab/cycle_analytics/shared_event_spec'
-
-describe Gitlab::CycleAnalytics::StagingEvent do
- it_behaves_like 'default query config' do
- it 'does not have the default order' do
- expect(event.order).not_to eq(event.start_time_attrs)
- end
- end
-end
diff --git a/spec/lib/gitlab/cycle_analytics/staging_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/staging_stage_spec.rb
new file mode 100644
index 00000000000..8154b3ac701
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/staging_stage_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_stage_spec'
+
+describe Gitlab::CycleAnalytics::StagingStage do
+ let(:stage_name) { :staging }
+
+ it_behaves_like 'base stage'
+end
diff --git a/spec/lib/gitlab/cycle_analytics/test_event_fetcher_spec.rb b/spec/lib/gitlab/cycle_analytics/test_event_fetcher_spec.rb
new file mode 100644
index 00000000000..6639fa54e0e
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/test_event_fetcher_spec.rb
@@ -0,0 +1,12 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_event_spec'
+
+describe Gitlab::CycleAnalytics::TestEventFetcher do
+ let(:stage_name) { :test }
+
+ it_behaves_like 'default query config' do
+ it 'has a default order' do
+ expect(event.order).not_to be_nil
+ end
+ end
+end
diff --git a/spec/lib/gitlab/cycle_analytics/test_event_spec.rb b/spec/lib/gitlab/cycle_analytics/test_event_spec.rb
deleted file mode 100644
index e249db69fc6..00000000000
--- a/spec/lib/gitlab/cycle_analytics/test_event_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'spec_helper'
-require 'lib/gitlab/cycle_analytics/shared_event_spec'
-
-describe Gitlab::CycleAnalytics::TestEvent do
- it_behaves_like 'default query config' do
- it 'does not have the default order' do
- expect(event.order).not_to eq(event.start_time_attrs)
- end
- end
-end
diff --git a/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb b/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb
new file mode 100644
index 00000000000..eacde22cd56
--- /dev/null
+++ b/spec/lib/gitlab/cycle_analytics/test_stage_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+require 'lib/gitlab/cycle_analytics/shared_stage_spec'
+
+describe Gitlab::CycleAnalytics::TestStage do
+ let(:stage_name) { :test }
+
+ it_behaves_like 'base stage'
+end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index ceed9c942c1..7fb6829f582 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -15,6 +15,7 @@ issues:
- events
- merge_requests_closing_issues
- metrics
+- timelogs
events:
- author
- project
@@ -77,6 +78,7 @@ merge_requests:
- events
- merge_requests_closing_issues
- metrics
+- timelogs
merge_request_diff:
- merge_request
pipelines:
@@ -198,3 +200,6 @@ award_emoji:
- user
priorities:
- label
+timelogs:
+- trackable
+- user
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index d88a141b458..493bc2db21a 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -20,6 +20,7 @@ Issue:
- lock_version
- milestone_id
- weight
+- time_estimate
Event:
- id
- target_type
@@ -150,6 +151,7 @@ MergeRequest:
- milestone_id
- approvals_before_merge
- rebase_commit_sha
+- time_estimate
MergeRequestDiff:
- id
- state
@@ -344,3 +346,11 @@ LabelPriority:
- priority
- created_at
- updated_at
+Timelog:
+- id
+- time_spent
+- trackable_id
+- trackable_type
+- user_id
+- created_at
+- updated_at
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index 1078c959419..d7d31892e12 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -408,4 +408,42 @@ describe Issue, "Issuable" do
expect(issue.assignee_or_author?(user)).to eq(false)
end
end
+
+ describe '#spend_time' do
+ let(:user) { create(:user) }
+ let(:issue) { create(:issue) }
+
+ def spend_time(seconds)
+ issue.spend_time(duration: seconds, user: user)
+ issue.save!
+ end
+
+ context 'adding time' do
+ it 'should update the total time spent' do
+ spend_time(1800)
+
+ expect(issue.total_time_spent).to eq(1800)
+ end
+ end
+
+ context 'substracting time' do
+ before do
+ spend_time(1800)
+ end
+
+ it 'should update the total time spent' do
+ spend_time(-900)
+
+ expect(issue.total_time_spent).to eq(900)
+ end
+
+ context 'when time to substract exceeds the total time spent' do
+ it 'raise a validation error' do
+ expect do
+ spend_time(-3600)
+ end.to raise_error(ActiveRecord::RecordInvalid)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/cycle_analytics/code_spec.rb b/spec/models/cycle_analytics/code_spec.rb
index 7771785ead3..70f985afefb 100644
--- a/spec/models/cycle_analytics/code_spec.rb
+++ b/spec/models/cycle_analytics/code_spec.rb
@@ -6,7 +6,7 @@ describe 'CycleAnalytics#code', feature: true do
let(:project) { create(:project) }
let(:from_date) { 10.days.ago }
let(:user) { create(:user, :admin) }
- subject { CycleAnalytics.new(project, user, from: from_date) }
+ subject { CycleAnalytics.new(project, from: from_date) }
context 'with deployment' do
generate_cycle_analytics_spec(
@@ -16,10 +16,10 @@ describe 'CycleAnalytics#code', feature: true do
-> (context, data) do
context.create_commit_referencing_issue(data[:issue])
end]],
- end_time_conditions: [["merge request that closes issue is created",
- -> (context, data) do
- context.create_merge_request_closing_issue(data[:issue])
- end]],
+ end_time_conditions: [["merge request that closes issue is created",
+ -> (context, data) do
+ context.create_merge_request_closing_issue(data[:issue])
+ end]],
post_fn: -> (context, data) do
context.merge_merge_requests_closing_issue(data[:issue])
context.deploy_master
@@ -37,7 +37,7 @@ describe 'CycleAnalytics#code', feature: true do
deploy_master
end
- expect(subject.code).to be_nil
+ expect(subject[:code].median).to be_nil
end
end
end
@@ -50,10 +50,10 @@ describe 'CycleAnalytics#code', feature: true do
-> (context, data) do
context.create_commit_referencing_issue(data[:issue])
end]],
- end_time_conditions: [["merge request that closes issue is created",
- -> (context, data) do
- context.create_merge_request_closing_issue(data[:issue])
- end]],
+ end_time_conditions: [["merge request that closes issue is created",
+ -> (context, data) do
+ context.create_merge_request_closing_issue(data[:issue])
+ end]],
post_fn: -> (context, data) do
context.merge_merge_requests_closing_issue(data[:issue])
end)
@@ -69,7 +69,7 @@ describe 'CycleAnalytics#code', feature: true do
merge_merge_requests_closing_issue(issue)
end
- expect(subject.code).to be_nil
+ expect(subject[:code].median).to be_nil
end
end
end
diff --git a/spec/models/cycle_analytics/issue_spec.rb b/spec/models/cycle_analytics/issue_spec.rb
index 5ed3d37f2fb..e4b6a8f4518 100644
--- a/spec/models/cycle_analytics/issue_spec.rb
+++ b/spec/models/cycle_analytics/issue_spec.rb
@@ -6,7 +6,7 @@ describe 'CycleAnalytics#issue', models: true do
let(:project) { create(:project) }
let(:from_date) { 10.days.ago }
let(:user) { create(:user, :admin) }
- subject { CycleAnalytics.new(project, user, from: from_date) }
+ subject { CycleAnalytics.new(project, from: from_date) }
generate_cycle_analytics_spec(
phase: :issue,
@@ -42,7 +42,7 @@ describe 'CycleAnalytics#issue', models: true do
merge_merge_requests_closing_issue(issue)
end
- expect(subject.issue).to be_nil
+ expect(subject[:issue].median).to be_nil
end
end
end
diff --git a/spec/models/cycle_analytics/plan_spec.rb b/spec/models/cycle_analytics/plan_spec.rb
index baf3e3241a1..dc5b04852d6 100644
--- a/spec/models/cycle_analytics/plan_spec.rb
+++ b/spec/models/cycle_analytics/plan_spec.rb
@@ -6,7 +6,7 @@ describe 'CycleAnalytics#plan', feature: true do
let(:project) { create(:project) }
let(:from_date) { 10.days.ago }
let(:user) { create(:user, :admin) }
- subject { CycleAnalytics.new(project, user, from: from_date) }
+ subject { CycleAnalytics.new(project, from: from_date) }
generate_cycle_analytics_spec(
phase: :plan,
@@ -44,7 +44,7 @@ describe 'CycleAnalytics#plan', feature: true do
create_merge_request_closing_issue(issue, source_branch: branch_name)
merge_merge_requests_closing_issue(issue)
- expect(subject.issue).to be_nil
+ expect(subject[:issue].median).to be_nil
end
end
end
diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb
index 21b9c6e7150..5e99188f318 100644
--- a/spec/models/cycle_analytics/production_spec.rb
+++ b/spec/models/cycle_analytics/production_spec.rb
@@ -6,7 +6,7 @@ describe 'CycleAnalytics#production', feature: true do
let(:project) { create(:project) }
let(:from_date) { 10.days.ago }
let(:user) { create(:user, :admin) }
- subject { CycleAnalytics.new(project, user, from: from_date) }
+ subject { CycleAnalytics.new(project, from: from_date) }
generate_cycle_analytics_spec(
phase: :production,
@@ -35,7 +35,7 @@ describe 'CycleAnalytics#production', feature: true do
deploy_master
end
- expect(subject.production).to be_nil
+ expect(subject[:production].median).to be_nil
end
end
@@ -48,7 +48,7 @@ describe 'CycleAnalytics#production', feature: true do
deploy_master(environment: 'staging')
end
- expect(subject.production).to be_nil
+ expect(subject[:production].median).to be_nil
end
end
end
diff --git a/spec/models/cycle_analytics/review_spec.rb b/spec/models/cycle_analytics/review_spec.rb
index 158621d59a4..45baa5f7006 100644
--- a/spec/models/cycle_analytics/review_spec.rb
+++ b/spec/models/cycle_analytics/review_spec.rb
@@ -6,7 +6,7 @@ describe 'CycleAnalytics#review', feature: true do
let(:project) { create(:project) }
let(:from_date) { 10.days.ago }
let(:user) { create(:user, :admin) }
- subject { CycleAnalytics.new(project, user, from: from_date) }
+ subject { CycleAnalytics.new(project, from: from_date) }
generate_cycle_analytics_spec(
phase: :review,
@@ -27,7 +27,7 @@ describe 'CycleAnalytics#review', feature: true do
MergeRequests::MergeService.new(project, user).execute(create(:merge_request))
end
- expect(subject.review).to be_nil
+ expect(subject[:review].median).to be_nil
end
end
end
diff --git a/spec/models/cycle_analytics/staging_spec.rb b/spec/models/cycle_analytics/staging_spec.rb
index dad653964b7..77625aad580 100644
--- a/spec/models/cycle_analytics/staging_spec.rb
+++ b/spec/models/cycle_analytics/staging_spec.rb
@@ -6,7 +6,7 @@ describe 'CycleAnalytics#staging', feature: true do
let(:project) { create(:project) }
let(:from_date) { 10.days.ago }
let(:user) { create(:user, :admin) }
- subject { CycleAnalytics.new(project, user, from: from_date) }
+ subject { CycleAnalytics.new(project, from: from_date) }
generate_cycle_analytics_spec(
phase: :staging,
@@ -45,7 +45,7 @@ describe 'CycleAnalytics#staging', feature: true do
deploy_master
end
- expect(subject.staging).to be_nil
+ expect(subject[:staging].median).to be_nil
end
end
@@ -58,7 +58,7 @@ describe 'CycleAnalytics#staging', feature: true do
deploy_master(environment: 'staging')
end
- expect(subject.staging).to be_nil
+ expect(subject[:staging].median).to be_nil
end
end
end
diff --git a/spec/models/cycle_analytics/test_spec.rb b/spec/models/cycle_analytics/test_spec.rb
index 2313724e8f3..27a117d2d76 100644
--- a/spec/models/cycle_analytics/test_spec.rb
+++ b/spec/models/cycle_analytics/test_spec.rb
@@ -6,7 +6,7 @@ describe 'CycleAnalytics#test', feature: true do
let(:project) { create(:project) }
let(:from_date) { 10.days.ago }
let(:user) { create(:user, :admin) }
- subject { CycleAnalytics.new(project, user, from: from_date) }
+ subject { CycleAnalytics.new(project, from: from_date) }
generate_cycle_analytics_spec(
phase: :test,
@@ -35,7 +35,7 @@ describe 'CycleAnalytics#test', feature: true do
merge_merge_requests_closing_issue(issue)
end
- expect(subject.test).to be_nil
+ expect(subject[:test].median).to be_nil
end
end
@@ -48,7 +48,7 @@ describe 'CycleAnalytics#test', feature: true do
pipeline.succeed!
end
- expect(subject.test).to be_nil
+ expect(subject[:test].median).to be_nil
end
end
@@ -65,7 +65,7 @@ describe 'CycleAnalytics#test', feature: true do
merge_merge_requests_closing_issue(issue)
end
- expect(subject.test).to be_nil
+ expect(subject[:test].median).to be_nil
end
end
@@ -82,7 +82,7 @@ describe 'CycleAnalytics#test', feature: true do
merge_merge_requests_closing_issue(issue)
end
- expect(subject.test).to be_nil
+ expect(subject[:test].median).to be_nil
end
end
end
diff --git a/spec/models/generic_commit_status_spec.rb b/spec/models/generic_commit_status_spec.rb
index 6004bfdb7b7..f4c3e6d503f 100644
--- a/spec/models/generic_commit_status_spec.rb
+++ b/spec/models/generic_commit_status_spec.rb
@@ -1,10 +1,20 @@
require 'spec_helper'
describe GenericCommitStatus, models: true do
- let(:pipeline) { create(:ci_pipeline) }
+ let(:project) { create(:empty_project) }
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:external_url) { 'http://example.gitlab.com/status' }
let(:generic_commit_status) do
- create(:generic_commit_status, pipeline: pipeline)
+ create(:generic_commit_status, pipeline: pipeline,
+ target_url: external_url)
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_length_of(:target_url).is_at_most(255) }
+ it { is_expected.to allow_value(nil).for(:target_url) }
+ it { is_expected.to allow_value('http://gitlab.com/s').for(:target_url) }
+ it { is_expected.not_to allow_value('javascript:alert(1)').for(:target_url) }
end
describe '#context' do
@@ -22,10 +32,25 @@ describe GenericCommitStatus, models: true do
describe '#detailed_status' do
let(:user) { create(:user) }
+ let(:status) { generic_commit_status.detailed_status(user) }
it 'returns detailed status object' do
- expect(generic_commit_status.detailed_status(user))
- .to be_a Gitlab::Ci::Status::Success
+ expect(status).to be_a Gitlab::Ci::Status::Success
+ end
+
+ context 'when user has ability to see datails' do
+ before { project.team << [user, :developer] }
+
+ it 'details path points to an external URL' do
+ expect(status).to have_details
+ expect(status.details_path).to eq external_url
+ end
+ end
+
+ context 'when user should not see details' do
+ it 'does not have details' do
+ expect(status).not_to have_details
+ end
end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 861426acbc3..38c80ba53ad 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -773,10 +773,12 @@ describe MergeRequest, models: true do
subject { create(:merge_request, source_project: project, merge_status: :unchecked) }
context 'when it is not broken and has no conflicts' do
- it 'is marked as mergeable' do
+ before do
allow(subject).to receive(:broken?) { false }
allow(project.repository).to receive(:can_be_merged?).and_return(true)
+ end
+ it 'is marked as mergeable' do
expect { subject.check_if_can_be_merged }.to change { subject.merge_status }.to('can_be_merged')
end
end
@@ -787,6 +789,12 @@ describe MergeRequest, models: true do
it 'becomes unmergeable' do
expect { subject.check_if_can_be_merged }.to change { subject.merge_status }.to('cannot_be_merged')
end
+
+ it 'creates Todo on unmergeability' do
+ expect_any_instance_of(TodoService).to receive(:merge_request_became_unmergeable).with(subject)
+
+ subject.check_if_can_be_merged
+ end
end
context 'when it has conflicts' do
diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb
index 77403cc9eb0..ff29f6f66ba 100644
--- a/spec/models/project_statistics_spec.rb
+++ b/spec/models/project_statistics_spec.rb
@@ -107,7 +107,7 @@ describe ProjectStatistics, models: true do
describe '#update_repository_size' do
before do
- allow(project.repository).to receive(:size).and_return(12.megabytes)
+ allow(project.repository).to receive(:size).and_return(12)
statistics.update_repository_size
end
diff --git a/spec/models/timelog_spec.rb b/spec/models/timelog_spec.rb
new file mode 100644
index 00000000000..f08935b6425
--- /dev/null
+++ b/spec/models/timelog_spec.rb
@@ -0,0 +1,10 @@
+require 'rails_helper'
+
+RSpec.describe Timelog, type: :model do
+ subject { build(:timelog) }
+
+ it { is_expected.to be_valid }
+
+ it { is_expected.to validate_presence_of(:time_spent) }
+ it { is_expected.to validate_presence_of(:user) }
+end
diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb
index 335efc4db6c..c1c7c0882de 100644
--- a/spec/requests/api/commit_statuses_spec.rb
+++ b/spec/requests/api/commit_statuses_spec.rb
@@ -152,8 +152,11 @@ describe API::CommitStatuses, api: true do
context 'with all optional parameters' do
before do
- optional_params = { state: 'success', context: 'coverage',
- ref: 'develop', target_url: 'url', description: 'test' }
+ optional_params = { state: 'success',
+ context: 'coverage',
+ ref: 'develop',
+ description: 'test',
+ target_url: 'http://gitlab.com/status' }
post api(post_url, developer), optional_params
end
@@ -164,12 +167,12 @@ describe API::CommitStatuses, api: true do
expect(json_response['status']).to eq('success')
expect(json_response['name']).to eq('coverage')
expect(json_response['ref']).to eq('develop')
- expect(json_response['target_url']).to eq('url')
expect(json_response['description']).to eq('test')
+ expect(json_response['target_url']).to eq('http://gitlab.com/status')
end
end
- context 'invalid status' do
+ context 'when status is invalid' do
before { post api(post_url, developer), state: 'invalid' }
it 'does not create commit status' do
@@ -177,7 +180,7 @@ describe API::CommitStatuses, api: true do
end
end
- context 'request without state' do
+ context 'when request without a state made' do
before { post api(post_url, developer) }
it 'does not create commit status' do
@@ -185,7 +188,7 @@ describe API::CommitStatuses, api: true do
end
end
- context 'invalid commit' do
+ context 'when commit SHA is invalid' do
let(:sha) { 'invalid_sha' }
before { post api(post_url, developer), state: 'running' }
@@ -193,6 +196,19 @@ describe API::CommitStatuses, api: true do
expect(response).to have_http_status(404)
end
end
+
+ context 'when target URL is an invalid address' do
+ before do
+ post api(post_url, developer), state: 'pending',
+ target_url: 'invalid url'
+ end
+
+ it 'responds with bad request status and validation errors' do
+ expect(response).to have_http_status(400)
+ expect(json_response['message']['target_url'])
+ .to include 'must be a valid URL'
+ end
+ end
end
context 'reporter user' do
diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb
index 12dd4bd83f7..807c999b84a 100644
--- a/spec/requests/api/issues_spec.rb
+++ b/spec/requests/api/issues_spec.rb
@@ -1193,4 +1193,10 @@ describe API::Issues, api: true do
expect(response).to have_http_status(404)
end
end
+
+ describe 'time tracking endpoints' do
+ let(:issuable) { issue }
+
+ include_examples 'time tracking endpoints', 'issue'
+ end
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index f032d1b683d..4e4fea1dad8 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -6,7 +6,7 @@ describe API::MergeRequests, api: true do
let(:user) { create(:user) }
let(:admin) { create(:user, :admin) }
let(:non_member) { create(:user) }
- let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
+ let!(:project) { create(:project, :public, creator_id: user.id, namespace: user.namespace) }
let!(:merge_request) { create(:merge_request, :simple, author: user, assignee: user, source_project: project, target_project: project, title: "Test", created_at: base_time) }
let!(:merge_request_closed) { create(:merge_request, state: "closed", author: user, assignee: user, source_project: project, target_project: project, title: "Closed test", created_at: base_time + 1.second) }
let!(:merge_request_merged) { create(:merge_request, state: "merged", author: user, assignee: user, source_project: project, target_project: project, title: "Merged test", created_at: base_time + 2.seconds, merge_commit_sha: '9999999999999999999999999999999999999999') }
@@ -671,6 +671,12 @@ describe API::MergeRequests, api: true do
end
end
+ describe 'Time tracking' do
+ let(:issuable) { merge_request }
+
+ include_examples 'time tracking endpoints', 'merge_request'
+ end
+
def mr_with_later_created_and_updated_at_time
merge_request
merge_request.created_at += 1.hour
diff --git a/spec/serializers/analytics_stage_serializer_spec.rb b/spec/serializers/analytics_stage_serializer_spec.rb
new file mode 100644
index 00000000000..f9951826683
--- /dev/null
+++ b/spec/serializers/analytics_stage_serializer_spec.rb
@@ -0,0 +1,24 @@
+require 'spec_helper'
+
+describe AnalyticsStageSerializer do
+ let(:serializer) do
+ described_class
+ .new.represent(resource)
+ end
+
+ let(:json) { serializer.as_json }
+ let(:resource) { Gitlab::CycleAnalytics::CodeStage.new(project: double, options: {}) }
+
+ before do
+ allow_any_instance_of(Gitlab::CycleAnalytics::BaseStage).to receive(:median).and_return(1.12)
+ allow_any_instance_of(Gitlab::CycleAnalytics::BaseEventFetcher).to receive(:event_result).and_return({})
+ end
+
+ it 'it generates payload for single object' do
+ expect(json).to be_kind_of Hash
+ end
+
+ it 'contains important elements of AnalyticsStage' do
+ expect(json).to include(:title, :description, :value)
+ end
+end
diff --git a/spec/serializers/analytics_summary_serializer_spec.rb b/spec/serializers/analytics_summary_serializer_spec.rb
new file mode 100644
index 00000000000..7a84c8b0b40
--- /dev/null
+++ b/spec/serializers/analytics_summary_serializer_spec.rb
@@ -0,0 +1,29 @@
+require 'spec_helper'
+
+describe AnalyticsSummarySerializer do
+ let(:serializer) do
+ described_class
+ .new.represent(resource)
+ end
+
+ let(:json) { serializer.as_json }
+ let(:project) { create(:empty_project) }
+ let(:user) { create(:user) }
+ let(:resource) do
+ Gitlab::CycleAnalytics::Summary::Issue.new(project: double,
+ from: 1.day.ago,
+ current_user: user)
+ end
+
+ before do
+ allow_any_instance_of(Gitlab::CycleAnalytics::Summary::Issue).to receive(:value).and_return(1.12)
+ end
+
+ it 'it generates payload for single object' do
+ expect(json).to be_kind_of Hash
+ end
+
+ it 'contains important elements of AnalyticsStage' do
+ expect(json).to include(:title, :value)
+ end
+end
diff --git a/spec/services/notes/slash_commands_service_spec.rb b/spec/services/notes/slash_commands_service_spec.rb
index 960b5cd5e6f..1a64c8bbf00 100644
--- a/spec/services/notes/slash_commands_service_spec.rb
+++ b/spec/services/notes/slash_commands_service_spec.rb
@@ -86,6 +86,18 @@ describe Notes::SlashCommandsService, services: true do
expect(note.noteable).to be_open
end
end
+
+ describe '/spend' do
+ let(:note_text) { '/spend 1h' }
+
+ it 'updates the spent time on the noteable' do
+ content, command_params = service.extract_commands(note)
+ service.execute(command_params, note)
+
+ expect(content).to eq ''
+ expect(note.noteable.time_spent).to eq(3600)
+ end
+ end
end
describe 'note with command & text' do
diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb
index ffcf02d2c56..66fc8fc360b 100644
--- a/spec/services/slash_commands/interpret_service_spec.rb
+++ b/spec/services/slash_commands/interpret_service_spec.rb
@@ -211,6 +211,46 @@ describe SlashCommands::InterpretService, services: true do
end
end
+ shared_examples 'estimate command' do
+ it 'populates time_estimate: 3600 if content contains /estimate 1h' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(time_estimate: 3600)
+ end
+ end
+
+ shared_examples 'spend command' do
+ it 'populates spend_time: 3600 if content contains /spend 1h' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(spend_time: { duration: 3600, user: developer })
+ end
+ end
+
+ shared_examples 'spend command with negative time' do
+ it 'populates spend_time: -1800 if content contains /spend -30m' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(spend_time: { duration: -1800, user: developer })
+ end
+ end
+
+ shared_examples 'remove_estimate command' do
+ it 'populates time_estimate: 0 if content contains /remove_estimate' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(time_estimate: 0)
+ end
+ end
+
+ shared_examples 'remove_time_spent command' do
+ it 'populates spend_time: :reset if content contains /remove_time_spent' do
+ _, updates = service.execute(content, issuable)
+
+ expect(updates).to eq(spend_time: { duration: :reset, user: developer })
+ end
+ end
+
shared_examples 'empty command' do
it 'populates {} if content contains an unsupported command' do
_, updates = service.execute(content, issuable)
@@ -518,6 +558,51 @@ describe SlashCommands::InterpretService, services: true do
let(:issuable) { merge_request }
end
+ it_behaves_like 'estimate command' do
+ let(:content) { '/estimate 1h' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/estimate' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/estimate abc' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'spend command' do
+ let(:content) { '/spend 1h' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'spend command with negative time' do
+ let(:content) { '/spend -30m' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/spend' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'empty command' do
+ let(:content) { '/spend abc' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'remove_estimate command' do
+ let(:content) { '/remove_estimate' }
+ let(:issuable) { issue }
+ end
+
+ it_behaves_like 'remove_time_spent command' do
+ let(:content) { '/remove_time_spent' }
+ let(:issuable) { issue }
+ end
+
context 'when current_user cannot :admin_issue' do
let(:visitor) { create(:user) }
let(:issue) { create(:issue, project: project, author: visitor) }
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 0e8adb68721..4042f2b0512 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -740,4 +740,69 @@ describe SystemNoteService, services: true do
expect(note.note).to include(issue.to_reference)
end
end
+
+ describe '.change_time_estimate' do
+ subject { described_class.change_time_estimate(noteable, project, author) }
+
+ it_behaves_like 'a system note'
+
+ context 'with a time estimate' do
+ it 'sets the note text' do
+ noteable.update_attribute(:time_estimate, 277200)
+
+ expect(subject.note).to eq "Changed time estimate of this issue to 1w 4d 5h"
+ end
+ end
+
+ context 'without a time estimate' do
+ it 'sets the note text' do
+ expect(subject.note).to eq "Removed time estimate on this issue"
+ end
+ end
+ end
+
+ describe '.change_time_spent' do
+ # We need a custom noteable in order to the shared examples to be green.
+ let(:noteable) do
+ mr = create(:merge_request, source_project: project)
+ mr.spend_time(duration: 360000, user: author)
+ mr.save!
+ mr
+ end
+
+ subject do
+ described_class.change_time_spent(noteable, project, author)
+ end
+
+ it_behaves_like 'a system note'
+
+ context 'when time was added' do
+ it 'sets the note text' do
+ spend_time!(277200)
+
+ expect(subject.note).to eq "Added 1w 4d 5h of time spent on this merge request"
+ end
+ end
+
+ context 'when time was subtracted' do
+ it 'sets the note text' do
+ spend_time!(-277200)
+
+ expect(subject.note).to eq "Subtracted 1w 4d 5h of time spent on this merge request"
+ end
+ end
+
+ context 'when time was removed' do
+ it 'sets the note text' do
+ spend_time!(:reset)
+
+ expect(subject.note).to eq "Removed time spent on this merge request"
+ end
+ end
+
+ def spend_time!(seconds)
+ noteable.spend_time(duration: seconds, user: author)
+ noteable.save!
+ end
+ end
end
diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb
index ed55791d24e..13d584a8975 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -469,6 +469,13 @@ describe TodoService, services: true do
should_create_todo(user: author, target: mr_unassigned, action: Todo::BUILD_FAILED)
end
+
+ it 'creates a pending todo for merge_user' do
+ mr_unassigned.update(merge_when_build_succeeds: true, merge_user: admin)
+ service.merge_request_build_failed(mr_unassigned)
+
+ should_create_todo(user: admin, author: admin, target: mr_unassigned, action: Todo::BUILD_FAILED)
+ end
end
describe '#merge_request_push' do
@@ -482,6 +489,15 @@ describe TodoService, services: true do
end
end
+ describe '#merge_request_became_unmergeable' do
+ it 'creates a pending todo for a merge_user' do
+ mr_unassigned.update(merge_when_build_succeeds: true, merge_user: admin)
+ service.merge_request_became_unmergeable(mr_unassigned)
+
+ should_create_todo(user: admin, author: admin, target: mr_unassigned, action: Todo::UNMERGEABLE)
+ end
+ end
+
describe '#mark_todo' do
it 'creates a todo from a merge request' do
service.mark_todo(mr_unassigned, author)
diff --git a/spec/support/api/time_tracking_shared_examples.rb b/spec/support/api/time_tracking_shared_examples.rb
new file mode 100644
index 00000000000..210cd5817e0
--- /dev/null
+++ b/spec/support/api/time_tracking_shared_examples.rb
@@ -0,0 +1,132 @@
+shared_examples 'an unauthorized API user' do
+ it { is_expected.to eq(403) }
+end
+
+shared_examples 'time tracking endpoints' do |issuable_name|
+ issuable_collection_name = issuable_name.pluralize
+
+ describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_estimate" do
+ context 'with an unauthorized user' do
+ subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", non_member), duration: '1w') }
+
+ it_behaves_like 'an unauthorized API user'
+ end
+
+ it "sets the time estimate for #{issuable_name}" do
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w'
+
+ expect(response).to have_http_status(200)
+ expect(json_response['human_time_estimate']).to eq('1w')
+ end
+
+ describe 'updating the current estimate' do
+ before do
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w'
+ end
+
+ context 'when duration has a bad format' do
+ it 'does not modify the original estimate' do
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: 'foo'
+
+ expect(response).to have_http_status(400)
+ expect(issuable.reload.human_time_estimate).to eq('1w')
+ end
+ end
+
+ context 'with a valid duration' do
+ it 'updates the estimate' do
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '3w1h'
+
+ expect(response).to have_http_status(200)
+ expect(issuable.reload.human_time_estimate).to eq('3w 1h')
+ end
+ end
+ end
+ end
+
+ describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_time_estimate" do
+ context 'with an unauthorized user' do
+ subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", non_member)) }
+
+ it_behaves_like 'an unauthorized API user'
+ end
+
+ it "resets the time estimate for #{issuable_name}" do
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['time_estimate']).to eq(0)
+ end
+ end
+
+ describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/add_spent_time" do
+ context 'with an unauthorized user' do
+ subject do
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", non_member),
+ duration: '2h'
+ end
+
+ it_behaves_like 'an unauthorized API user'
+ end
+
+ it "add spent time for #{issuable_name}" do
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user),
+ duration: '2h'
+
+ expect(response).to have_http_status(201)
+ expect(json_response['human_total_time_spent']).to eq('2h')
+ end
+
+ context 'when subtracting time' do
+ it 'subtracts time of the total spent time' do
+ issuable.update_attributes!(spend_time: { duration: 7200, user: user })
+
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user),
+ duration: '-1h'
+
+ expect(response).to have_http_status(201)
+ expect(json_response['total_time_spent']).to eq(3600)
+ end
+ end
+
+ context 'when time to subtract is greater than the total spent time' do
+ it 'does not modify the total time spent' do
+ issuable.update_attributes!(spend_time: { duration: 7200, user: user })
+
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user),
+ duration: '-1w'
+
+ expect(response).to have_http_status(400)
+ expect(json_response['message']['time_spent'].first).to match(/exceeds the total time spent/)
+ end
+ end
+ end
+
+ describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_spent_time" do
+ context 'with an unauthorized user' do
+ subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", non_member)) }
+
+ it_behaves_like 'an unauthorized API user'
+ end
+
+ it "resets spent time for #{issuable_name}" do
+ post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['total_time_spent']).to eq(0)
+ end
+ end
+
+ describe "GET /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_stats" do
+ it "returns the time stats for #{issuable_name}" do
+ issuable.update_attributes!(spend_time: { duration: 1800, user: user },
+ time_estimate: 3600)
+
+ get api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_stats", user)
+
+ expect(response).to have_http_status(200)
+ expect(json_response['total_time_spent']).to eq(1800)
+ expect(json_response['time_estimate']).to eq(3600)
+ end
+ end
+end
diff --git a/spec/support/cycle_analytics_helpers/test_generation.rb b/spec/support/cycle_analytics_helpers/test_generation.rb
index 8e19a6c92e2..35b40d73191 100644
--- a/spec/support/cycle_analytics_helpers/test_generation.rb
+++ b/spec/support/cycle_analytics_helpers/test_generation.rb
@@ -2,7 +2,6 @@
# Note: The ABC size is large here because we have a method generating test cases with
# multiple nested contexts. This shouldn't count as a violation.
-
module CycleAnalyticsHelpers
module TestGeneration
# Generate the most common set of specs that all cycle analytics phases need to have.
@@ -51,7 +50,7 @@ module CycleAnalyticsHelpers
end
median_time_difference = time_differences.sort[2]
- expect(subject.send(phase)).to be_within(5).of(median_time_difference)
+ expect(subject[phase].median).to be_within(5).of(median_time_difference)
end
context "when the data belongs to another project" do
@@ -83,7 +82,7 @@ module CycleAnalyticsHelpers
# Turn off the stub before checking assertions
allow(self).to receive(:project).and_call_original
- expect(subject.send(phase)).to be_nil
+ expect(subject[phase].median).to be_nil
end
end
@@ -106,7 +105,7 @@ module CycleAnalyticsHelpers
Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn
- expect(subject.send(phase)).to be_nil
+ expect(subject[phase].median).to be_nil
end
end
end
@@ -126,7 +125,7 @@ module CycleAnalyticsHelpers
Timecop.freeze(end_time + 1.day) { post_fn[self, data] } if post_fn
end
- expect(subject.send(phase)).to be_nil
+ expect(subject[phase].median).to be_nil
end
end
end
@@ -145,7 +144,7 @@ module CycleAnalyticsHelpers
post_fn[self, data] if post_fn
end
- expect(subject.send(phase)).to be_nil
+ expect(subject[phase].median).to be_nil
end
end
end
@@ -153,7 +152,7 @@ module CycleAnalyticsHelpers
context "when none of the start / end conditions are matched" do
it "returns nil" do
- expect(subject.send(phase)).to be_nil
+ expect(subject[phase].median).to be_nil
end
end
end
diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb
new file mode 100644
index 00000000000..02657684b57
--- /dev/null
+++ b/spec/support/time_tracking_shared_examples.rb
@@ -0,0 +1,82 @@
+shared_examples 'issuable time tracker' do
+ it 'renders the sidebar component empty state' do
+ page.within '.time-tracking-no-tracking-pane' do
+ expect(page).to have_content 'No estimate or time spent'
+ end
+ end
+
+ it 'updates the sidebar component when estimate is added' do
+ submit_time('/estimate 3w 1d 1h')
+
+ page.within '.time-tracking-estimate-only-pane' do
+ expect(page).to have_content '3w 1d 1h'
+ end
+ end
+
+ it 'updates the sidebar component when spent is added' do
+ submit_time('/spend 3w 1d 1h')
+
+ page.within '.time-tracking-spend-only-pane' do
+ expect(page).to have_content '3w 1d 1h'
+ end
+ end
+
+ it 'shows the comparison when estimate and spent are added' do
+ submit_time('/estimate 3w 1d 1h')
+ submit_time('/spend 3w 1d 1h')
+
+ page.within '.time-tracking-comparison-pane' do
+ expect(page).to have_content '3w 1d 1h'
+ end
+ end
+
+ it 'updates the sidebar component when estimate is removed' do
+ submit_time('/estimate 3w 1d 1h')
+ submit_time('/remove_estimate')
+
+ page.within '#issuable-time-tracker' do
+ expect(page).to have_content 'No estimate or time spent'
+ end
+ end
+
+ it 'updates the sidebar component when spent is removed' do
+ submit_time('/spend 3w 1d 1h')
+ submit_time('/remove_time_spent')
+
+ page.within '#issuable-time-tracker' do
+ expect(page).to have_content 'No estimate or time spent'
+ end
+ end
+
+ it 'shows the help state when icon is clicked' do
+ page.within '#issuable-time-tracker' do
+ find('.help-button').click
+ expect(page).to have_content 'Track time with slash commands'
+ expect(page).to have_content 'Learn more'
+ end
+ end
+
+ it 'hides the help state when close icon is clicked' do
+ page.within '#issuable-time-tracker' do
+ find('.help-button').click
+ find('.close-help-button').click
+
+ expect(page).not_to have_content 'Track time with slash commands'
+ expect(page).not_to have_content 'Learn more'
+ end
+ end
+
+ it 'displays the correct help url' do
+ page.within '#issuable-time-tracker' do
+ find('.help-button').click
+
+ expect(find_link('Learn more')[:href]).to have_content('/help/workflow/time_tracking.md')
+ end
+ end
+end
+
+def submit_time(slash_command)
+ fill_in 'note[note]', with: slash_command
+ click_button 'Comment'
+ wait_for_ajax
+end