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
  ...
This commit is contained in:
Mike Greiling 2017-01-18 22:23:53 -06:00
commit 0d2ae3e7c1
215 changed files with 3719 additions and 713 deletions

View File

@ -62,7 +62,7 @@ Lint/UnusedMethodArgument:
# Offense count: 93
# Configuration parameters: CountComments.
Metrics/BlockLength:
Max: 288
Enabled: false
# Offense count: 3
# Cop supports --auto-correct.
@ -125,7 +125,7 @@ RSpec/MessageSpies:
# Offense count: 3036
RSpec/MultipleExpectations:
Max: 37
Enabled: false
# Offense count: 2133
RSpec/NamedSubject:

View File

@ -3,9 +3,6 @@
/* global ResolveCount */
function requireAll(context) { return context.keys().map(context); }
window.Vue = require('vue');
window.Vue.use(require('vue-resource'));
requireAll(require.context('./models', false, /^\.\/.*\.(js|es6)$/));
requireAll(require.context('./stores', false, /^\.\/.*\.(js|es6)$/));
requireAll(require.context('./services', false, /^\.\/.*\.(js|es6)$/));

View File

@ -0,0 +1 @@
require('./time_tracking/time_tracking_bundle');

View File

@ -0,0 +1,41 @@
/* 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>
`,
});
})();

View File

@ -0,0 +1,69 @@
/* global Vue */
require('../../../lib/utils/pretty_time');
(() => {
const prettyTime = gl.utils.prettyTime;
Vue.component('time-tracking-comparison-pane', {
name: 'time-tracking-comparison-pane',
props: [
'timeSpent',
'timeEstimate',
'timeSpentHumanReadable',
'timeEstimateHumanReadable',
],
computed: {
parsedRemaining() {
const diffSeconds = this.timeEstimate - this.timeSpent;
return prettyTime.parseSeconds(diffSeconds);
},
timeRemainingHumanReadable() {
return prettyTime.stringifyTime(this.parsedRemaining);
},
timeRemainingTooltip() {
const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
return `${prefix} ${this.timeRemainingHumanReadable}`;
},
/* Diff values for comparison meter */
timeRemainingMinutes() {
return this.timeEstimate - this.timeSpent;
},
timeRemainingPercent() {
return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
},
timeRemainingStatusClass() {
return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
},
/* Parsed time values */
parsedEstimate() {
return prettyTime.parseSeconds(this.timeEstimate);
},
parsedSpent() {
return prettyTime.parseSeconds(this.timeSpent);
},
},
template: `
<div class='time-tracking-comparison-pane'>
<div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay'
:aria-valuenow='timeRemainingTooltip'
:title='timeRemainingTooltip'
:data-original-title='timeRemainingTooltip'
:class='timeRemainingStatusClass'>
<div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'>
<div :style='{ width: timeRemainingPercent }' class='meter-fill'></div>
</div>
<div class='compare-display-container'>
<div class='compare-display pull-left'>
<span class='compare-label'>Spent</span>
<span class='compare-value spent'>{{ timeSpentHumanReadable }}</span>
</div>
<div class='compare-display estimated pull-right'>
<span class='compare-label'>Est</span>
<span class='compare-value'>{{ timeEstimateHumanReadable }}</span>
</div>
</div>
</div>
</div>
`,
});
})();

View File

@ -0,0 +1,13 @@
/* global Vue */
(() => {
Vue.component('time-tracking-estimate-only-pane', {
name: 'time-tracking-estimate-only-pane',
props: ['timeEstimateHumanReadable'],
template: `
<div class='time-tracking-estimate-only-pane'>
<span class='bold'>Estimated:</span>
{{ timeEstimateHumanReadable }}
</div>
`,
});
})();

View File

@ -0,0 +1,24 @@
/* global Vue */
(() => {
Vue.component('time-tracking-help-state', {
name: 'time-tracking-help-state',
props: ['docsUrl'],
template: `
<div class='time-tracking-help-state'>
<div class='time-tracking-info'>
<h4>Track time with slash commands</h4>
<p>Slash commands can be used in the issues description and comment boxes.</p>
<p>
<code>/estimate</code>
will update the estimated time with the latest command.
</p>
<p>
<code>/spend</code>
will update the sum of the time spent.
</p>
<a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a>
</div>
</div>
`,
});
})();

View File

@ -0,0 +1,11 @@
/* global Vue */
(() => {
Vue.component('time-tracking-no-tracking-pane', {
name: 'time-tracking-no-tracking-pane',
template: `
<div class='time-tracking-no-tracking-pane'>
<span class='no-value'>No estimate or time spent</span>
</div>
`,
});
})();

View File

@ -0,0 +1,13 @@
/* global Vue */
(() => {
Vue.component('time-tracking-spent-only-pane', {
name: 'time-tracking-spent-only-pane',
props: ['timeSpentHumanReadable'],
template: `
<div class='time-tracking-spend-only-pane'>
<span class='bold'>Spent:</span>
{{ timeSpentHumanReadable }}
</div>
`,
});
})();

View File

@ -0,0 +1,119 @@
/* 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>
`,
});
})();

View File

@ -0,0 +1,62 @@
/* 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 = {}));

View File

@ -4,13 +4,13 @@
* stringifyTime condensed or non-condensed, abbreviateTimelengths)
* */
class PrettyTime {
const utils = window.gl.utils = gl.utils || {};
const prettyTime = utils.prettyTime = {
/*
* Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
* Seconds can be negative or positive, zero or non-zero.
*/
static parseSeconds(seconds) {
parseSeconds(seconds) {
const DAYS_PER_WEEK = 5;
const HOURS_PER_DAY = 8;
const MINUTES_PER_HOUR = 60;
@ -24,7 +24,7 @@
minutes: 1,
};
let unorderedMinutes = PrettyTime.secondsToMinutes(seconds);
let unorderedMinutes = prettyTime.secondsToMinutes(seconds);
return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
@ -33,35 +33,33 @@
return periodCount;
});
}
},
/*
* Accepts a timeObject and returns a condensed string representation of it
* (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
*/
static stringifyTime(timeObject) {
stringifyTime(timeObject) {
const reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => {
const isNonZero = !!unitValue;
return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
}, '').trim();
return reducedTime.length ? reducedTime : '0m';
}
},
/*
* Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
* the first non-zero unit/value pair.
*/
static abbreviateTime(timeStr) {
abbreviateTime(timeStr) {
return timeStr.split(' ')
.filter(unitStr => unitStr.charAt(0) !== '0')[0];
}
},
static secondsToMinutes(seconds) {
secondsToMinutes(seconds) {
return Math.abs(seconds / 60);
}
}
gl.PrettyTime = PrettyTime;
},
};
})(window.gl || (window.gl = {}));

View File

@ -0,0 +1,2 @@
window.Vue = require('vue');
window.Vue.use(require('vue-resource'));

View File

@ -3,6 +3,7 @@
/* global GLForm */
/* global Autosave */
/* global ResolveService */
/* global mrRefreshWidgetUrl */
require('./autosave');
window.autosize = require('vendor/autosize');
@ -245,6 +246,16 @@ require('vendor/task_list');
};
Notes.prototype.handleCreateChanges = function(note) {
if (typeof note === 'undefined') {
return;
}
if (note.commands_changes && note.commands_changes.indexOf('merge') !== -1) {
$.get(mrRefreshWidgetUrl);
}
};
/*
Render note in main comments area.
@ -430,6 +441,7 @@ require('vendor/task_list');
*/
Notes.prototype.addNote = function(xhr, note, status) {
this.handleCreateChanges(note);
return this.renderNote(note);
};

View File

@ -5,18 +5,19 @@
gl.VueStage = Vue.extend({
data() {
return {
count: 0,
builds: '',
spinner: '<span class="fa fa-spinner fa-spin"></span>',
};
},
props: ['stage', 'svgs', 'match'],
methods: {
fetchBuilds() {
if (this.count > 0) return null;
fetchBuilds(e) {
const areaExpanded = e.currentTarget.attributes['aria-expanded'];
if (areaExpanded && (areaExpanded.textContent === 'true')) return null;
return this.$http.get(this.stage.dropdown_path)
.then((response) => {
this.count += 1;
this.builds = JSON.parse(response.body).html;
}, () => {
const flash = new Flash('Something went wrong on our end.');
@ -39,7 +40,7 @@
return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`;
},
svg() {
const icon = this.stage.status.icon;
const { icon } = this.stage.status;
const stageIcon = icon.replace(/icon/i, 'stage_icon');
return this.svgs[this.match(stageIcon)];
},
@ -50,18 +51,25 @@
template: `
<div>
<button
@click='fetchBuilds'
@click='fetchBuilds($event)'
:class="triggerButtonClass"
:title='stage.title'
data-placement="top"
data-toggle="dropdown"
type="button">
type="button"
>
<span v-html="svg"></span>
<i class="fa fa-caret-down "></i>
</button>
<ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container">
<div class="arrow-up"></div>
<div :class="dropdownClass" class="js-builds-dropdown-list scrollable-menu" v-html="buildsOrSpinner"></div>
<div
@click=''
:class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu"
v-html="buildsOrSpinner"
>
</div>
</ul>
</div>
`,

View File

@ -76,7 +76,7 @@
.filter-dropdown {
max-height: 215px;
overflow-x: scroll;
overflow: auto;
}
.filter-dropdown-item {
@ -86,7 +86,7 @@
text-align: left;
padding: 8px 16px;
text-overflow: ellipsis;
overflow-y: hidden;
overflow: hidden;
border-radius: 0;
.fa {

View File

@ -236,9 +236,13 @@ header.header-sidebar-pinned {
@media (min-width: $screen-md-min) {
padding-right: $gutter_width;
.merge-request-tabs-holder.affix {
&:not(.with-overlay) .merge-request-tabs-holder.affix {
right: $gutter_width;
}
&.with-overlay .merge-request-tabs-holder.affix {
right: $sidebar_collapsed_width;
}
}
&.with-overlay {

View File

@ -5,7 +5,7 @@ $sidebar_collapsed_width: 62px;
$sidebar_width: 220px;
$gutter_collapsed_width: 62px;
$gutter_width: 290px;
$gutter_inner_width: 258px;
$gutter_inner_width: 250px;
$sidebar-transition-duration: .15s;
$sidebar-breakpoint: 1024px;
@ -56,6 +56,7 @@ $black-transparent: rgba(0, 0, 0, 0.3);
$border-white-light: darken($white-light, $darken-border-factor);
$border-white-normal: darken($white-normal, $darken-border-factor);
$border-gray-light: darken($gray-light, $darken-border-factor);
$border-gray-normal: darken($gray-normal, $darken-border-factor);
$border-gray-dark: darken($white-normal, $darken-border-factor);
@ -85,6 +86,7 @@ $warning-message-border: #f0e2bb;
*/
$border-color: #e5e5e5;
$focus-border-color: #3aabf0;
$sidebar-collapsed-icon-color: #999;
$well-expand-item: #e8f2f7;
$well-inner-border: #eef0f2;
$well-light-border: #f1f1f1;
@ -280,6 +282,7 @@ $dropdown-hover-color: #3b86ff;
*/
$btn-active-gray: #ececec;
$btn-active-gray-light: e4e7ed;
$btn-white-active: #848484;
/*
* Badges
@ -433,6 +436,7 @@ $help-shortcut-header-color: #333;
*/
$issues-today-bg: #f3fff2;
$issues-today-border: #e1e8d5;
$compare-display-color: #888;
/*
* jQuery UI

View File

@ -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;
}
}

View File

@ -44,8 +44,8 @@
.pipeline-info,
.pipeline-commit,
.pipeline-actions,
.pipeline-stages {
.pipeline-stages,
.pipeline-actions {
width: 20%;
}
}
@ -185,6 +185,7 @@
.stage-cell {
font-size: 0;
padding: 10px 4px;
> .stage-container > div > button > span > svg,
> .stage-container > button > svg {
@ -202,8 +203,8 @@
position: relative;
margin-right: 6px;
.tooltip {
white-space: nowrap;
.tooltip-inner {
padding: 3px 4px;
}
&:not(:last-child) {
@ -348,6 +349,7 @@
padding: $gl-padding;
white-space: nowrap;
transition: max-height 0.3s, padding 0.3s;
overflow: auto;
.stage-column-list,
.builds-container > ul {

View File

@ -1,6 +1,10 @@
module CycleAnalyticsParams
extend ActiveSupport::Concern
def options(params)
@options ||= { from: start_date(params), current_user: current_user }
end
def start_date(params)
params[:start_date] == '30' ? 30.days.ago : 90.days.ago
end

View File

@ -25,8 +25,17 @@ class Projects::CompareController < Projects::ApplicationController
end
def create
redirect_to namespace_project_compare_path(@project.namespace, @project,
if params[:from].blank? || params[:to].blank?
flash[:alert] = "You must select from and to branches"
from_to_vars = {
from: params[:from].presence,
to: params[:to].presence
}
redirect_to namespace_project_compare_index_path(@project.namespace, @project, from_to_vars)
else
redirect_to namespace_project_compare_path(@project.namespace, @project,
params[:from], params[:to])
end
end
private

View File

@ -9,56 +9,52 @@ module Projects
before_action :authorize_read_merge_request!, only: [:code, :review]
def issue
render_events(events.issue_events)
render_events(cycle_analytics[:issue].events)
end
def plan
render_events(events.plan_events)
render_events(cycle_analytics[:plan].events)
end
def code
render_events(events.code_events)
render_events(cycle_analytics[:code].events)
end
def test
options[:branch] = events_params[:branch_name]
options(events_params)[:branch] = events_params[:branch_name]
render_events(events.test_events)
render_events(cycle_analytics[:test].events)
end
def review
render_events(events.review_events)
render_events(cycle_analytics[:review].events)
end
def staging
render_events(events.staging_events)
render_events(cycle_analytics[:staging].events)
end
def production
render_events(events.production_events)
render_events(cycle_analytics[:production].events)
end
private
def render_events(events_list)
def render_events(events)
respond_to do |format|
format.html
format.json { render json: { events: events_list } }
format.json { render json: { events: events } }
end
end
def events
@events ||= Gitlab::CycleAnalytics::Events.new(project: project, options: options)
end
def options
@options ||= { from: start_date(events_params), current_user: current_user }
def cycle_analytics
@cycle_analytics ||= ::CycleAnalytics.new(project, options(events_params))
end
def events_params
return {} unless params[:events].present?
params[:events].slice(:start_date, :branch_name)
params[:events].permit(:start_date, :branch_name)
end
end
end

View File

@ -6,11 +6,9 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
before_action :authorize_read_cycle_analytics!
def show
@cycle_analytics = ::CycleAnalytics.new(@project, current_user, from: start_date(cycle_analytics_params))
@cycle_analytics = ::CycleAnalytics.new(@project, options(cycle_analytics_params))
stats_values, cycle_analytics_json = generate_cycle_analytics_data
@cycle_analytics_no_data = stats_values.blank?
@cycle_analytics_no_data = @cycle_analytics.no_stats?
respond_to do |format|
format.html
@ -23,50 +21,14 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
def cycle_analytics_params
return {} unless params[:cycle_analytics].present?
{ start_date: params[:cycle_analytics][:start_date] }
params[:cycle_analytics].permit(:start_date)
end
def generate_cycle_analytics_data
stats_values = []
cycle_analytics_view_data = [[:issue, "Issue", "Related Issues", "Time before an issue gets scheduled"],
[:plan, "Plan", "Related Commits", "Time before an issue starts implementation"],
[:code, "Code", "Related Merge Requests", "Time spent coding"],
[:test, "Test", "Relative Builds Trigger by Commits", "The time taken to build and test the application"],
[:review, "Review", "Relative Merged Requests", "The time taken to review the code"],
[:staging, "Staging", "Relative Deployed Builds", "The time taken in staging"],
[:production, "Production", "Related Issues", "The total time taken from idea to production"]]
stats = cycle_analytics_view_data.reduce([]) do |stats, (stage_method, stage_text, stage_legend, stage_description)|
value = @cycle_analytics.send(stage_method).presence
stats_values << value.abs if value
stats << {
title: stage_text,
description: stage_description,
legend: stage_legend,
value: value && !value.zero? ? distance_of_time_in_words(value) : nil
}
stats
end
issues = @cycle_analytics.summary.new_issues
commits = @cycle_analytics.summary.commits
deploys = @cycle_analytics.summary.deploys
summary = [
{ title: "New Issue".pluralize(issues), value: issues },
{ title: "Commit".pluralize(commits), value: commits },
{ title: "Deploy".pluralize(deploys), value: deploys }
]
cycle_analytics_hash = { summary: summary,
stats: stats,
permissions: @cycle_analytics.permissions(user: current_user)
def cycle_analytics_json
{
summary: @cycle_analytics.summary,
stats: @cycle_analytics.stats,
permissions: @cycle_analytics.permissions(user: current_user)
}
[stats_values, cycle_analytics_hash]
end
end

View File

@ -347,6 +347,16 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
end
def merge_widget_refresh
if merge_request.in_progress_merge_commit_sha || merge_request.state == 'merged'
@status = :success
elsif merge_request.merge_when_build_succeeds
@status = :merge_when_build_succeeds
end
render 'merge'
end
def branch_from
# This is always source
@source_project = @merge_request.nil? ? @project : @merge_request.source_project

View File

@ -23,7 +23,8 @@ class Projects::NotesController < Projects::ApplicationController
end
def create
@note = Notes::CreateService.new(project, current_user, note_params).execute
create_params = note_params.merge(merge_request_diff_head_sha: params[:merge_request_diff_head_sha])
@note = Notes::CreateService.new(project, current_user, create_params).execute
if @note.is_a?(Note)
Banzai::NoteRenderer.render([@note], @project, current_user)

View File

@ -165,4 +165,10 @@ module DiffHelper
link_to "#{hide_whitespace? ? 'Show' : 'Hide'} whitespace changes", url, class: options[:class]
end
def render_overflow_warning?(diff_files)
diffs = @merge_request_diff.presence || diff_files
diffs.overflow?
end
end

View File

@ -30,6 +30,15 @@ module IssuablesHelper
end
end
def serialize_issuable(issuable)
case issuable
when Issue
IssueSerializer.new.represent(issuable).to_json
when MergeRequest
MergeRequestSerializer.new.represent(issuable).to_json
end
end
def template_dropdown_tag(issuable, &block)
title = selected_template(issuable) || "Choose a template"
options = {

View File

@ -19,6 +19,14 @@ module MergeRequestsHelper
}
end
def mr_widget_refresh_url(mr)
if mr && mr.source_project
merge_widget_refresh_namespace_project_merge_request_url(mr.source_project.namespace, mr.source_project, mr)
else
''
end
end
def mr_css_classes(mr)
classes = "merge-request"
classes << " closed" if mr.closed?

View File

@ -507,6 +507,10 @@ module Ci
end
end
def has_expiring_artifacts?
artifacts_expire_at.present?
end
def keep_artifacts!
self.update(artifacts_expire_at: nil)
end

View File

@ -318,6 +318,14 @@ class Commit
Gitlab::Diff::FileCollection::Commit.new(self, diff_options: diff_options)
end
def persisted?
true
end
def touch
# no-op but needs to be defined since #persisted? is defined
end
private
def commit_reference(from_project, referable_commit_id, full: false)

View File

@ -13,6 +13,7 @@ module Issuable
include StripAttribute
include Awardable
include Taskable
include TimeTrackable
included do
cache_markdown_field :title, pipeline: :single_line

View File

@ -7,11 +7,14 @@ module Milestoneish
def total_items_count(user)
memoize_per_user(user, :total_items_count) do
issues_count = count_issues_by_state(user).values.sum
issues_count + merge_requests.size
total_issues_count(user) + merge_requests.size
end
end
def total_issues_count(user)
count_issues_by_state(user).values.sum
end
def complete?(user)
total_items_count(user) > 0 && total_items_count(user) == closed_items_count(user)
end

View File

@ -0,0 +1,72 @@
# == TimeTrackable concern
#
# Contains functionality related to objects that support time tracking.
#
# Used by Issue and MergeRequest.
#
module TimeTrackable
extend ActiveSupport::Concern
included do
attr_reader :time_spent, :time_spent_user
alias_method :time_spent?, :time_spent
default_value_for :time_estimate, value: 0, allows_nil: false
validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false
validate :check_negative_time_spent
has_many :timelogs, as: :trackable, dependent: :destroy
end
def spend_time(options)
@time_spent = options[:duration]
@time_spent_user = options[:user]
@original_total_time_spent = nil
return if @time_spent == 0
if @time_spent == :reset
reset_spent_time
else
add_or_subtract_spent_time
end
end
alias_method :spend_time=, :spend_time
def total_time_spent
timelogs.sum(:time_spent)
end
def human_total_time_spent
Gitlab::TimeTrackingFormatter.output(total_time_spent)
end
def human_time_estimate
Gitlab::TimeTrackingFormatter.output(time_estimate)
end
private
def reset_spent_time
timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user)
end
def add_or_subtract_spent_time
timelogs.new(time_spent: time_spent, user: @time_spent_user)
end
def check_negative_time_spent
return if time_spent.nil? || time_spent == :reset
# we need to cache the total time spent so multiple calls to #valid?
# doesn't give a false error
@original_total_time_spent ||= total_time_spent
if time_spent < 0 && (time_spent.abs > @original_total_time_spent)
errors.add(:time_spent, 'Time to subtract exceeds the total time spent')
end
end
end

View File

@ -1,62 +1,38 @@
class CycleAnalytics
STAGES = %i[issue plan code test review staging production].freeze
def initialize(project, current_user, from:)
def initialize(project, options)
@project = project
@current_user = current_user
@from = from
@fetcher = Gitlab::CycleAnalytics::MetricsFetcher.new(project: project, from: from, branch: nil)
@options = options
end
def summary
@summary ||= Summary.new(@project, @current_user, from: @from)
@summary ||= ::Gitlab::CycleAnalytics::StageSummary.new(@project,
from: @options[:from],
current_user: @options[:current_user]).data
end
def stats
@stats ||= stats_per_stage
end
def no_stats?
stats.all? { |hash| hash[:value].nil? }
end
def permissions(user:)
Gitlab::CycleAnalytics::Permissions.get(user: user, project: @project)
end
def issue
@fetcher.calculate_metric(:issue,
Issue.arel_table[:created_at],
[Issue::Metrics.arel_table[:first_associated_with_milestone_at],
Issue::Metrics.arel_table[:first_added_to_board_at]])
def [](stage_name)
Gitlab::CycleAnalytics::Stage[stage_name].new(project: @project, options: @options)
end
def plan
@fetcher.calculate_metric(:plan,
[Issue::Metrics.arel_table[:first_associated_with_milestone_at],
Issue::Metrics.arel_table[:first_added_to_board_at]],
Issue::Metrics.arel_table[:first_mentioned_in_commit_at])
end
private
def code
@fetcher.calculate_metric(:code,
Issue::Metrics.arel_table[:first_mentioned_in_commit_at],
MergeRequest.arel_table[:created_at])
end
def test
@fetcher.calculate_metric(:test,
MergeRequest::Metrics.arel_table[:latest_build_started_at],
MergeRequest::Metrics.arel_table[:latest_build_finished_at])
end
def review
@fetcher.calculate_metric(:review,
MergeRequest.arel_table[:created_at],
MergeRequest::Metrics.arel_table[:merged_at])
end
def staging
@fetcher.calculate_metric(:staging,
MergeRequest::Metrics.arel_table[:merged_at],
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
end
def production
@fetcher.calculate_metric(:production,
Issue.arel_table[:created_at],
MergeRequest::Metrics.arel_table[:first_deployed_to_production_at])
def stats_per_stage
STAGES.map do |stage_name|
self[stage_name].as_json
end
end
end

View File

@ -1,43 +0,0 @@
class CycleAnalytics
class Summary
def initialize(project, current_user, from:)
@project = project
@current_user = current_user
@from = from
end
def new_issues
IssuesFinder.new(@current_user, project_id: @project.id).execute.created_after(@from).count
end
def commits
ref = @project.default_branch.presence
count_commits_for(ref)
end
def deploys
@project.deployments.where("created_at > ?", @from).count
end
private
# Don't use the `Gitlab::Git::Repository#log` method, because it enforces
# a limit. Since we need a commit count, we _can't_ enforce a limit, so
# the easiest way forward is to replicate the relevant portions of the
# `log` function here.
def count_commits_for(ref)
return unless ref
repository = @project.repository.raw_repository
sha = @project.repository.commit(ref).sha
cmd = %W(#{Gitlab.config.git.bin_path} --git-dir=#{repository.path} log)
cmd << '--format=%H'
cmd << "--after=#{@from.iso8601}"
cmd << sha
raw_output = IO.popen(cmd) { |io| io.read }
raw_output.lines.count
end
end
end

View File

@ -898,10 +898,22 @@ class MergeRequest < ActiveRecord::Base
end
def has_commits?
commits_count > 0
merge_request_diff && commits_count > 0
end
def has_no_commits?
!has_commits?
end
def mergeable_with_slash_command?(current_user, autocomplete_precheck: false, last_diff_sha: nil)
return false unless can_be_merged_by?(current_user)
return true if autocomplete_precheck
return false unless mergeable?(skip_ci_check: true)
return false if head_pipeline && !(head_pipeline.success? || head_pipeline.active?)
return false if last_diff_sha != diff_head_sha
true
end
end

View File

@ -234,28 +234,28 @@ class MergeRequestDiff < ActiveRecord::Base
# and save it as array of hashes in st_diffs db field
def save_diffs
new_attributes = {}
new_diffs = []
if commits.size.zero?
new_attributes[:state] = :empty
else
diff_collection = compare.diffs(Commit.max_diff_options)
if diff_collection.overflow?
# Set our state to 'overflow' to make the #empty? and #collected?
# methods (generated by StateMachine) return false.
new_attributes[:state] = :overflow
end
new_attributes[:real_size] = diff_collection.real_size
new_attributes[:real_size] = compare.diffs.real_size
if diff_collection.any?
new_diffs = dump_diffs(diff_collection)
new_attributes[:state] = :collected
end
new_attributes[:st_diffs] = new_diffs || []
# Set our state to 'overflow' to make the #empty? and #collected?
# methods (generated by StateMachine) return false.
#
# This attribution has to come at the end of the method so 'overflow'
# state does not get overridden by 'collected'.
new_attributes[:state] = :overflow if diff_collection.overflow?
end
new_attributes[:st_diffs] = new_diffs
update_columns_serialized(new_attributes)
end

View File

@ -25,8 +25,9 @@ class ProjectStatistics < ActiveRecord::Base
self.commit_count = project.repository.commit_count
end
# Repository#size needs to be converted from MB to Byte.
def update_repository_size
self.repository_size = project.repository.size
self.repository_size = project.repository.size * 1.megabyte
end
def update_lfs_objects_size

6
app/models/timelog.rb Normal file
View File

@ -0,0 +1,6 @@
class Timelog < ActiveRecord::Base
validates :time_spent, :user, presence: true
belongs_to :trackable, polymorphic: true
belongs_to :user
end

View File

@ -0,0 +1,10 @@
class AnalyticsStageEntity < Grape::Entity
include EntityDateHelper
expose :title
expose :description
expose :median, as: :value do |stage|
stage.median && !stage.median.zero? ? distance_of_time_in_words(stage.median) : nil
end
end

View File

@ -0,0 +1,3 @@
class AnalyticsStageSerializer < BaseSerializer
entity AnalyticsStageEntity
end

View File

@ -0,0 +1,7 @@
class AnalyticsSummaryEntity < Grape::Entity
expose :value, safe: true
expose :title do |object|
object.title.pluralize(object.value)
end
end

View File

@ -0,0 +1,3 @@
class AnalyticsSummarySerializer < BaseSerializer
entity AnalyticsSummaryEntity
end

View File

@ -13,4 +13,8 @@ class IssuableEntity < Grape::Entity
expose :created_at
expose :updated_at
expose :deleted_at
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
end

View File

@ -36,6 +36,14 @@ class IssuableBaseService < BaseService
end
end
def create_time_estimate_note(issuable)
SystemNoteService.change_time_estimate(issuable, issuable.project, current_user)
end
def create_time_spent_note(issuable)
SystemNoteService.change_time_spent(issuable, issuable.project, current_user)
end
def filter_params(issuable)
ability_name = :"admin_#{issuable.to_ability_name}"
@ -272,6 +280,14 @@ class IssuableBaseService < BaseService
create_task_status_note(issuable)
end
if issuable.previous_changes.include?('time_estimate')
create_time_estimate_note(issuable)
end
if issuable.time_spent?
create_time_spent_note(issuable)
end
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end
end

View File

@ -7,6 +7,8 @@ module MergeRequests
params.except!(:target_project_id)
params.except!(:source_branch)
merge_from_slash_command(merge_request) if params[:merge]
if merge_request.closed_without_fork?
params.except!(:target_branch, :force_remove_source_branch)
end
@ -69,6 +71,19 @@ module MergeRequests
end
end
def merge_from_slash_command(merge_request)
last_diff_sha = params.delete(:merge)
return unless merge_request.mergeable_with_slash_command?(current_user, last_diff_sha: last_diff_sha)
merge_request.update(merge_error: nil)
if merge_request.head_pipeline && merge_request.head_pipeline.active?
MergeRequests::MergeWhenPipelineSucceedsService.new(project, current_user).execute(merge_request)
else
MergeWorker.perform_async(merge_request.id, current_user.id, {})
end
end
def reopen_service
MergeRequests::ReopenService
end

View File

@ -1,6 +1,8 @@
module Notes
class CreateService < BaseService
def execute
merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha)
note = project.notes.new(params)
note.author = current_user
note.system = false
@ -19,7 +21,8 @@ module Notes
slash_commands_service = SlashCommandsService.new(project, current_user)
if slash_commands_service.supported?(note)
content, command_params = slash_commands_service.extract_commands(note)
options = { merge_request_diff_head_sha: merge_request_diff_head_sha }
content, command_params = slash_commands_service.extract_commands(note, options)
only_commands = content.empty?

View File

@ -19,10 +19,10 @@ module Notes
self.class.supported?(note, current_user)
end
def extract_commands(note)
def extract_commands(note, options = {})
return [note.note, {}] unless supported?(note)
SlashCommands::InterpretService.new(project, current_user).
SlashCommands::InterpretService.new(project, current_user, options).
execute(note.note, note.noteable)
end

View File

@ -2,7 +2,7 @@ module SlashCommands
class InterpretService < BaseService
include Gitlab::SlashCommands::Dsl
attr_reader :issuable
attr_reader :issuable, :options
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record.
@ -13,7 +13,8 @@ module SlashCommands
opts = {
issuable: issuable,
current_user: current_user,
project: project
project: project,
params: params
}
content, commands = extractor.extract_commands(content, opts)
@ -58,6 +59,17 @@ module SlashCommands
@updates[:state_event] = 'reopen'
end
desc 'Merge (when build succeeds)'
condition do
last_diff_sha = params && params[:merge_request_diff_head_sha]
issuable.is_a?(MergeRequest) &&
issuable.persisted? &&
issuable.mergeable_with_slash_command?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha)
end
command :merge do
@updates[:merge] = params[:merge_request_diff_head_sha]
end
desc 'Change title'
params '<New title>'
condition do
@ -243,6 +255,50 @@ module SlashCommands
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
end
desc 'Set time estimate'
params '<1w 3d 2h 14m>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :estimate do |raw_duration|
time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration)
if time_estimate
@updates[:time_estimate] = time_estimate
end
end
desc 'Add or substract spent time'
params '<1h 30m | -1h 30m>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end
command :spend do |raw_duration|
time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration)
if time_spent
@updates[:spend_time] = { duration: time_spent, user: current_user }
end
end
desc 'Remove time estimate'
condition do
issuable.persisted? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :remove_estimate do
@updates[:time_estimate] = 0
end
desc 'Remove spent time'
condition do
issuable.persisted? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :remove_time_spent do
@updates[:spend_time] = { duration: :reset, user: current_user }
end
# This is a dummy command, so that it appears in the autocomplete commands
desc 'CC'
params '@user'

View File

@ -109,6 +109,57 @@ module SystemNoteService
create_note(noteable: noteable, project: project, author: author, note: body)
end
# Called when the estimated time of a Noteable is changed
#
# noteable - Noteable object
# project - Project owning noteable
# author - User performing the change
# time_estimate - Estimated time
#
# Example Note text:
#
# "Changed estimate of this issue to 3d 5h"
#
# Returns the created Note object
def change_time_estimate(noteable, project, author)
parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
body = if noteable.time_estimate == 0
"Removed time estimate on this #{noteable.human_class_name}"
else
"Changed time estimate of this #{noteable.human_class_name} to #{parsed_time}"
end
create_note(noteable: noteable, project: project, author: author, note: body)
end
# Called when the spent time of a Noteable is changed
#
# noteable - Noteable object
# project - Project owning noteable
# author - User performing the change
# time_spent - Spent time
#
# Example Note text:
#
# "Added 2h 30m of time spent on this issue"
#
# Returns the created Note object
def change_time_spent(noteable, project, author)
time_spent = noteable.time_spent
if time_spent == :reset
body = "Removed time spent on this #{noteable.human_class_name}"
else
parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
action = time_spent > 0 ? 'Added' : 'Subtracted'
body = "#{action} #{parsed_time} of time spent on this #{noteable.human_class_name}"
end
create_note(noteable: noteable, project: project, author: author, note: body)
end
# Called when the status of a Noteable is changed
#
# noteable - Noteable object

View File

@ -22,14 +22,14 @@
%p.build-detail-row
The artifacts were removed
#{time_ago_with_tooltip(@build.artifacts_expire_at)}
- elsif @build.artifacts_expire_at
- elsif @build.has_expiring_artifacts?
%p.build-detail-row
The artifacts will be removed in
%span.js-artifacts-remove= @build.artifacts_expire_at
- if @build.artifacts?
.btn-group.btn-group-justified{ role: :group }
- if @build.artifacts_expire_at
- if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build)
= link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do
Keep

View File

@ -13,7 +13,7 @@
%a.close{ href: "#", "data-dismiss" => "modal" } ×
%h3.page-title== #{label} this #{commit.change_type_title(current_user)}
.modal-body
= form_tag send("#{type.underscore}_namespace_project_commit_path", @project.namespace, @project, commit.id), method: :post, remote: false, class: "form-horizontal js-#{type}-form js-requires-input" do
= form_tag [type.underscore, @project.namespace, @project, commit], method: :post, remote: false, class: "form-horizontal js-#{type}-form js-requires-input" do
.form-group.branch
= label_tag 'target_branch', target_label, class: 'control-label'
.col-sm-10

View File

@ -1,7 +1,7 @@
.page-content-header
.header-main-content
%strong
= clipboard_button(clipboard_text: @commit.id)
= clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
= @commit.short_id
%span.hidden-xs authored
#{time_ago_with_tooltip(@commit.authored_date)}

View File

@ -36,6 +36,6 @@
.table-list-cell.commit-actions.hidden-xs
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
= clipboard_button(clipboard_text: commit.id)
= clipboard_button(clipboard_text: commit.id, title: "Copy commit SHA to clipboard")
= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent"
= link_to_browse_code(project, commit)

View File

@ -18,8 +18,8 @@
= parallel_diff_btn
= render 'projects/diffs/stats', diff_files: diff_files
- if diff_files.overflow?
= render 'projects/diffs/warning', diff_files: diff_files
- if render_overflow_warning?(diff_files)
= render 'projects/diffs/warning', diff_files: diffs
.files{ data: { can_create_note: can_create_note } }
- diff_files.each_with_index do |diff_file|

View File

@ -2,6 +2,8 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description
- page_card_attributes @issue.card_attributes
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('lib_vue')
.clearfix.detail-page-header
.issuable-header

View File

@ -3,6 +3,7 @@
- page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('lib_vue')
= page_specific_javascript_bundle_tag('diff_notes')
.merge-request{ 'data-url' => merge_request_path(@merge_request) }
@ -112,3 +113,5 @@
merge_request = new MergeRequest({
action: "#{controller.action_name}"
});
var mrRefreshWidgetUrl = "#{mr_widget_refresh_url(@merge_request)}";

View File

@ -1,5 +1,6 @@
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('lib_vue')
= page_specific_javascript_bundle_tag('merge_conflicts')
= page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/show/mr_title"

View File

@ -1,13 +1,5 @@
- if @merge_request_diff.collected?
- if @merge_request_diff.collected? || @merge_request_diff.overflow?
= render 'projects/merge_requests/show/versions'
= render "projects/diffs/diffs", diffs: @diffs
- elsif @merge_request_diff.empty?
.nothing-here-block Nothing to merge from #{@merge_request.source_branch} into #{@merge_request.target_branch}
- else
.alert.alert-warning
%h4
Changes view for this comparison is extremely large.
%p
You can
= link_to "download it", merge_request_path(@merge_request, format: :diff), class: "vlink"
instead.

View File

@ -3,6 +3,7 @@
= form_for [@project.namespace.becomes(Namespace), @project, @note], remote: true, html: { :'data-type' => 'json', multipart: true, id: nil, class: "new-note js-new-note-form js-quick-submit common-note-form", "data-noteable-iid" => @note.noteable.try(:iid), }, authenticity_token: true do |f|
= hidden_field_tag :view, diff_view
= hidden_field_tag :line_type
= hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha)
= note_target_fields(@note)
= f.hidden_field :commit_id
= f.hidden_field :line_code

View File

@ -8,7 +8,7 @@
.pull-left Last commit
.last-commit.hidden-sm.pull-left
%small.light
= clipboard_button(clipboard_text: @commit.id)
= clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
= link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace"
= time_ago_with_tooltip(@commit.committed_date)
= @commit.full_title

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14" enable-background="new 0 0 12 14"><path d="m11.5 2.4l-1.3-1.1-1 1.1 1.4 1.1.9-1.1"/><path d="m6.8 2v-.5h.5v-1.5h-2.6v1.5h.5v.5c-2.9.4-5.2 2.9-5.2 6 0 3.3 2.7 6 6 6s6-2.7 6-6c0-3-2.3-5.6-5.2-6m-.8 10.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5"/><path d="m6.2 8.9h-.5c-.1 0-.2-.1-.2-.2v-3.5c0-.1.1-.2.2-.2h.5c.1 0 .2.1.2.2v3.5c0 .1-.1.2-.2.2"/></svg>

After

Width:  |  Height:  |  Size: 430 B

View File

@ -1,5 +1,8 @@
- todo = issuable_todo(issuable)
%aside.right-sidebar{ class: sidebar_gutter_collapsed_class }
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('issuable')
%aside.right-sidebar{ class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header
@ -72,7 +75,13 @@
.selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil
= dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }})
- if issuable.has_attribute?(:time_estimate)
#issuable-time-tracker.block
%issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'stopwatch-svg' => custom_icon('icon_stopwatch'), 'docs-url' => help_page_path('workflow/time_tracking.md') }
// Fallback while content is loading
.title.hide-collapsed
Time tracking
= icon('spinner spin')
- if issuable.has_attribute?(:due_date)
.block.due_date
.sidebar-collapsed-icon
@ -162,6 +171,8 @@
= clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left")
:javascript
gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}');
new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}");
new MilestoneSelect('{"namespace":"#{@project.namespace.path}","path":"#{@project.path}"}');
new LabelsSelect();
new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');

View File

@ -9,7 +9,7 @@
.pull-right.light #{milestone.percent_complete(current_user)}% complete
.row
.col-sm-6
= link_to pluralize(milestone.issues_visible_to_user(current_user).size, 'Issue'), issues_path
= link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path
&middot;
= link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
.col-sm-6= milestone_progress_bar(milestone)

View File

@ -0,0 +1,4 @@
---
title: Support slash comand `/merge` for merging merge requests.
merge_request: 7746
author: Jarka Kadlecova

View File

@ -0,0 +1,4 @@
---
title: Fixes big pipeline and small pipeline width problems and tooltips text being outside the tooltip
merge_request: 8593
author:

View File

@ -0,0 +1,4 @@
---
title: Adjust ProjectStatistic#repository_size with values saved as MB
merge_request: 8616
author:

View File

@ -0,0 +1,4 @@
---
title: "Correct User-agent placement in robots.txt"
merge_request: 8623
author: Eric Sabelhaus

View File

@ -0,0 +1,3 @@
---
title: 'Copy commit SHA to clipboard'
merge_request: 8547

View File

@ -0,0 +1,4 @@
---
title: Hide build artifacts keep button if operation is not allowed
merge_request: 8501
author:

View File

@ -0,0 +1,4 @@
---
title: Fix Compare page throws 500 error when any branch/reference is not selected
merge_request: 8492
author: Martin Cabrera

View File

@ -0,0 +1,4 @@
---
title: Show 'too many changes' message for created merge requests when they are too large
merge_request:
author:

View File

@ -0,0 +1,4 @@
---
title: Fixed merge request tabs dont move when opening collapsed sidebar
merge_request:
author:

View File

@ -0,0 +1,4 @@
---
title: Use cached values to compute total issues count in milestone index pages
merge_request: 8518
author:

View File

@ -0,0 +1,4 @@
---
title: Add new endpoints for Time Tracking.
merge_request: 8483
author:

View File

@ -94,6 +94,7 @@ constraints(ProjectUrlConstrainer.new) do
get :pipelines
get :merge_check
post :merge
get :merge_widget_refresh
post :cancel_merge_when_build_succeeds
get :ci_status
get :ci_environments_status

View File

@ -24,6 +24,7 @@ var config = {
environments: './environments/environments_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js',
issuable: './issuable/issuable_bundle.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
merge_request_widget: './merge_request_widget/ci_bundle.js',
network: './network/network_bundle.js',
@ -34,6 +35,7 @@ var config = {
users: './users/users_bundle.js',
lib_chart: './lib/chart.js',
lib_d3: './lib/d3.js',
lib_vue: './lib/vue_resource.js',
vue_pipelines: './vue_pipelines_index/index.js',
},

View File

@ -0,0 +1,30 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddTimeEstimateToIssuables < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index" or "add_column_with_default"
# you must disable the use of transactions as these methods can not run in an
# existing transaction. When using "add_concurrent_index" make sure that this
# method is the _only_ method called in the migration, any other changes
# should go in a separate migration. This ensures that upon failure _only_ the
# index creation fails and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def change
add_column :issues, :time_estimate, :integer
add_column :merge_requests, :time_estimate, :integer
end
end

View File

@ -0,0 +1,38 @@
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class CreateTimelogs < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index" or "add_column_with_default"
# you must disable the use of transactions as these methods can not run in an
# existing transaction. When using "add_concurrent_index" make sure that this
# method is the _only_ method called in the migration, any other changes
# should go in a separate migration. This ensures that upon failure _only_ the
# index creation fails and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def change
create_table :timelogs do |t|
t.integer :time_spent, null: false
t.references :trackable, polymorphic: true
t.references :user
t.timestamps null: false
end
add_index :timelogs, [:trackable_type, :trackable_id]
add_index :timelogs, :user_id
end
end

View File

@ -506,6 +506,7 @@ ActiveRecord::Schema.define(version: 20170106172224) do
t.integer "lock_version"
t.text "title_html"
t.text "description_html"
t.integer "time_estimate"
end
add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
@ -685,6 +686,7 @@ ActiveRecord::Schema.define(version: 20170106172224) do
t.integer "lock_version"
t.text "title_html"
t.text "description_html"
t.integer "time_estimate"
end
add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree
@ -1128,6 +1130,18 @@ ActiveRecord::Schema.define(version: 20170106172224) do
add_index "tags", ["name"], name: "index_tags_on_name", unique: true, using: :btree
create_table "timelogs", force: :cascade do |t|
t.integer "time_spent", null: false
t.integer "trackable_id"
t.string "trackable_type"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "timelogs", ["trackable_type", "trackable_id"], name: "index_timelogs_on_trackable_type_and_trackable_id", using: :btree
add_index "timelogs", ["user_id"], name: "index_timelogs_on_user_id", using: :btree
create_table "todos", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "project_id", null: false

View File

@ -712,6 +712,146 @@ Example response:
}
```
## Set a time estimate for an issue
Sets an estimated time of work for this issue.
```
POST /projects/:id/issues/:issue_id/time_estimate
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
| `duration` | string | yes | The duration in human format. e.g: 3h30m |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/time_estimate?duration=3h30m
```
Example response:
```json
{
"human_time_estimate": "3h 30m",
"human_total_time_spent": null,
"time_estimate": 12600,
"total_time_spent": 0
}
```
## Reset the time estimate for an issue
Resets the estimated time for this issue to 0 seconds.
```
POST /projects/:id/issues/:issue_id/reset_time_estimate
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/reset_time_estimate
```
Example response:
```json
{
"human_time_estimate": null,
"human_total_time_spent": null,
"time_estimate": 0,
"total_time_spent": 0
}
```
## Add spent time for an issue
Adds spent time for this issue
```
POST /projects/:id/issues/:issue_id/add_spent_time
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
| `duration` | string | yes | The duration in human format. e.g: 3h30m |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/add_spent_time?duration=1h
```
Example response:
```json
{
"human_time_estimate": null,
"human_total_time_spent": "1h",
"time_estimate": 0,
"total_time_spent": 3600
}
```
## Reset spent time for an issue
Resets the total spent time for this issue to 0 seconds.
```
POST /projects/:id/issues/:issue_id/reset_spent_time
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/reset_spent_time
```
Example response:
```json
{
"human_time_estimate": null,
"human_total_time_spent": null,
"time_estimate": 0,
"total_time_spent": 0
}
```
## Get time tracking stats
```
GET /projects/:id/issues/:issue_id/time_stats
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of a project |
| `issue_id` | integer | yes | The ID of a project's issue |
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/projects/5/issues/93/time_stats
```
Example response:
```json
{
"human_time_estimate": "2h",
"human_total_time_spent": "1h",
"time_estimate": 7200,
"total_time_spent": 3600
}
```
## Comments on issues
Comments are done via the [notes](notes.md) resource.

View File

@ -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
}
```

View File

@ -14,6 +14,7 @@ do.
|:---------------------------|:-------------|
| `/close` | Close the issue or merge request |
| `/reopen` | Reopen the issue or merge request |
| `/merge` | Merge (when build succeeds) |
| `/title <New title>` | Change title |
| `/assign @username` | Assign |
| `/unassign` | Remove assignee |
@ -29,3 +30,7 @@ do.
| <code>/due &lt;in 2 days &#124; this Friday &#124; December 31st&gt;</code> | Set due date |
| `/remove_due_date` | Remove due date |
| `/wip` | Toggle the Work In Progress status |
| <code>/estimate &lt;1w 3d 2h 14m&gt;</code> | Set time estimate |
| `/remove_estimate` | Remove estimated time |
| <code>/spend &lt;1h 30m &#124; -1h 5m&gt;</code> | Add or substract spent time |
| `/remove_time_spent` | Remove time spent |

View File

@ -19,6 +19,7 @@
- [Slash commands](../user/project/slash_commands.md)
- [Sharing a project with a group](share_with_group.md)
- [Share projects with other groups](share_projects_with_other_groups.md)
- [Time tracking](time_tracking.md)
- [Web Editor](../user/project/repository/web_editor.md)
- [Releases](releases.md)
- [Milestones](milestones.md)

View File

@ -79,7 +79,7 @@ Now that SubGit has configured the Git/SVN repos, run `subgit` to perform the
initial translation of existing SVN revisions into the Git repository:
```
subgit install $GIT_REPOS_PATH
subgit install $GIT_REPO_PATH
```
After the initial translation is completed, the Git repository and the SVN

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,73 @@
# Time Tracking
> Introduced in GitLab 8.14.
Time Tracking allows you to track estimates and time spent on issues and merge
requests within GitLab.
## Overview
Time Tracking lets you:
* record the time spent working on an issue or a merge request,
* add an estimate of the amount of time needed to complete an issue or a merge
request.
You don't have to indicate an estimate to enter the time spent, and vice versa.
Data about time tracking is shown on the issue/merge request sidebar, as shown
below.
![Time tracking in the sidebar](time-tracking/time-tracking-sidebar.png)
## How to enter data
Time Tracking uses two [slash commands] that GitLab introduced with this new
feature: `/spend` and `/estimate`.
Slash commands can be used in the body of an issue or a merge request, but also
in a comment in both an issue or a merge request.
Below is an example of how you can use those new slash commands inside a comment.
![Time tracking example in a comment](time-tracking/time-tracking-example.png)
Adding time entries (time spent or estimates) is limited to project members.
### Estimates
To enter an estimate, write `/estimate`, followed by the time. For example, if
you need to enter an estimate of 3 days, 5 hours and 10 minutes, you would write
`/estimate 3d 5h 10m`.
Every time you enter a new time estimate, any previous time estimates will be
overridden by this new value. There should only be one valid estimate in an
issue or a merge request.
To remove an estimation entirely, use `/remove_estimation`.
### Time spent
To enter a time spent, use `/spend 3d 5h 10m`.
Every new time spent entry will be added to the current total time spent for the
issue or the merge request.
You can remove time by entering a negative amount: `/spend -3d` will remove 3
days from the total time spent. You can't go below 0 minutes of time spent,
so GitLab will automatically reset the time spent if you remove a larger amount
of time compared to the time that was entered already.
To remove all the time spent at once, use `/remove_time_spent`.
## Configuration
The following time units are available:
* weeks (w)
* days (d)
* hours (h)
* minutes (m)
Default conversion rates are 1w = 5d and 1d = 8h.
[landing]: https://about.gitlab.com/features/time-tracking
[slash-commands]: ../user/project/slash_commands.md

View File

@ -35,7 +35,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps
step 'I have group with projects' do
@group = create(:group)
@project = create(:project, namespace: @group)
@project = create(:empty_project, namespace: @group)
@event = create(:closed_issue_event, project: @project)
@project.team << [current_user, :master]
@ -54,8 +54,8 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps
end
step 'group has a projects that does not belongs to me' do
@forbidden_project1 = create(:project, group: @group)
@forbidden_project2 = create(:project, group: @group)
@forbidden_project1 = create(:empty_project, group: @group)
@forbidden_project2 = create(:empty_project, group: @group)
end
step 'I should see 1 project at group list' do

View File

@ -79,13 +79,13 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps
def project
@project ||= begin
project = create :project
project = create(:empty_project)
project.team << [current_user, :master]
project
end
end
def public_project
@public_project ||= create :project, :public
@public_project ||= create(:empty_project, :public)
end
end

View File

@ -105,14 +105,14 @@ class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps
def project
@project ||= begin
project = create :project
project = create(:project, :repository)
project.team << [current_user, :master]
project
end
end
def public_project
@public_project ||= create :project, :public
@public_project ||= create(:project, :public, :repository)
end
def forked_project

View File

@ -104,7 +104,7 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
group = owned_group
%w(gitlabhq gitlab-ci cookbook-gitlab).each do |path|
project = create :project, path: path, group: group
project = create(:empty_project, path: path, group: group)
milestone = create :milestone, title: "Version 7.2", project: project
create(:label, project: project, title: 'bug')

View File

@ -109,7 +109,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
step 'Group "Owned" has archived project' do
group = Group.find_by(name: 'Owned')
@archived_project = create(:project, namespace: group, archived: true, path: "archived-project")
@archived_project = create(:empty_project, :archived, namespace: group, path: "archived-project")
end
step 'I should see "archived" label' do

View File

@ -162,7 +162,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
step 'I have group with projects' do
@group = create(:group)
@group.add_owner(current_user)
@project = create(:project, namespace: @group)
@project = create(:project, :repository, namespace: @group)
@event = create(:closed_issue_event, project: @project)
@project.team << [current_user, :master]

View File

@ -46,11 +46,11 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
end
step 'other projects have deploy keys' do
@second_project = create(:project, namespace: create(:group))
@second_project = create(:empty_project, namespace: create(:group))
@second_project.team << [current_user, :master]
create(:deploy_keys_project, project: @second_project)
@third_project = create(:project, namespace: create(:group))
@third_project = create(:empty_project, namespace: create(:group))
@third_project.team << [current_user, :master]
create(:deploy_keys_project, project: @third_project, deploy_key: @second_project.deploy_keys.first)
end

View File

@ -9,7 +9,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end
step 'I am a member of project "Shop"' do
@project = create(:project, name: "Shop")
@project = create(:project, :repository, name: "Shop")
@project.team << [@user, :reporter]
end
@ -18,7 +18,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps
end
step 'I already have a project named "Shop" in my namespace' do
@my_project = create(:project, name: "Shop", namespace: current_user.namespace)
@my_project = create(:project, :repository, name: "Shop", namespace: current_user.namespace)
end
step 'I should see a "Name has already been taken" warning' do

View File

@ -7,7 +7,7 @@ class Spinach::Features::ProjectForkedMergeRequests < Spinach::FeatureSteps
step 'I am a member of project "Shop"' do
@project = Project.find_by(name: "Shop")
@project ||= create(:project, name: "Shop")
@project ||= create(:project, :repository, name: "Shop")
@project.team << [@user, :reporter]
end

View File

@ -28,7 +28,7 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps
step 'There is an open Merge Request' do
@user = create(:user)
@project = create(:project, :public)
@project = create(:project, :public, :repository)
@project_member = create(:project_member, :developer, user: @user, project: @project)
@merge_request = create(:merge_request, :with_diffs, :simple, source_project: @project)
end

View File

@ -35,7 +35,7 @@ class Spinach::Features::RevertMergeRequests < Spinach::FeatureSteps
step 'There is an open Merge Request' do
@user = create(:user)
@project = create(:project, :public)
@project = create(:project, :public, :repository)
@project_member = create(:project_member, :developer, user: @user, project: @project)
@merge_request = create(:merge_request, :with_diffs, :simple, source_project: @project)
end

Some files were not shown because too many files have changed in this diff Show More