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: ...@@ -62,7 +62,7 @@ Lint/UnusedMethodArgument:
# Offense count: 93 # Offense count: 93
# Configuration parameters: CountComments. # Configuration parameters: CountComments.
Metrics/BlockLength: Metrics/BlockLength:
Max: 288 Enabled: false
# Offense count: 3 # Offense count: 3
# Cop supports --auto-correct. # Cop supports --auto-correct.
...@@ -125,7 +125,7 @@ RSpec/MessageSpies: ...@@ -125,7 +125,7 @@ RSpec/MessageSpies:
# Offense count: 3036 # Offense count: 3036
RSpec/MultipleExpectations: RSpec/MultipleExpectations:
Max: 37 Enabled: false
# Offense count: 2133 # Offense count: 2133
RSpec/NamedSubject: RSpec/NamedSubject:
......
...@@ -3,9 +3,6 @@ ...@@ -3,9 +3,6 @@
/* global ResolveCount */ /* global ResolveCount */
function requireAll(context) { return context.keys().map(context); } 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('./models', false, /^\.\/.*\.(js|es6)$/));
requireAll(require.context('./stores', false, /^\.\/.*\.(js|es6)$/)); requireAll(require.context('./stores', false, /^\.\/.*\.(js|es6)$/));
requireAll(require.context('./services', 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 @@ ...@@ -4,13 +4,13 @@
* stringifyTime condensed or non-condensed, abbreviateTimelengths) * 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: # } * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
* Seconds can be negative or positive, zero or non-zero. * Seconds can be negative or positive, zero or non-zero.
*/ */
static parseSeconds(seconds) { parseSeconds(seconds) {
const DAYS_PER_WEEK = 5; const DAYS_PER_WEEK = 5;
const HOURS_PER_DAY = 8; const HOURS_PER_DAY = 8;
const MINUTES_PER_HOUR = 60; const MINUTES_PER_HOUR = 60;
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
minutes: 1, minutes: 1,
}; };
let unorderedMinutes = PrettyTime.secondsToMinutes(seconds); let unorderedMinutes = prettyTime.secondsToMinutes(seconds);
return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => { return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod); const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
...@@ -33,35 +33,33 @@ ...@@ -33,35 +33,33 @@
return periodCount; return periodCount;
}); });
} },
/* /*
* Accepts a timeObject and returns a condensed string representation of it * 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. * (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 reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => {
const isNonZero = !!unitValue; const isNonZero = !!unitValue;
return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo; return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
}, '').trim(); }, '').trim();
return reducedTime.length ? reducedTime : '0m'; return reducedTime.length ? reducedTime : '0m';
} },
/* /*
* Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns * 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. * the first non-zero unit/value pair.
*/ */
static abbreviateTime(timeStr) { abbreviateTime(timeStr) {
return timeStr.split(' ') return timeStr.split(' ')
.filter(unitStr => unitStr.charAt(0) !== '0')[0]; .filter(unitStr => unitStr.charAt(0) !== '0')[0];
} },
static secondsToMinutes(seconds) { secondsToMinutes(seconds) {
return Math.abs(seconds / 60); return Math.abs(seconds / 60);
} },
} };
gl.PrettyTime = PrettyTime;
})(window.gl || (window.gl = {})); })(window.gl || (window.gl = {}));
window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
/* global GLForm */ /* global GLForm */
/* global Autosave */ /* global Autosave */
/* global ResolveService */ /* global ResolveService */
/* global mrRefreshWidgetUrl */
require('./autosave'); require('./autosave');
window.autosize = require('vendor/autosize'); window.autosize = require('vendor/autosize');
...@@ -245,6 +246,16 @@ require('vendor/task_list'); ...@@ -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. Render note in main comments area.
...@@ -430,6 +441,7 @@ require('vendor/task_list'); ...@@ -430,6 +441,7 @@ require('vendor/task_list');
*/ */
Notes.prototype.addNote = function(xhr, note, status) { Notes.prototype.addNote = function(xhr, note, status) {
this.handleCreateChanges(note);
return this.renderNote(note); return this.renderNote(note);
}; };
......
...@@ -5,18 +5,19 @@ ...@@ -5,18 +5,19 @@
gl.VueStage = Vue.extend({ gl.VueStage = Vue.extend({
data() { data() {
return { return {
count: 0,
builds: '', builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>', spinner: '<span class="fa fa-spinner fa-spin"></span>',
}; };
}, },
props: ['stage', 'svgs', 'match'], props: ['stage', 'svgs', 'match'],
methods: { methods: {
fetchBuilds() { fetchBuilds(e) {
if (this.count > 0) return null; const areaExpanded = e.currentTarget.attributes['aria-expanded'];
if (areaExpanded && (areaExpanded.textContent === 'true')) return null;
return this.$http.get(this.stage.dropdown_path) return this.$http.get(this.stage.dropdown_path)
.then((response) => { .then((response) => {
this.count += 1;
this.builds = JSON.parse(response.body).html; this.builds = JSON.parse(response.body).html;
}, () => { }, () => {
const flash = new Flash('Something went wrong on our end.'); const flash = new Flash('Something went wrong on our end.');
...@@ -39,7 +40,7 @@ ...@@ -39,7 +40,7 @@
return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`; return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
}, },
svg() { svg() {
const icon = this.stage.status.icon; const { icon } = this.stage.status;
const stageIcon = icon.replace(/icon/i, 'stage_icon'); const stageIcon = icon.replace(/icon/i, 'stage_icon');
return this.svgs[this.match(stageIcon)]; return this.svgs[this.match(stageIcon)];
}, },
...@@ -50,18 +51,25 @@ ...@@ -50,18 +51,25 @@
template: ` template: `
<div> <div>
<button <button
@click='fetchBuilds' @click='fetchBuilds($event)'
:class="triggerButtonClass" :class="triggerButtonClass"
:title='stage.title' :title='stage.title'
data-placement="top" data-placement="top"
data-toggle="dropdown" data-toggle="dropdown"
type="button"> type="button"
>
<span v-html="svg"></span> <span v-html="svg"></span>
<i class="fa fa-caret-down "></i> <i class="fa fa-caret-down "></i>
</button> </button>
<ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
<div class="arrow-up"></div> <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> </ul>
</div> </div>
`, `,
......
...@@ -76,7 +76,7 @@ ...@@ -76,7 +76,7 @@
.filter-dropdown { .filter-dropdown {
max-height: 215px; max-height: 215px;
overflow-x: scroll; overflow: auto;
} }
.filter-dropdown-item { .filter-dropdown-item {
...@@ -86,7 +86,7 @@ ...@@ -86,7 +86,7 @@
text-align: left; text-align: left;
padding: 8px 16px; padding: 8px 16px;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow-y: hidden; overflow: hidden;
border-radius: 0; border-radius: 0;
.fa { .fa {
......
...@@ -236,9 +236,13 @@ header.header-sidebar-pinned { ...@@ -236,9 +236,13 @@ header.header-sidebar-pinned {
@media (min-width: $screen-md-min) { @media (min-width: $screen-md-min) {
padding-right: $gutter_width; padding-right: $gutter_width;
.merge-request-tabs-holder.affix { &:not(.with-overlay) .merge-request-tabs-holder.affix {
right: $gutter_width; right: $gutter_width;
} }
&.with-overlay .merge-request-tabs-holder.affix {
right: $sidebar_collapsed_width;
}
} }
&.with-overlay { &.with-overlay {
......
...@@ -5,7 +5,7 @@ $sidebar_collapsed_width: 62px; ...@@ -5,7 +5,7 @@ $sidebar_collapsed_width: 62px;
$sidebar_width: 220px; $sidebar_width: 220px;
$gutter_collapsed_width: 62px; $gutter_collapsed_width: 62px;
$gutter_width: 290px; $gutter_width: 290px;
$gutter_inner_width: 258px; $gutter_inner_width: 250px;
$sidebar-transition-duration: .15s; $sidebar-transition-duration: .15s;
$sidebar-breakpoint: 1024px; $sidebar-breakpoint: 1024px;
...@@ -56,6 +56,7 @@ $black-transparent: rgba(0, 0, 0, 0.3); ...@@ -56,6 +56,7 @@ $black-transparent: rgba(0, 0, 0, 0.3);
$border-white-light: darken($white-light, $darken-border-factor); $border-white-light: darken($white-light, $darken-border-factor);
$border-white-normal: darken($white-normal, $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-normal: darken($gray-normal, $darken-border-factor);
$border-gray-dark: darken($white-normal, $darken-border-factor); $border-gray-dark: darken($white-normal, $darken-border-factor);
...@@ -85,6 +86,7 @@ $warning-message-border: #f0e2bb; ...@@ -85,6 +86,7 @@ $warning-message-border: #f0e2bb;
*/ */
$border-color: #e5e5e5; $border-color: #e5e5e5;
$focus-border-color: #3aabf0; $focus-border-color: #3aabf0;
$sidebar-collapsed-icon-color: #999;
$well-expand-item: #e8f2f7; $well-expand-item: #e8f2f7;
$well-inner-border: #eef0f2; $well-inner-border: #eef0f2;
$well-light-border: #f1f1f1; $well-light-border: #f1f1f1;
...@@ -280,6 +282,7 @@ $dropdown-hover-color: #3b86ff; ...@@ -280,6 +282,7 @@ $dropdown-hover-color: #3b86ff;
*/ */
$btn-active-gray: #ececec; $btn-active-gray: #ececec;
$btn-active-gray-light: e4e7ed; $btn-active-gray-light: e4e7ed;
$btn-white-active: #848484;
/* /*
* Badges * Badges
...@@ -433,6 +436,7 @@ $help-shortcut-header-color: #333; ...@@ -433,6 +436,7 @@ $help-shortcut-header-color: #333;
*/ */
$issues-today-bg: #f3fff2; $issues-today-bg: #f3fff2;
$issues-today-border: #e1e8d5; $issues-today-border: #e1e8d5;
$compare-display-color: #888;
/* /*
* jQuery UI * jQuery UI
......
...@@ -473,3 +473,102 @@ ...@@ -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 @@ ...@@ -44,8 +44,8 @@
.pipeline-info, .pipeline-info,
.pipeline-commit, .pipeline-commit,
.pipeline-actions, .pipeline-stages,
.pipeline-stages { .pipeline-actions {
width: 20%; width: 20%;
} }
} }
...@@ -185,6 +185,7 @@ ...@@ -185,6 +185,7 @@
.stage-cell { .stage-cell {
font-size: 0; font-size: 0;
padding: 10px 4px;
> .stage-container > div > button > span > svg, > .stage-container > div > button > span > svg,
> .stage-container > button > svg { > .stage-container > button > svg {
...@@ -202,8 +203,8 @@ ...@@ -202,8 +203,8 @@
position: relative; position: relative;
margin-right: 6px; margin-right: 6px;
.tooltip { .tooltip-inner {
white-space: nowrap; padding: 3px 4px;
} }
&:not(:last-child) { &:not(:last-child) {
...@@ -348,6 +349,7 @@ ...@@ -348,6 +349,7 @@
padding: $gl-padding; padding: $gl-padding;
white-space: nowrap; white-space: nowrap;
transition: max-height 0.3s, padding 0.3s; transition: max-height 0.3s, padding 0.3s;
overflow: auto;
.stage-column-list, .stage-column-list,
.builds-container > ul { .builds-container > ul {
......
module CycleAnalyticsParams module CycleAnalyticsParams
extend ActiveSupport::Concern extend ActiveSupport::Concern
def options(params)
@options ||= { from: start_date(params), current_user: current_user }
end
def start_date(params) def start_date(params)
params[:start_date] == '30' ? 30.days.ago : 90.days.ago params[:start_date] == '30' ? 30.days.ago : 90.days.ago
end end
......
...@@ -25,9 +25,18 @@ class Projects::CompareController < Projects::ApplicationController ...@@ -25,9 +25,18 @@ class Projects::CompareController < Projects::ApplicationController
end end
def create def create
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, redirect_to namespace_project_compare_path(@project.namespace, @project,
params[:from], params[:to]) params[:from], params[:to])
end end
end
private private
......
...@@ -9,56 +9,52 @@ module Projects ...@@ -9,56 +9,52 @@ module Projects
before_action :authorize_read_merge_request!, only: [:code, :review] before_action :authorize_read_merge_request!, only: [:code, :review]
def issue def issue
render_events(events.issue_events) render_events(cycle_analytics[:issue].events)
end end
def plan def plan
render_events(events.plan_events) render_events(cycle_analytics[:plan].events)
end end
def code def code
render_events(events.code_events) render_events(cycle_analytics[:code].events)
end end
def test 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 end
def review def review
render_events(events.review_events) render_events(cycle_analytics[:review].events)
end end
def staging def staging
render_events(events.staging_events) render_events(cycle_analytics[:staging].events)
end end
def production def production
render_events(events.production_events) render_events(cycle_analytics[:production].events)
end end
private private
def render_events(events_list) def render_events(events)
respond_to do |format| respond_to do |format|
format.html format.html
format.json { render json: { events: events_list } } format.json { render json: { events: events } }
end end
end end
def events def cycle_analytics
@events ||= Gitlab::CycleAnalytics::Events.new(project: project, options: options) @cycle_analytics ||= ::CycleAnalytics.new(project, options(events_params))
end
def options
@options ||= { from: start_date(events_params), current_user: current_user }
end end
def events_params def events_params
return {} unless params[:events].present? return {} unless params[:events].present?
params[:events].slice(:start_date, :branch_name) params[:events].permit(:start_date, :branch_name)
end end
end end
end end
......
...@@ -6,11 +6,9 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController ...@@ -6,11 +6,9 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
before_action :authorize_read_cycle_analytics! before_action :authorize_read_cycle_analytics!
def show 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 = @cycle_analytics.no_stats?
@cycle_analytics_no_data = stats_values.blank?
respond_to do |format| respond_to do |format|
format.html format.html
...@@ -23,50 +21,14 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController ...@@ -23,50 +21,14 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
def cycle_analytics_params def cycle_analytics_params
return {} unless params[:cycle_analytics].present? 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 end
issues = @cycle_analytics.summary.new_issues def cycle_analytics_json
commits = @cycle_analytics.summary.commits {
deploys = @cycle_analytics.summary.deploys summary: @cycle_analytics.summary,
stats: @cycle_analytics.stats,
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) permissions: @cycle_analytics.permissions(user: current_user)
} }
[stats_values, cycle_analytics_hash]
end end
end end
...@@ -347,6 +347,16 @@ class Projects::MergeRequestsController < Projects::ApplicationController ...@@ -347,6 +347,16 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end end
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 def branch_from
# This is always source # This is always source
@source_project = @merge_request.nil? ? @project : @merge_request.source_project @source_project = @merge_request.nil? ? @project : @merge_request.source_project
......
...@@ -23,7 +23,8 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -23,7 +23,8 @@ class Projects::NotesController < Projects::ApplicationController
end end
def create 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) if @note.is_a?(Note)
Banzai::NoteRenderer.render([@note], @project, current_user) Banzai::NoteRenderer.render([@note], @project, current_user)
......
...@@ -165,4 +165,10 @@ module DiffHelper ...@@ -165,4 +165,10 @@ module DiffHelper
link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class] link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class]
end end
def render_overflow_warning?(diff_files)
diffs = @merge_request_diff.presence || diff_files
diffs.overflow?
end
end end
...@@ -30,6 +30,15 @@ module IssuablesHelper ...@@ -30,6 +30,15 @@ module IssuablesHelper
end end
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) def template_dropdown_tag(issuable, &block)
title = selected_template(issuable) || "Choose a template" title = selected_template(issuable) || "Choose a template"
options = { options = {
......
...@@ -19,6 +19,14 @@ module MergeRequestsHelper ...@@ -19,6 +19,14 @@ module MergeRequestsHelper
} }
end 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) def mr_css_classes(mr)
classes = "merge-request" classes = "merge-request"
classes << " closed" if mr.closed? classes << " closed" if mr.closed?
......
...@@ -507,6 +507,10 @@ module Ci ...@@ -507,6 +507,10 @@ module Ci
end end
end end
def has_expiring_artifacts?
artifacts_expire_at.present?
end
def keep_artifacts! def keep_artifacts!
self.update(artifacts_expire_at: nil) self.update(artifacts_expire_at: nil)
end end
......
...@@ -318,6 +318,14 @@ class Commit ...@@ -318,6 +318,14 @@ class Commit
Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options) Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options)
end end
def persisted?
true
end
def touch
# no-op but needs to be defined since #persisted? is defined
end
private private
def commit_reference(from_project, referable_commit_id, full: false) def commit_reference(from_project, referable_commit_id, full: false)
......
...@@ -13,6 +13,7 @@ module Issuable ...@@ -13,6 +13,7 @@ module Issuable
include StripAttribute include StripAttribute
include Awardable include Awardable
include Taskable include Taskable
include TimeTrackable
included do included do
cache_markdown_field :title, pipeline: :single_line cache_markdown_field :title, pipeline: :single_line
......
...@@ -7,11 +7,14 @@ module Milestoneish ...@@ -7,11 +7,14 @@ module Milestoneish
def total_items_count(user) def total_items_count(user)
memoize_per_user(user, :total_items_count) do memoize_per_user(user, :total_items_count) do
issues_count = count_issues_by_state(user).values.sum total_issues_count(user) + merge_requests.size
issues_count + merge_requests.size
end end
end end
def total_issues_count(user)
count_issues_by_state(user).values.sum
end
def complete?(user) def complete?(user)
total_items_count(user) > 0 && total_items_count(user) == closed_items_count(user) total_items_count(user) > 0 && total_items_count(user) == closed_items_count(user)
end 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 class CycleAnalytics
STAGES = %i[issue plan code test review staging production].freeze STAGES = %i[issue plan code test review staging production].freeze
def initialize(project, current_user, from:) def initialize(project, options)
@project = project @project = project
@current_user = current_user @options = options
@from = from
@fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: from, branch: nil)
end end
def summary 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 end
def permissions(user:) def stats
Gitlab::CycleAnalytics::Permissions.get(user: user, project: @project) @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]])
end end
def plan def no_stats?
@fetcher.calculate_metric(:plan, stats.all? { |hash| hash[:value].nil? }
[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 end
def code def permissions(user:)
@fetcher.calculate_metric(:code, Gitlab::CycleAnalytics::Permissions.get(user: user, project: @project)
Issue::Metrics.arel_table[:first_mentioned_in_commit_at],
MergeRequest.arel_table[:created_at])
end end
def test def [](stage_name)
@fetcher.calculate_metric(:test, Gitlab::CycleAnalytics::Stage[stage_name].new(project: @project, options: @options)
MergeRequest::Metrics.arel_table[:latest_build_started_at],
MergeRequest::Metrics.arel_table[:latest_build_finished_at])
end end
def review private
@fetcher.calculate_metric(:review,
MergeRequest.arel_table[:created_at],
MergeRequest::Metrics.arel_table[:merged_at])
end
def staging def stats_per_stage
@fetcher.calculate_metric(:staging, STAGES.map do |stage_name|
MergeRequest::Metrics.arel_table[:merged_at], self[stage_name].as_json
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
end end
def production
@fetcher.calculate_metric(:production,
Issue.arel_table[:created_at],
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
end 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 ...@@ -898,10 +898,22 @@ class MergeRequest < ActiveRecord::Base
end end
def has_commits? def has_commits?
commits_count > 0 merge_request_diff && commits_count > 0
end end
def has_no_commits? def has_no_commits?
!has_commits? !has_commits?
end 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 end
...@@ -234,28 +234,28 @@ class MergeRequestDiff < ActiveRecord::Base ...@@ -234,28 +234,28 @@ class MergeRequestDiff < ActiveRecord::Base
# and save it as array of hashes in st_diffs db field # and save it as array of hashes in st_diffs db field
def save_diffs def save_diffs
new_attributes = {} new_attributes = {}
new_diffs = []
if commits.size.zero? if commits.size.zero?
new_attributes[:state] = :empty new_attributes[:state] = :empty
else else
diff_collection = compare.diffs(Commit.max_diff_options) diff_collection = compare.diffs(Commit.max_diff_options)
new_attributes[:real_size] = compare.diffs.real_size
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
if diff_collection.any? if diff_collection.any?
new_diffs = dump_diffs(diff_collection) new_diffs = dump_diffs(diff_collection)
new_attributes[:state] = :collected new_attributes[:state] = :collected
end 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 end
new_attributes[:st_diffs] = new_diffs
update_columns_serialized(new_attributes) update_columns_serialized(new_attributes)
end end
......
...@@ -25,8 +25,9 @@ class ProjectStatistics < ActiveRecord::Base ...@@ -25,8 +25,9 @@ class ProjectStatistics < ActiveRecord::Base
self.commit_count = project.repository.commit_count self.commit_count = project.repository.commit_count
end end
# Repository#size needs to be converted from MB to Byte.
def update_repository_size def update_repository_size
self.repository_size = project.repository.size self.repository_size = project.repository.size * 1.megabyte
end end
def update_lfs_objects_size 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 ...@@ -13,4 +13,8 @@ class IssuableEntity < Grape::Entity
expose :created_at expose :created_at
expose :updated_at expose :updated_at
expose :deleted_at expose :deleted_at
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
end end
...@@ -36,6 +36,14 @@ class IssuableBaseService < BaseService ...@@ -36,6 +36,14 @@ class IssuableBaseService < BaseService
end end
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) def filter_params(issuable)
ability_name = :"admin_#{issuable.to_ability_name}" ability_name = :"admin_#{issuable.to_ability_name}"
...@@ -272,6 +280,14 @@ class IssuableBaseService < BaseService ...@@ -272,6 +280,14 @@ class IssuableBaseService < BaseService
create_task_status_note(issuable) create_task_status_note(issuable)
end 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 create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end end
end end
...@@ -7,6 +7,8 @@ module MergeRequests ...@@ -7,6 +7,8 @@ module MergeRequests
params.except!(:target_project_id) params.except!(:target_project_id)
params.except!(:source_branch) params.except!(:source_branch)
merge_from_slash_command(merge_request) if params[:merge]
if merge_request.closed_without_fork? if merge_request.closed_without_fork?
params.except!(:target_branch, :force_remove_source_branch) params.except!(:target_branch, :force_remove_source_branch)
end end
...@@ -69,6 +71,19 @@ module MergeRequests ...@@ -69,6 +71,19 @@ module MergeRequests
end end
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 def reopen_service
MergeRequests::ReopenService MergeRequests::ReopenService
end end
......
module Notes module Notes
class CreateService < BaseService class CreateService < BaseService
def execute def execute
merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha)
note = project.notes.new(params) note = project.notes.new(params)
note.author = current_user note.author = current_user
note.system = false note.system = false
...@@ -19,7 +21,8 @@ module Notes ...@@ -19,7 +21,8 @@ module Notes
slash_commands_service = SlashCommandsService.new(project, current_user) slash_commands_service = SlashCommandsService.new(project, current_user)
if slash_commands_service.supported?(note) 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? only_commands = content.empty?
......
...@@ -19,10 +19,10 @@ module Notes ...@@ -19,10 +19,10 @@ module Notes
self.class.supported?(note, current_user) self.class.supported?(note, current_user)
end end
def extract_commands(note) def extract_commands(note, options = {})
return [note.note, {}] unless supported?(note) return [note.note, {}] unless supported?(note)
SlashCommands::InterpretService.new(project, current_user). SlashCommands::InterpretService.new(project, current_user, options).
execute(note.note, note.noteable) execute(note.note, note.noteable)
end end
......
...@@ -2,7 +2,7 @@ module SlashCommands ...@@ -2,7 +2,7 @@ module SlashCommands
class InterpretService < BaseService class InterpretService < BaseService
include Gitlab::SlashCommands::Dsl include Gitlab::SlashCommands::Dsl
attr_reader :issuable attr_reader :issuable, :options
# Takes a text and interprets the commands that are extracted from it. # 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. # Returns the content without commands, and hash of changes to be applied to a record.
...@@ -13,7 +13,8 @@ module SlashCommands ...@@ -13,7 +13,8 @@ module SlashCommands
opts = { opts = {
issuable: issuable, issuable: issuable,
current_user: current_user, current_user: current_user,
project: project project: project,
params: params
} }
content, commands = extractor.extract_commands(content, opts) content, commands = extractor.extract_commands(content, opts)
...@@ -58,6 +59,17 @@ module SlashCommands ...@@ -58,6 +59,17 @@ module SlashCommands
@updates[:state_event] = 'reopen' @updates[:state_event] = 'reopen'
end 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' desc 'Change title'
params '<New title>' params '<New title>'
condition do condition do
...@@ -243,6 +255,50 @@ module SlashCommands ...@@ -243,6 +255,50 @@ module SlashCommands
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip' @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
end 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 # This is a dummy command, so that it appears in the autocomplete commands
desc 'CC' desc 'CC'
params '@user' params '@user'
......
...@@ -109,6 +109,57 @@ module SystemNoteService ...@@ -109,6 +109,57 @@ module SystemNoteService
create_note(noteable: noteable, project: project, author: author, note: body) create_note(noteable: noteable, project: project, author: author, note: body)
end 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 # Called when the status of a Noteable is changed
# #
# noteable - Noteable object # noteable - Noteable object
......
...@@ -22,14 +22,14 @@ ...@@ -22,14 +22,14 @@
%p.build-detail-row %p.build-detail-row
The artifacts were removed The artifacts were removed
#{time_ago_with_tooltip(@build.artifacts_expire_at)} #{time_ago_with_tooltip(@build.artifacts_expire_at)}
- elsif @build.artifacts_expire_at - elsif @build.has_expiring_artifacts?
%p.build-detail-row %p.build-detail-row
The artifacts will be removed in The artifacts will be removed in
%span.js-artifacts-remove= @build.artifacts_expire_at %span.js-artifacts-remove= @build.artifacts_expire_at
- if @build.artifacts? - if @build.artifacts?
.btn-group.btn-group-justified{ role: :group } .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 = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
Keep Keep
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
%a.close{ href: "#", "data-dismiss" => "modal" } × %a.close{ href: "#", "data-dismiss" => "modal" } ×
%h3.page-title== #{label} this #{commit.change_type_title(current_user)} %h3.page-title== #{label} this #{commit.change_type_title(current_user)}
.modal-body .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 .form-group.branch
= label_tag 'target_branch', target_label, class: 'control-label' = label_tag 'target_branch', target_label, class: 'control-label'
.col-sm-10 .col-sm-10
......
.page-content-header .page-content-header
.header-main-content .header-main-content
%strong %strong
= clipboard_button(clipboard_text: @commit.id) = clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
= @commit.short_id = @commit.short_id
%span.hidden-xs authored %span.hidden-xs authored
#{time_ago_with_tooltip(@commit.authored_date)} #{time_ago_with_tooltip(@commit.authored_date)}
......
...@@ -36,6 +36,6 @@ ...@@ -36,6 +36,6 @@
.table-list-cell.commit-actions.hidden-xs .table-list-cell.commit-actions.hidden-xs
- if commit.status(ref) - if commit.status(ref)
= render_commit_status(commit, ref: 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 commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent"
= link_to_browse_code(project, commit) = link_to_browse_code(project, commit)
...@@ -18,8 +18,8 @@ ...@@ -18,8 +18,8 @@
= parallel_diff_btn = parallel_diff_btn
= render 'projects/diffs/stats', diff_files: diff_files = render 'projects/diffs/stats', diff_files: diff_files
- if diff_files.overflow? - if render_overflow_warning?(diff_files)
= render 'projects/diffs/warning', diff_files: diff_files = render 'projects/diffs/warning', diff_files: diffs
.files{ data: { can_create_note: can_create_note } } .files{ data: { can_create_note: can_create_note } }
- diff_files.each_with_index do |diff_file| - diff_files.each_with_index do |diff_file|
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description - page_description @issue.description
- page_card_attributes @issue.card_attributes - page_card_attributes @issue.card_attributes
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('lib_vue')
.clearfix.detail-page-header .clearfix.detail-page-header
.issuable-header .issuable-header
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
- page_description @merge_request.description - page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes - page_card_attributes @merge_request.card_attributes
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('lib_vue')
= page_specific_javascript_bundle_tag('diff_notes') = page_specific_javascript_bundle_tag('diff_notes')
.merge-request{ 'data-url' => merge_request_path(@merge_request) } .merge-request{ 'data-url' => merge_request_path(@merge_request) }
...@@ -112,3 +113,5 @@ ...@@ -112,3 +113,5 @@
merge_request = new MergeRequest({ merge_request = new MergeRequest({
action: "#{controller.action_name}" 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" - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('lib_vue')
= page_specific_javascript_bundle_tag('merge_conflicts') = page_specific_javascript_bundle_tag('merge_conflicts')
= page_specific_javascript_tag('lib/ace.js') = page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/show/mr_title" = 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/merge_requests/show/versions'
= render "projects/diffs/diffs", diffs: @diffs = render "projects/diffs/diffs", diffs: @diffs
- elsif @merge_request_diff.empty? - elsif @merge_request_diff.empty?
.nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch} .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 @@ ...@@ -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| = 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 :view, diff_view
= hidden_field_tag :line_type = hidden_field_tag :line_type
= hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha)
= note_target_fields(@note) = note_target_fields(@note)
= f.hidden_field :commit_id = f.hidden_field :commit_id
= f.hidden_field :line_code = f.hidden_field :line_code
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
.pull-left Last commit .pull-left Last commit
.last-commit.hidden-sm.pull-left .last-commit.hidden-sm.pull-left
%small.light %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" = link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace"
= time_ago_with_tooltip(@commit.committed_date) = time_ago_with_tooltip(@commit.committed_date)
= @commit.full_title = @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) - 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 .issuable-sidebar
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header .block.issuable-sidebar-header
...@@ -72,7 +75,13 @@ ...@@ -72,7 +75,13 @@
.selectbox.hide-collapsed .selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil = 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 }}) = 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) - if issuable.has_attribute?(:due_date)
.block.due_date .block.due_date
.sidebar-collapsed-icon .sidebar-collapsed-icon
...@@ -162,6 +171,8 @@ ...@@ -162,6 +171,8 @@
= clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left") = clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left")
:javascript :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 MilestoneSelect('{"namespace":"#{@project.namespace.path}","path":"#{@project.path}"}');
new LabelsSelect(); new LabelsSelect();
new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}'); new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
.pull-right.light #{milestone.percent_complete(current_user)}% complete .pull-right.light #{milestone.percent_complete(current_user)}% complete
.row .row
.col-sm-6 .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; &middot;
= link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
.col-sm-6= milestone_progress_bar(milestone) .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 ...@@ -94,6 +94,7 @@ constraints(ProjectUrlConstrainer.new) do
get :pipelines get :pipelines
get :merge_check get :merge_check
post :merge post :merge
get :merge_widget_refresh
post :cancel_merge_when_build_succeeds post :cancel_merge_when_build_succeeds
get :ci_status get :ci_status
get :ci_environments_status get :ci_environments_status
......
...@@ -24,6 +24,7 @@ var config = { ...@@ -24,6 +24,7 @@ var config = {
environments: './environments/environments_bundle.js', environments: './environments/environments_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js', filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js', graphs: './graphs/graphs_bundle.js',
issuable: './issuable/issuable_bundle.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
merge_request_widget: './merge_request_widget/ci_bundle.js', merge_request_widget: './merge_request_widget/ci_bundle.js',
network: './network/network_bundle.js', network: './network/network_bundle.js',
...@@ -34,6 +35,7 @@ var config = { ...@@ -34,6 +35,7 @@ var config = {
users: './users/users_bundle.js', users: './users/users_bundle.js',
lib_chart: './lib/chart.js', lib_chart: './lib/chart.js',
lib_d3: './lib/d3.js', lib_d3: './lib/d3.js',
lib_vue: './lib/vue_resource.js',
vue_pipelines: './vue_pipelines_index/index.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 ...@@ -506,6 +506,7 @@ ActiveRecord::Schema.define(version: 20170106172224) do
t.integer "lock_version" t.integer "lock_version"
t.text "title_html" t.text "title_html"
t.text "description_html" t.text "description_html"
t.integer "time_estimate"
end end
add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
...@@ -685,6 +686,7 @@ ActiveRecord::Schema.define(version: 20170106172224) do ...@@ -685,6 +686,7 @@ ActiveRecord::Schema.define(version: 20170106172224) do
t.integer "lock_version" t.integer "lock_version"
t.text "title_html" t.text "title_html"
t.text "description_html" t.text "description_html"
t.integer "time_estimate"
end end
add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree 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 ...@@ -1128,6 +1130,18 @@ ActiveRecord::Schema.define(version: 20170106172224) do
add_index "tags", ["name"], name: "index_tags_on_name", unique: true, using: :btree 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| create_table "todos", force: :cascade do |t|
t.integer "user_id", null: false t.integer "user_id", null: false
t.integer "project_id", null: false t.integer "project_id", null: false
......
...@@ -712,6 +712,146 @@ Example response: ...@@ -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 on issues
Comments are done via the [notes](notes.md) resource. Comments are done via the [notes](notes.md) resource.
...@@ -1018,3 +1018,142 @@ Example response: ...@@ -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. ...@@ -14,6 +14,7 @@ do.
|:---------------------------|:-------------| |:---------------------------|:-------------|
| `/close` | Close the issue or merge request | | `/close` | Close the issue or merge request |
| `/reopen` | Reopen the issue or merge request | | `/reopen` | Reopen the issue or merge request |
| `/merge` | Merge (when build succeeds) |
| `/title <New title>` | Change title | | `/title <New title>` | Change title |
| `/assign @username` | Assign | | `/assign @username` | Assign |
| `/unassign` | Remove assignee | | `/unassign` | Remove assignee |
...@@ -29,3 +30,7 @@ do. ...@@ -29,3 +30,7 @@ do.
| <code>/due &lt;in 2 days &#124; this Friday &#124; December 31st&gt;</code> | Set due date | | <code>/due &lt;in 2 days &#124; this Friday &#124; December 31st&gt;</code> | Set due date |
| `/remove_due_date` | Remove due date | | `/remove_due_date` | Remove due date |
| `/wip` | Toggle the Work In Progress status | | `/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 @@ ...@@ -19,6 +19,7 @@
- [Slash commands](../user/project/slash_commands.md) - [Slash commands](../user/project/slash_commands.md)
- [Sharing a project with a group](share_with_group.md) - [Sharing a project with a group](share_with_group.md)
- [Share projects with other groups](share_projects_with_other_groups.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) - [Web Editor](../user/project/repository/web_editor.md)
- [Releases](releases.md) - [Releases](releases.md)
- [Milestones](milestones.md) - [Milestones](milestones.md)
......
...@@ -79,7 +79,7 @@ Now that SubGit has configured the Git/SVN repos, run `subgit` to perform the ...@@ -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: 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 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 ...@@ -35,7 +35,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps
step 'I have group with projects' do step 'I have group with projects' do
@group = create(:group) @group = create(:group)
@project = create(:project, namespace: @group) @project = create(:empty_project, namespace: @group)
@event = create(:closed_issue_event, project: @project) @event = create(:closed_issue_event, project: @project)
@project.team << [current_user, :master] @project.team << [current_user, :master]
...@@ -54,8 +54,8 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps ...@@ -54,8 +54,8 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps
end end
step 'group has a projects that does not belongs to me' do step 'group has a projects that does not belongs to me' do
@forbidden_project1 = create(:project, group: @group) @forbidden_project1 = create(:empty_project, group: @group)
@forbidden_project2 = create(:project, group: @group) @forbidden_project2 = create(:empty_project, group: @group)
end end
step 'I should see 1 project at group list' do step 'I should see 1 project at group list' do
......
...@@ -79,13 +79,13 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps ...@@ -79,13 +79,13 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps
def project def project
@project ||= begin @project ||= begin
project = create :project project = create(:empty_project)
project.team << [current_user, :master] project.team << [current_user, :master]
project project
end end
end end
def public_project def public_project
@public_project ||= create :project, :public @public_project ||= create(:empty_project, :public)
end end
end end
...@@ -105,14 +105,14 @@ class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps ...@@ -105,14 +105,14 @@ class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps
def project def project
@project ||= begin @project ||= begin
project = create :project project = create(:project, :repository)
project.team << [current_user, :master] project.team << [current_user, :master]
project project
end end
end end
def public_project def public_project
@public_project ||= create :project, :public @public_project ||= create(:project, :public, :repository)
end end
def forked_project def forked_project
......
...@@ -104,7 +104,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps ...@@ -104,7 +104,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
group = owned_group group = owned_group
%w(gitlabhq gitlab-ci cookbook-gitlab).each do |path| %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 milestone = create :milestone, title: "Version 7.2", project: project
create(:label, project: project, title: 'bug') create(:label, project: project, title: 'bug')
......
...@@ -109,7 +109,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps ...@@ -109,7 +109,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
step 'Group "Owned" has archived project' do step 'Group "Owned" has archived project' do
group = Group.find_by(name: 'Owned') 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 end
step 'I should see "archived" label' do step 'I should see "archived" label' do
......
...@@ -162,7 +162,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps ...@@ -162,7 +162,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
step 'I have group with projects' do step 'I have group with projects' do
@group = create(:group) @group = create(:group)
@group.add_owner(current_user) @group.add_owner(current_user)
@project = create(:project, namespace: @group) @project = create(:project, :repository, namespace: @group)
@event = create(:closed_issue_event, project: @project) @event = create(:closed_issue_event, project: @project)
@project.team << [current_user, :master] @project.team << [current_user, :master]
......
...@@ -46,11 +46,11 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps ...@@ -46,11 +46,11 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end end
step 'other projects have deploy keys' do 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] @second_project.team << [current_user, :master]
create(:deploy_keys_project, project: @second_project) 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] @third_project.team << [current_user, :master]
create(:deploy_keys_project, project: @third_project, deploy_key: @second_project.deploy_keys.first) create(:deploy_keys_project, project: @third_project, deploy_key: @second_project.deploy_keys.first)
end end
......
...@@ -9,7 +9,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps ...@@ -9,7 +9,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end end
step 'I am a member of project "Shop"' do step 'I am a member of project "Shop"' do
@project = create(:project, name: "Shop") @project = create(:project, :repository, name: "Shop")
@project.team << [@user, :reporter] @project.team << [@user, :reporter]
end end
...@@ -18,7 +18,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps ...@@ -18,7 +18,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end end
step 'I already have a project named "Shop" in my namespace' do 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 end
step 'I should see a "Name has already been taken" warning' do step 'I should see a "Name has already been taken" warning' do
......
...@@ -7,7 +7,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps ...@@ -7,7 +7,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
step 'I am a member of project "Shop"' do step 'I am a member of project "Shop"' do
@project = Project.find_by(name: "Shop") @project = Project.find_by(name: "Shop")
@project ||= create(:project, name: "Shop") @project ||= create(:project, :repository, name: "Shop")
@project.team << [@user, :reporter] @project.team << [@user, :reporter]
end end
......
...@@ -28,7 +28,7 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps ...@@ -28,7 +28,7 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps
step 'There is an open Merge Request' do step 'There is an open Merge Request' do
@user = create(:user) @user = create(:user)
@project = create(:project, :public) @project = create(:project, :public, :repository)
@project_member = create(:project_member, :developer, user: @user, project: @project) @project_member = create(:project_member, :developer, user: @user, project: @project)
@merge_request = create(:merge_request, :with_diffs, :simple, source_project: @project) @merge_request = create(:merge_request, :with_diffs, :simple, source_project: @project)
end end
......
...@@ -35,7 +35,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps ...@@ -35,7 +35,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps
step 'There is an open Merge Request' do step 'There is an open Merge Request' do
@user = create(:user) @user = create(:user)
@project = create(:project, :public) @project = create(:project, :public, :repository)
@project_member = create(:project_member, :developer, user: @user, project: @project) @project_member = create(:project_member, :developer, user: @user, project: @project)
@merge_request = create(:merge_request, :with_diffs, :simple, source_project: @project) @merge_request = create(:merge_request, :with_diffs, :simple, source_project: @project)
end end
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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