Commit 0d2ae3e7 authored by Mike Greiling's avatar Mike Greiling

Merge branch 'master' into go-go-gadget-webpack

* master: (67 commits)
  Add some API endpoints for time tracking.
  use destructuring syntax instead
  add changelog yml file
  correct User_agent placement in robots.txt
  Fixing typo
  Fix Project#update_repository_size to convert MB to Bytes properly
  Remove repository trait from factories that don't need it in features
  Add the `:repository` trait to `:project` factories in Cucumber steps
  Add a `:repository` trait to the `:empty_project` factory
  Update clipboard_button text: Copy commit SHA to clipboard
  Fix search bar filter dropdown scrollbars
  get rid of log
  fix UI behaviour - only make new calls when button is clicked and dropdown is not displayed
  better UI fix - simple solution
  Disable all cops in .rubocop_todo.yml
  fix spec
  refactored a bunch of stuff based on feedback
  fix serializer
  fix bug retrieving medians
  fix specs
  ...
parents c0ba747c 270dc226
......@@ -62,7 +62,7 @@ Lint/UnusedMethodArgument:
# Offense count: 93
# Configuration parameters: CountComments.
Metrics/BlockLength:
Max: 288
Enabled: false
# Offense count: 3
# Cop supports --auto-correct.
......@@ -125,7 +125,7 @@ RSpec/MessageSpies:
# Offense count: 3036
RSpec/MultipleExpectations:
Max: 37
Enabled: false
# Offense count: 2133
RSpec/NamedSubject:
......
......@@ -3,9 +3,6 @@
/* global ResolveCount */
function requireAll(context) { return context.keys().map(context); }
window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
requireAll(require.context('./models', false, /^\.\/.*\.(js|es6)$/));
requireAll(require.context('./stores', false, /^\.\/.*\.(js|es6)$/));
requireAll(require.context('./services', false, /^\.\/.*\.(js|es6)$/));
......
require('./time_tracking/time_tracking_bundle');
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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 = {}));
......@@ -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 = {}));
window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
......@@ -3,6 +3,7 @@
/* global GLForm */
/* global Autosave */
/* global ResolveService */
/* global mrRefreshWidgetUrl */
require('./autosave');
window.autosize = require('vendor/autosize');
......@@ -245,6 +246,16 @@ require('vendor/task_list');
};
Notes.prototype.handleCreateChanges = function(note) {
if (typeof note === 'undefined') {
return;
}
if (note.commands_changes && note.commands_changes.indexOf('merge') !== -1) {
$.get(mrRefreshWidgetUrl);
}
};
/*
Render note in main comments area.
......@@ -430,6 +441,7 @@ require('vendor/task_list');
*/
Notes.prototype.addNote = function(xhr, note, status) {
this.handleCreateChanges(note);
return this.renderNote(note);
};
......
......@@ -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>
`,
......
......@@ -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 {
......
......@@ -236,9 +236,13 @@ header.header-sidebar-pinned {
@media (min-width: $screen-md-min) {
padding-right: $gutter_width;
.merge-request-tabs-holder.affix {
&:not(.with-overlay) .merge-request-tabs-holder.affix {
right: $gutter_width;
}
&.with-overlay .merge-request-tabs-holder.affix {
right: $sidebar_collapsed_width;
}
}
&.with-overlay {
......
......@@ -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
......
......@@ -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;
}
}
......@@ -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 {
......
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
......
......@@ -25,8 +25,17 @@ class Projects::CompareController < Projects::ApplicationController
end
def create
redirect_to namespace_project_compare_path(@project.namespace, @project,
if params[:from].blank? || params[:to].blank?
flash[:alert] = "You must select from and to branches"
from_to_vars = {
from: params[:from].presence,
to: params[:to].presence
}
redirect_to namespace_project_compare_index_path(@project.namespace, @project, from_to_vars)
else
redirect_to namespace_project_compare_path(@project.namespace, @project,
params[:from], params[:to])
end
end
private
......
......@@ -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
......
......@@ -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
......@@ -347,6 +347,16 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
def merge_widget_refresh
if merge_request.in_progress_merge_commit_sha || merge_request.state == 'merged'
@status = :success
elsif merge_request.merge_when_build_succeeds
@status = :merge_when_build_succeeds
end
render 'merge'
end
def branch_from
# This is always source
@source_project = @merge_request.nil? ? @project : @merge_request.source_project
......
......@@ -23,7 +23,8 @@ class Projects::NotesController < Projects::ApplicationController
end
def create
@note = Notes::CreateService.new(project, current_user, note_params).execute
create_params = note_params.merge(merge_request_diff_head_sha: params[:merge_request_diff_head_sha])
@note = Notes::CreateService.new(project, current_user, create_params).execute
if @note.is_a?(Note)
Banzai::NoteRenderer.render([@note], @project, current_user)
......
......@@ -165,4 +165,10 @@ module DiffHelper
link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class]
end
def render_overflow_warning?(diff_files)
diffs = @merge_request_diff.presence || diff_files
diffs.overflow?
end
end
......@@ -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 = {
......
......@@ -19,6 +19,14 @@ module MergeRequestsHelper
}
end
def mr_widget_refresh_url(mr)
if mr && mr.source_project
merge_widget_refresh_namespace_project_merge_request_url(mr.source_project.namespace, mr.source_project, mr)
else
''
end
end
def mr_css_classes(mr)
classes = "merge-request"
classes << " closed" if mr.closed?
......
......@@ -507,6 +507,10 @@ module Ci
end
end
def has_expiring_artifacts?
artifacts_expire_at.present?
end
def keep_artifacts!
self.update(artifacts_expire_at: nil)
end
......
......@@ -318,6 +318,14 @@ class Commit
Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options)
end
def persisted?
true
end
def touch
# no-op but needs to be defined since #persisted? is defined
end
private
def commit_reference(from_project, referable_commit_id, full: false)
......
......@@ -13,6 +13,7 @@ module Issuable
include StripAttribute
include Awardable
include Taskable
include TimeTrackable
included do
cache_markdown_field :title, pipeline: :single_line
......
......@@ -7,11 +7,14 @@ module Milestoneish
def total_items_count(user)
memoize_per_user(user, :total_items_count) do
issues_count = count_issues_by_state(user).values.sum
issues_count + merge_requests.size
total_issues_count(user) + merge_requests.size
end
end
def total_issues_count(user)
count_issues_by_state(user).values.sum
end
def complete?(user)
total_items_count(user) > 0 && total_items_count(user) == closed_items_count(user)
end
......
# == 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
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
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
......@@ -898,10 +898,22 @@ class MergeRequest < ActiveRecord::Base
end
def has_commits?
commits_count > 0
merge_request_diff && commits_count > 0
end
def has_no_commits?
!has_commits?
end
def mergeable_with_slash_command?(current_user, autocomplete_precheck: false, last_diff_sha: nil)
return false unless can_be_merged_by?(current_user)
return true if autocomplete_precheck
return false unless mergeable?(skip_ci_check: true)
return false if head_pipeline && !(head_pipeline.success? || head_pipeline.active?)
return false if last_diff_sha != diff_head_sha
true
end
end
......@@ -234,28 +234,28 @@ class MergeRequestDiff < ActiveRecord::Base
# and save it as array of hashes in st_diffs db field
def save_diffs
new_attributes = {}
new_diffs = []
if commits.size.zero?
new_attributes[:state] = :empty
else
diff_collection = compare.diffs(Commit.max_diff_options)
if diff_collection.overflow?
# Set our state to 'overflow' to make the #empty? and #collected?
# methods (generated by StateMachine) return false.
new_attributes[:state] = :overflow
end
new_attributes[:real_size] = diff_collection.real_size
new_attributes[:real_size] = compare.diffs.real_size
if diff_collection.any?
new_diffs = dump_diffs(diff_collection)
new_attributes[:state] = :collected
end
new_attributes[:st_diffs] = new_diffs || []
# Set our state to 'overflow' to make the #empty? and #collected?
# methods (generated by StateMachine) return false.
#
# This attribution has to come at the end of the method so 'overflow'
# state does not get overridden by 'collected'.
new_attributes[:state] = :overflow if diff_collection.overflow?
end
new_attributes[:st_diffs] = new_diffs
update_columns_serialized(new_attributes)
end
......
......@@ -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
......
class Timelog < ActiveRecord::Base
validates :time_spent, :user, presence: true
belongs_to :trackable, polymorphic: true
belongs_to :user
end
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
class AnalyticsStageSerializer < BaseSerializer
entity AnalyticsStageEntity
end
class AnalyticsSummaryEntity < Grape::Entity
expose :value, safe: true
expose :title do |object|
object.title.pluralize(object.value)
end
end
class AnalyticsSummarySerializer < BaseSerializer
entity AnalyticsSummaryEntity
end
......@@ -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
......@@ -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
......@@ -7,6 +7,8 @@ module MergeRequests
params.except!(:target_project_id)
params.except!(:source_branch)
merge_from_slash_command(merge_request) if params[:merge]
if merge_request.closed_without_fork?
params.except!(:target_branch, :force_remove_source_branch)
end
......@@ -69,6 +71,19 @@ module MergeRequests
end
end
def merge_from_slash_command(merge_request)
last_diff_sha = params.delete(:merge)
return unless merge_request.mergeable_with_slash_command?(current_user, last_diff_sha: last_diff_sha)
merge_request.update(merge_error: nil)
if merge_request.head_pipeline && merge_request.head_pipeline.active?
MergeRequests::MergeWhenPipelineSucceedsService.new(project, current_user).execute(merge_request)
else
MergeWorker.perform_async(merge_request.id, current_user.id, {})
end
end
def reopen_service
MergeRequests::ReopenService
end
......
module Notes
class CreateService < BaseService
def execute
merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha)
note = project.notes.new(params)
note.author = current_user
note.system = false
......@@ -19,7 +21,8 @@ module Notes
slash_commands_service = SlashCommandsService.new(project, current_user)
if slash_commands_service.supported?(note)
content, command_params = slash_commands_service.extract_commands(note)
options = { merge_request_diff_head_sha: merge_request_diff_head_sha }
content, command_params = slash_commands_service.extract_commands(note, options)
only_commands = content.empty?
......
......@@ -19,10 +19,10 @@ module Notes
self.class.supported?(note, current_user)
end
def extract_commands(note)
def extract_commands(note, options = {})
return [note.note, {}] unless supported?(note)
SlashCommands::InterpretService.new(project, current_user).
SlashCommands::InterpretService.new(project, current_user, options).
execute(note.note, note.noteable)
end
......
......@@ -2,7 +2,7 @@ module SlashCommands
class InterpretService < BaseService
include Gitlab::SlashCommands::Dsl
attr_reader :issuable
attr_reader :issuable, :options
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record.
......@@ -13,7 +13,8 @@ module SlashCommands
opts = {
issuable: issuable,
current_user: current_user,
project: project
project: project,
params: params
}
content, commands = extractor.extract_commands(content, opts)
......@@ -58,6 +59,17 @@ module SlashCommands
@updates[:state_event] = 'reopen'
end
desc 'Merge (when build succeeds)'
condition do
last_diff_sha = params && params[:merge_request_diff_head_sha]
issuable.is_a?(MergeRequest) &&
issuable.persisted? &&
issuable.mergeable_with_slash_command?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha)
end
command :merge do
@updates[:merge] = params[:merge_request_diff_head_sha]
end
desc 'Change title'
params '<New title>'
condition do
......@@ -243,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'
......
......@@ -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
......
......@@ -22,14 +22,14 @@
%p.build-detail-row
The artifacts were removed
#{time_ago_with_tooltip(@build.artifacts_expire_at)}
- elsif @build.artifacts_expire_at
- elsif @build.has_expiring_artifacts?
%p.build-detail-row
The artifacts will be removed in
%span.js-artifacts-remove= @build.artifacts_expire_at
- if @build.artifacts?
.btn-group.btn-group-justified{ role: :group }
- if @build.artifacts_expire_at
- if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build)
= link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
Keep
......
......@@ -13,7 +13,7 @@
%a.close{ href: "#", "data-dismiss" => "modal" } ×
%h3.page-title== #{label} this #{commit.change_type_title(current_user)}
.modal-body
= form_tag send("#{type.underscore}_namespace_project_commit_path", @project.namespace, @project, commit.id), method: :post, remote: false, class: "form-horizontal js-#{type}-form js-requires-input" do
= form_tag [type.underscore, @project.namespace, @project, commit], method: :post, remote: false, class: "form-horizontal js-#{type}-form js-requires-input" do
.form-group.branch
= label_tag 'target_branch', target_label, class: 'control-label'
.col-sm-10
......
.page-content-header
.header-main-content
%strong
= clipboard_button(clipboard_text: @commit.id)
= clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
= @commit.short_id
%span.hidden-xs authored
#{time_ago_with_tooltip(@commit.authored_date)}
......
......@@ -36,6 +36,6 @@
.table-list-cell.commit-actions.hidden-xs
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
= clipboard_button(clipboard_text: commit.id)
= clipboard_button(clipboard_text: commit.id, title: "Copy commit SHA to clipboard")
= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent"
= link_to_browse_code(project, commit)
......@@ -18,8 +18,8 @@
= parallel_diff_btn
= render 'projects/diffs/stats', diff_files: diff_files
- if diff_files.overflow?
= render 'projects/diffs/warning', diff_files: diff_files
- if render_overflow_warning?(diff_files)
= render 'projects/diffs/warning', diff_files: diffs
.files{ data: { can_create_note: can_create_note } }
- diff_files.each_with_index do |diff_file|
......
......@@ -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_bundle_tag('lib_vue')
.clearfix.detail-page-header
.issuable-header
......
......@@ -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_bundle_tag('lib_vue')
= page_specific_javascript_bundle_tag('diff_notes')
.merge-request{ 'data-url' => merge_request_path(@merge_request) }
......@@ -112,3 +113,5 @@
merge_request = new MergeRequest({
action: "#{controller.action_name}"
});
var mrRefreshWidgetUrl = "#{mr_widget_refresh_url(@merge_request)}";
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('lib_vue')
= page_specific_javascript_bundle_tag('merge_conflicts')
= page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/show/mr_title"
......
- if @merge_request_diff.collected?
- if @merge_request_diff.collected? || @merge_request_diff.overflow?
= render 'projects/merge_requests/show/versions'
= render "projects/diffs/diffs", diffs: @diffs
- elsif @merge_request_diff.empty?
.nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch}
- else
.alert.alert-warning
%h4
Changes view for this comparison is extremely large.
%p
You can
= link_to "download it", merge_request_path(@merge_request, format: :diff), class: "vlink"
instead.
......@@ -3,6 +3,7 @@
= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
= hidden_field_tag :view, diff_view
= hidden_field_tag :line_type
= hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha)
= note_target_fields(@note)
= f.hidden_field :commit_id
= f.hidden_field :line_code
......
......@@ -8,7 +8,7 @@
.pull-left Last commit
.last-commit.hidden-sm.pull-left
%small.light
= clipboard_button(clipboard_text: @commit.id)
= clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
= link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace"
= time_ago_with_tooltip(@commit.committed_date)
= @commit.full_title
......
<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
- todo = issuable_todo(issuable)
%aside.right-sidebar{ class: sidebar_gutter_collapsed_class }
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('issuable')
%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 +75,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 +171,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]))}');
......
......@@ -9,7 +9,7 @@
.pull-right.light #{milestone.percent_complete(current_user)}% complete
.row
.col-sm-6
= link_to pluralize(milestone.issues_visible_to_user(current_user).size, 'Issue'), issues_path
= link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path
&middot;
= link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
.col-sm-6= milestone_progress_bar(milestone)
......
---
title: Support slash comand `/merge` for merging merge requests.
merge_request: 7746
author: Jarka Kadlecova
---
title: Fixes big pipeline and small pipeline width problems and tooltips text being outside the tooltip
merge_request: 8593
author:
---
title: Adjust ProjectStatistic#repository_size with values saved as MB
merge_request: 8616
author:
---
title: "Correct User-agent placement in robots.txt"
merge_request: 8623
author: Eric Sabelhaus
---
title: 'Copy commit SHA to clipboard'
merge_request: 8547
---
title: Hide build artifacts keep button if operation is not allowed
merge_request: 8501
author:
---
title: Fix Compare page throws 500 error when any branch/reference is not selected
merge_request: 8492
author: Martin Cabrera
---
title: Show 'too many changes' message for created merge requests when they are too large
merge_request:
author:
---
title: Fixed merge request tabs dont move when opening collapsed sidebar
merge_request:
author:
---
title: Use cached values to compute total issues count in milestone index pages
merge_request: 8518
author:
---
title: Add new endpoints for Time Tracking.
merge_request: 8483
author:
......@@ -94,6 +94,7 @@ constraints(ProjectUrlConstrainer.new) do
get :pipelines
get :merge_check
post :merge
get :merge_widget_refresh
post :cancel_merge_when_build_succeeds
get :ci_status
get :ci_environments_status
......
......@@ -24,6 +24,7 @@ var config = {
environments: './environments/environments_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js',
issuable: './issuable/issuable_bundle.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
merge_request_widget: './merge_request_widget/ci_bundle.js',
network: './network/network_bundle.js',
......@@ -34,6 +35,7 @@ var config = {
users: './users/users_bundle.js',
lib_chart: './lib/chart.js',
lib_d3: './lib/d3.js',
lib_vue: './lib/vue_resource.js',
vue_pipelines: './vue_pipelines_index/index.js',
},
......
# 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
# 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
......@@ -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
......
......@@ -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.
......@@ -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
}
```
......@@ -14,6 +14,7 @@ do.
|:---------------------------|:-------------|
| `/close` | Close the issue or merge request |
| `/reopen` | Reopen the issue or merge request |
| `/merge` | Merge (when build succeeds) |
| `/title <New title>` | Change title |
| `/assign @username` | Assign |
| `/unassign` | Remove assignee |
......@@ -29,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 |
......@@ -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)
......
......@@ -79,7 +79,7 @@ Now that SubGit has configured the Git/SVN repos, run `subgit` to perform the
initial translation of existing SVN revisions into the Git repository:
```
subgit install $GIT_REPOS_PATH
subgit install $GIT_REPO_PATH
```
After the initial translation is completed, the Git repository and the SVN
......
# 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
......@@ -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
......
......@@ -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
......@@ -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
......
......@@ -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')
......
......@@ -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
......
......@@ -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]
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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)
......
......@@ -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
......
......@@ -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
......
......@@ -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]
......@@ -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
......
......@@ -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
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
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
......@@ -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)
......
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)
......
module Gitlab
module CycleAnalytics
class MetricsFetcher
module BaseQuery
include MetricsTables
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))
private
median_datetime(cte_table, interval_query, name)
def base_query
@base_query ||= stage_query
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
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(@from))
query = query.where(build_table[:ref].eq(@branch)) if name == :test && @branch
where(issue_table[:created_at].gteq(@options[:from]))
# Load merge_requests
query = query.join(mr_table, Arel::Nodes::OuterJoin).
......@@ -48,11 +24,6 @@ module Gitlab
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
......
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
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
......
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
module Gitlab
module CycleAnalytics
module EventFetcher
def self.[](stage_name)
CycleAnalytics.const_get("#{stage_name.to_s.camelize}EventFetcher")
end
end
end
end
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
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
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
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],
......
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
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
......
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
module Gitlab
module CycleAnalytics
class ProductionEventFetcher < IssueEventFetcher
end
end
end
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
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
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
......
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
module Gitlab
module CycleAnalytics
module Stage
def self.[](stage_name)
CycleAnalytics.const_get("#{stage_name.to_s.camelize}Stage")
end
end
end
end
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
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
......
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
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
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
module Gitlab
module CycleAnalytics
module Summary
class Deploy < Base
def value
@value ||= @project.deployments.where("created_at > ?", @from).count
end
end
end
end
end
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
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
module Gitlab
module CycleAnalytics
class TestEventFetcher < StagingEventFetcher
end
end
end
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
......@@ -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")])
......
require_relative 'encoding_helper'
module Gitlab
module Git
class Blame
......
require_relative 'encoding_helper'
require_relative 'path_helper'
module Gitlab
module Git
class Blob
......
# Gitlab::Git::Repository is a wrapper around native Rugged::Repository object
require_relative 'encoding_helper'
require_relative 'path_helper'
require 'forwardable'
require 'tempfile'
require 'forwardable'
......
......@@ -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
......
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
......@@ -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*
......
......@@ -64,6 +64,36 @@ describe Projects::CompareController do
expect(assigns(:diffs)).to eq(nil)
expect(assigns(:commits)).to eq(nil)
end
it 'redirects back to index when params[:from] is empty and preserves params[:to]' do
post(:create,
namespace_id: project.namespace.to_param,
project_id: project.to_param,
from: '',
to: 'master')
expect(response).to redirect_to(namespace_project_compare_index_path(project.namespace, project, to: 'master'))
end
it 'redirects back to index when params[:to] is empty and preserves params[:from]' do
post(:create,
namespace_id: project.namespace.to_param,
project_id: project.to_param,
from: 'master',
to: '')
expect(response).to redirect_to(namespace_project_compare_index_path(project.namespace, project, from: 'master'))
end
it 'redirects back to index when params[:from] and params[:to] are empty' do
post(:create,
namespace_id: project.namespace.to_param,
project_id: project.to_param,
from: '',
to: '')
expect(response).to redirect_to(namespace_project_compare_index_path)
end
end
describe 'GET diff_for_path' do
......
......@@ -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
......
......@@ -1048,4 +1048,72 @@ describe Projects::MergeRequestsController do
end
end
end
describe 'GET merge_widget_refresh' do
let(:params) do
{
namespace_id: project.namespace.path,
project_id: project.path,
id: merge_request.iid,
format: :raw
}
end
before do
project.team << [user, :developer]
xhr :get, :merge_widget_refresh, params
end
context 'when merge in progress' do
let(:merge_request) { create(:merge_request, source_project: project, in_progress_merge_commit_sha: 'sha') }
it 'returns an OK response' do
expect(response).to have_http_status(:ok)
end
it 'sets status to :success' do
expect(assigns(:status)).to eq(:success)
expect(response).to render_template('merge')
end
end
context 'when merge request was merged already' do
let(:merge_request) { create(:merge_request, source_project: project, state: :merged) }
it 'returns an OK response' do
expect(response).to have_http_status(:ok)
end
it 'sets status to :success' do
expect(assigns(:status)).to eq(:success)
expect(response).to render_template('merge')
end
end
context 'when waiting for build' do
let(:merge_request) { create(:merge_request, source_project: project, merge_when_build_succeeds: true, merge_user: user) }
it 'returns an OK response' do
expect(response).to have_http_status(:ok)
end
it 'sets status to :merge_when_build_succeeds' do
expect(assigns(:status)).to eq(:merge_when_build_succeeds)
expect(response).to render_template('merge')
end
end
context 'when no special status for MR' do
let(:merge_request) { create(:merge_request, source_project: project) }
it 'returns an OK response' do
expect(response).to have_http_status(:ok)
end
it 'sets status to nil' do
expect(assigns(:status)).to be_nil
expect(response).to render_template('merge')
end
end
end
end
......@@ -14,6 +14,54 @@ describe Projects::NotesController do
}
end
describe 'POST create' do
let(:merge_request) { create(:merge_request) }
let(:request_params) do
{
note: { note: 'some note', noteable_id: merge_request.id, noteable_type: 'MergeRequest' },
namespace_id: project.namespace,
project_id: project,
merge_request_diff_head_sha: 'sha'
}
end
before do
sign_in(user)
project.team << [user, :developer]
end
it "returns status 302 for html" do
post :create, request_params
expect(response).to have_http_status(302)
end
it "returns status 200 for json" do
post :create, request_params.merge(format: :json)
expect(response).to have_http_status(200)
end
context 'when merge_request_diff_head_sha present' do
before do
service_params = {
note: 'some note',
noteable_id: merge_request.id.to_s,
noteable_type: 'MergeRequest',
merge_request_diff_head_sha: 'sha'
}
expect(Notes::CreateService).to receive(:new).with(project, user, service_params).and_return(double(execute: true))
end
it "returns status 302 for html" do
post :create, request_params
expect(response).to have_http_status(302)
end
end
end
describe 'POST toggle_award_emoji' do
before do
sign_in(user)
......
......@@ -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
......
# 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
......@@ -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) }
......
......@@ -22,4 +22,18 @@ feature 'Diffs URL', js: true, feature: true do
expect(page).to have_css('.diffs.tab-pane.active')
end
end
context 'when merge request has overflow' do
it 'displays warning' do
allow_any_instance_of(MergeRequestDiff).to receive(:overflow?).and_return(true)
allow(Commit).to receive(:max_diff_options).and_return(max_files: 20, max_lines: 20)
visit diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
page.within('.alert') do
expect(page).to have_text("Too many changes to show. Plain diff Email patch To preserve
performance only 3 of 3+ files are displayed.")
end
end
end
end
......@@ -68,6 +68,51 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do
end
end
describe 'merging the MR from the note' do
context 'when the current user can merge the MR' do
it 'merges the MR' do
write_note("/merge")
expect(page).to have_content 'Commands applied'
expect(merge_request.reload).to be_merged
end
end
context 'when the head diff changes in the meanwhile' do
before do
merge_request.source_branch = 'another_branch'
merge_request.save
end
it 'does not merge the MR' do
write_note("/merge")
expect(page).not_to have_content 'Your commands have been executed!'
expect(merge_request.reload).not_to be_merged
end
end
context 'when the current user cannot merge the MR' do
let(:guest) { create(:user) }
before do
project.team << [guest, :guest]
logout
login_with(guest)
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
end
it 'does not merge the MR' do
write_note("/merge")
expect(page).not_to have_content 'Your commands have been executed!'
expect(merge_request.reload).not_to be_merged
end
end
end
describe 'adding a due date from note' do
it 'does not recognize the command nor create a note' do
write_note('/due 2016-08-28')
......
......@@ -3,6 +3,7 @@ require 'tempfile'
feature 'Builds', :feature do
let(:user) { create(:user) }
let(:user_access_level) { :developer }
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
......@@ -14,7 +15,7 @@ feature 'Builds', :feature do
end
before do
project.team << [user, :developer]
project.team << [user, user_access_level]
login_as(user)
end
......@@ -131,7 +132,9 @@ feature 'Builds', :feature do
context 'Artifacts expire date' do
before do
build.update_attributes(artifacts_file: artifacts_file, artifacts_expire_at: expire_at)
build.update_attributes(artifacts_file: artifacts_file,
artifacts_expire_at: expire_at)
visit namespace_project_build_path(project.namespace, project, build)
end
......@@ -146,12 +149,23 @@ feature 'Builds', :feature do
context 'when expire date is defined' do
let(:expire_at) { Time.now + 7.days }
it 'keeps artifacts when Keep button is clicked' do
expect(page).to have_content 'The artifacts will be removed'
click_link 'Keep'
context 'when user has ability to update build' do
it 'keeps artifacts when keep button is clicked' do
expect(page).to have_content 'The artifacts will be removed'
expect(page).not_to have_link 'Keep'
expect(page).not_to have_content 'The artifacts will be removed'
click_link 'Keep'
expect(page).to have_no_link 'Keep'
expect(page).to have_no_content 'The artifacts will be removed'
end
end
context 'when user does not have ability to update build' do
let(:user_access_level) { :guest }
it 'does not have keep button' do
expect(page).to have_no_link 'Keep'
end
end
end
......
......@@ -62,4 +62,19 @@ describe MergeRequestsHelper do
it { is_expected.to eq([source_title, target_title]) }
end
end
describe 'mr_widget_refresh_url' do
let(:merge_request) { create(:merge_request, source_project: project) }
let(:project) { create(:project) }
it 'returns correct url for MR' do
expected_url = "#{project.path_with_namespace}/merge_requests/#{merge_request.iid}/merge_widget_refresh"
expect(mr_widget_refresh_url(merge_request)).to end_with(expected_url)
end
it 'returns empty string for nil' do
expect(mr_widget_refresh_url(nil)).to end_with('')
end
end
end
/* eslint-disable */
require('jquery');
require('vue');
require('~/issuable/time_tracking/components/time_tracker');
function initTimeTrackingComponent(opts) {
setFixtures(`
<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 = {}));
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 @@ require('~/lib/utils/pretty_time');
});
it('should correctly parse a zero value', function () {
const parser = PrettyTime.parseSeconds;
const parser = prettyTime.parseSeconds;
const zeroSeconds = parser(0);
......@@ -28,7 +28,7 @@ require('~/lib/utils/pretty_time');
});
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 @@ require('~/lib/utils/pretty_time');
});
it('should correctly parse large second values', function () {
const parser = PrettyTime.parseSeconds;
const parser = prettyTime.parseSeconds;
const aboveOneHour = parser(4800);
......@@ -87,7 +87,7 @@ require('~/lib/utils/pretty_time');
minutes: 20,
};
const timeString = PrettyTime.stringifyTime(timeObject);
const timeString = prettyTime.stringifyTime(timeObject);
expect(timeString).toBe('1w 4d 7h 20m');
});
......@@ -100,7 +100,7 @@ require('~/lib/utils/pretty_time');
minutes: 20,
};
const timeString = PrettyTime.stringifyTime(timeObject);
const timeString = prettyTime.stringifyTime(timeObject);
expect(timeString).toBe('4d 20m');
});
......@@ -113,7 +113,7 @@ require('~/lib/utils/pretty_time');
minutes: 0,
};
const timeString = PrettyTime.stringifyTime(timeObject);
const timeString = prettyTime.stringifyTime(timeObject);
expect(timeString).toBe('0m');
});
......@@ -122,12 +122,12 @@ require('~/lib/utils/pretty_time');
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');
});
});
});
......
require 'spec_helper'
require 'lib/gitlab/cycle_analytics/shared_event_spec'
describe Gitlab::CycleAnalytics::ProductionEvent do
describe Gitlab::CycleAnalytics::CodeEventFetcher do
let(:stage_name) { :code }
it_behaves_like 'default query config' do
it 'has the default order' do
expect(event.order).to eq(event.start_time_attrs)
it 'has a default order' do
expect(event.order).not_to be_nil
end
end
end
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
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
......
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
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
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
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
......
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
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
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
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
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
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
......
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
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
require 'spec_helper'
require 'lib/gitlab/cycle_analytics/shared_event_spec'
describe Gitlab::CycleAnalytics::ReviewEvent do
describe Gitlab::CycleAnalytics::StagingEventFetcher do
let(:stage_name) { :staging }
it_behaves_like 'default query config' do
it 'has the default order' do
expect(event.order).to eq(event.start_time_attrs)
it 'has a default order' do
expect(event.order).not_to be_nil
end
end
end
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
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
require 'spec_helper'
require 'lib/gitlab/cycle_analytics/shared_event_spec'
describe Gitlab::CycleAnalytics::CodeEvent do
describe Gitlab::CycleAnalytics::TestEventFetcher do
let(:stage_name) { :test }
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)
it 'has a default order' do
expect(event.order).not_to be_nil
end
end
end
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
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
......@@ -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
......@@ -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
......@@ -1013,6 +1013,24 @@ describe Ci::Build, :models do
end
end
describe '#has_expiring_artifacts?' do
context 'when artifacts have expiration date set' do
before { build.update(artifacts_expire_at: 1.day.from_now) }
it 'has expiring artifacts' do
expect(build).to have_expiring_artifacts
end
end
context 'when artifacts do not have expiration date set' do
before { build.update(artifacts_expire_at: nil) }
it 'does not have expiring artifacts' do
expect(build).not_to have_expiring_artifacts
end
end
end
describe '#has_trace_file?' do
context 'when there is no trace' do
it { expect(build.has_trace_file?).to be_falsey }
......
......@@ -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
......@@ -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
......
......@@ -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
......@@ -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
......@@ -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
......@@ -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
......@@ -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
......@@ -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
......@@ -76,6 +76,32 @@ describe MergeRequestDiff, models: true do
end
end
describe '#save_diffs' do
it 'saves collected state' do
mr_diff = create(:merge_request).merge_request_diff
expect(mr_diff.collected?).to be_truthy
end
it 'saves overflow state' do
allow(Commit).to receive(:max_diff_options)
.and_return(max_lines: 0, max_files: 0)
mr_diff = create(:merge_request).merge_request_diff
expect(mr_diff.overflow?).to be_truthy
end
it 'saves empty state' do
allow_any_instance_of(MergeRequestDiff).to receive(:commits)
.and_return([])
mr_diff = create(:merge_request).merge_request_diff
expect(mr_diff.empty?).to be_truthy
end
end
describe '#commits_sha' do
it 'returns all commits SHA using serialized commits' do
subject.st_commits = [
......
......@@ -1514,6 +1514,108 @@ describe MergeRequest, models: true do
end
end
describe '#mergeable_with_slash_command?' do
def create_pipeline(status)
create(:ci_pipeline_with_one_job,
project: project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha,
status: status)
end
let(:project) { create(:project, :public, only_allow_merge_if_build_succeeds: true) }
let(:developer) { create(:user) }
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:mr_sha) { merge_request.diff_head_sha }
before do
project.team << [developer, :developer]
end
context 'when autocomplete_precheck is set to true' do
it 'is mergeable by developer' do
expect(merge_request.mergeable_with_slash_command?(developer, autocomplete_precheck: true)).to be_truthy
end
it 'is not mergeable by normal user' do
expect(merge_request.mergeable_with_slash_command?(user, autocomplete_precheck: true)).to be_falsey
end
end
context 'when autocomplete_precheck is set to false' do
it 'is mergeable by developer' do
expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_truthy
end
it 'is not mergeable by normal user' do
expect(merge_request.mergeable_with_slash_command?(user, last_diff_sha: mr_sha)).to be_falsey
end
context 'closed MR' do
before do
merge_request.update_attribute(:state, :closed)
end
it 'is not mergeable' do
expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_falsey
end
end
context 'MR with WIP' do
before do
merge_request.update_attribute(:title, 'WIP: some MR')
end
it 'is not mergeable' do
expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_falsey
end
end
context 'sha differs from the MR diff_head_sha' do
it 'is not mergeable' do
expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: 'some other sha')).to be_falsey
end
end
context 'sha is not provided' do
it 'is not mergeable' do
expect(merge_request.mergeable_with_slash_command?(developer)).to be_falsey
end
end
context 'with pipeline ok' do
before do
create_pipeline(:success)
end
it 'is mergeable' do
expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_truthy
end
end
context 'with failing pipeline' do
before do
create_pipeline(:failed)
end
it 'is not mergeable' do
expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_falsey
end
end
context 'with running pipeline' do
before do
create_pipeline(:running)
end
it 'is mergeable' do
expect(merge_request.mergeable_with_slash_command?(developer, last_diff_sha: mr_sha)).to be_truthy
end
end
end
end
describe '#has_commits?' do
before do
allow(subject.merge_request_diff).to receive(:commits_count).
......
......@@ -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
......
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
......@@ -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
......@@ -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
......
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
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
......@@ -121,6 +121,99 @@ describe MergeRequests::UpdateService, services: true do
end
end
context 'merge' do
let(:opts) do
{
merge: merge_request.diff_head_sha
}
end
let(:service) { MergeRequests::UpdateService.new(project, user, opts) }
context 'without pipeline' do
before do
merge_request.merge_error = 'Error'
perform_enqueued_jobs do
service.execute(merge_request)
@merge_request = MergeRequest.find(merge_request.id)
end
end
it { expect(@merge_request).to be_valid }
it { expect(@merge_request.state).to eq('merged') }
it { expect(@merge_request.merge_error).to be_nil }
end
context 'with finished pipeline' do
before do
create(:ci_pipeline_with_one_job,
project: project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha,
status: :success)
perform_enqueued_jobs do
@merge_request = service.execute(merge_request)
@merge_request = MergeRequest.find(merge_request.id)
end
end
it { expect(@merge_request).to be_valid }
it { expect(@merge_request.state).to eq('merged') }
end
context 'with active pipeline' do
before do
service_mock = double
create(:ci_pipeline_with_one_job,
project: project,
ref: merge_request.source_branch,
sha: merge_request.diff_head_sha)
expect(MergeRequests::MergeWhenPipelineSucceedsService).to receive(:new).with(project, user).
and_return(service_mock)
expect(service_mock).to receive(:execute).with(merge_request)
end
it { service.execute(merge_request) }
end
context 'with a non-authorised user' do
let(:visitor) { create(:user) }
let(:service) { MergeRequests::UpdateService.new(project, visitor, opts) }
before do
merge_request.update_attribute(:merge_error, 'Error')
perform_enqueued_jobs do
@merge_request = service.execute(merge_request)
@merge_request = MergeRequest.find(merge_request.id)
end
end
it { expect(@merge_request.state).to eq('opened') }
it { expect(@merge_request.merge_error).not_to be_nil }
end
context 'MR can not be merged when note sha != MR sha' do
let(:opts) do
{
merge: 'other_commit'
}
end
before do
perform_enqueued_jobs do
@merge_request = service.execute(merge_request)
@merge_request = MergeRequest.find(merge_request.id)
end
end
it { expect(@merge_request.state).to eq('opened') }
end
end
context 'todos' do
let!(:pending_todo) { create(:todo, :assigned, user: user, project: project, target: merge_request, author: user2) }
......
......@@ -63,6 +63,17 @@ describe Notes::CreateService, services: true do
expect(note.note).to eq "HELLO\nWORLD"
end
end
describe '/merge with sha option' do
let(:note_text) { %(HELLO\n/merge\nWORLD) }
let(:params) { opts.merge(note: note_text, merge_request_diff_head_sha: 'sha') }
it 'saves the note and exectues merge command' do
note = described_class.new(project, user, params).execute
expect(note.note).to eq "HELLO\nWORLD"
end
end
end
end
......
......@@ -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
......
require 'spec_helper'
describe SlashCommands::InterpretService, services: true do
let(:project) { create(:empty_project, :public) }
let(:project) { create(:project, :public) }
let(:developer) { create(:user) }
let(:issue) { create(:issue, project: project) }
let(:milestone) { create(:milestone, project: project, title: '9.10') }
let(:inprogress) { create(:label, project: project, title: 'In Progress') }
let(:bug) { create(:label, project: project, title: 'Bug') }
let(:note) { build(:note, commit_id: merge_request.diff_head_sha) }
before do
project.team << [developer, :developer]
......@@ -210,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)
......@@ -218,6 +259,14 @@ describe SlashCommands::InterpretService, services: true do
end
end
shared_examples 'merge command' do
it 'runs merge command if content contains /merge' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(merge: merge_request.diff_head_sha)
end
end
it_behaves_like 'reopen command' do
let(:content) { '/reopen' }
let(:issuable) { issue }
......@@ -238,6 +287,64 @@ describe SlashCommands::InterpretService, services: true do
let(:issuable) { merge_request }
end
context 'merge command' do
let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: merge_request.diff_head_sha }) }
it_behaves_like 'merge command' do
let(:content) { '/merge' }
let(:issuable) { merge_request }
end
context 'can not be merged when logged user does not have permissions' do
let(:service) { described_class.new(project, create(:user)) }
it_behaves_like 'empty command' do
let(:content) { "/merge" }
let(:issuable) { merge_request }
end
end
context 'can not be merged when sha does not match' do
let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: 'othersha' }) }
it_behaves_like 'empty command' do
let(:content) { "/merge" }
let(:issuable) { merge_request }
end
end
context 'when sha is missing' do
let(:service) { described_class.new(project, developer, {}) }
it 'precheck passes and returns merge command' do
_, updates = service.execute('/merge', merge_request)
expect(updates).to eq(merge: nil)
end
end
context 'issue can not be merged' do
it_behaves_like 'empty command' do
let(:content) { "/merge" }
let(:issuable) { issue }
end
end
context 'non persisted merge request cant be merged' do
it_behaves_like 'empty command' do
let(:content) { "/merge" }
let(:issuable) { build(:merge_request) }
end
end
context 'not persisted merge request can not be merged' do
it_behaves_like 'empty command' do
let(:content) { "/merge" }
let(:issuable) { build(:merge_request, source_project: project) }
end
end
end
it_behaves_like 'title command' do
let(:content) { '/title A brand new title' }
let(:issuable) { issue }
......@@ -451,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) }
......
......@@ -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
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
......@@ -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
......
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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment