Backport backend work for time tracking.
This commit is contained in:
parent
64dd41a0e2
commit
17196a2ff3
30 changed files with 693 additions and 18 deletions
2
app/assets/javascripts/lib/vue_resource.js.es6
Normal file
2
app/assets/javascripts/lib/vue_resource.js.es6
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
//= require vue
|
||||||
|
//= require vue-resource
|
|
@ -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);
|
||||||
|
|
||||||
|
@ -274,6 +275,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
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
|
58
app/models/concerns/time_trackable.rb
Normal file
58
app/models/concerns/time_trackable.rb
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
# == 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
|
||||||
|
|
||||||
|
alias_method :time_spent?, :time_spent
|
||||||
|
|
||||||
|
default_value_for :time_estimate, value: 0, allows_nil: false
|
||||||
|
|
||||||
|
has_many :timelogs, as: :trackable, dependent: :destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
def spend_time(seconds, user)
|
||||||
|
return if seconds == 0
|
||||||
|
|
||||||
|
@time_spent = seconds
|
||||||
|
@time_spent_user = user
|
||||||
|
|
||||||
|
if seconds == :reset
|
||||||
|
reset_spent_time
|
||||||
|
else
|
||||||
|
add_or_subtract_spent_time
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
# Exit if time to subtract exceeds the total time spent.
|
||||||
|
return if time_spent < 0 && (time_spent.abs > total_time_spent)
|
||||||
|
|
||||||
|
timelogs.new(time_spent: time_spent, user: @time_spent_user)
|
||||||
|
end
|
||||||
|
end
|
6
app/models/timelog.rb
Normal file
6
app/models/timelog.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
class Timelog < ActiveRecord::Base
|
||||||
|
validates :time_spent, :user, presence: true
|
||||||
|
|
||||||
|
belongs_to :trackable, polymorphic: true
|
||||||
|
belongs_to :user
|
||||||
|
end
|
|
@ -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
|
||||||
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}"
|
||||||
|
|
||||||
|
@ -156,6 +164,7 @@ class IssuableBaseService < BaseService
|
||||||
def create(issuable)
|
def create(issuable)
|
||||||
merge_slash_commands_into_params!(issuable)
|
merge_slash_commands_into_params!(issuable)
|
||||||
filter_params(issuable)
|
filter_params(issuable)
|
||||||
|
change_time_spent(issuable)
|
||||||
|
|
||||||
params.delete(:state_event)
|
params.delete(:state_event)
|
||||||
params[:author] ||= current_user
|
params[:author] ||= current_user
|
||||||
|
@ -198,13 +207,14 @@ class IssuableBaseService < BaseService
|
||||||
change_subscription(issuable)
|
change_subscription(issuable)
|
||||||
change_todo(issuable)
|
change_todo(issuable)
|
||||||
filter_params(issuable)
|
filter_params(issuable)
|
||||||
|
time_spent = change_time_spent(issuable)
|
||||||
old_labels = issuable.labels.to_a
|
old_labels = issuable.labels.to_a
|
||||||
old_mentioned_users = issuable.mentioned_users.to_a
|
old_mentioned_users = issuable.mentioned_users.to_a
|
||||||
|
|
||||||
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
|
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
|
||||||
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
|
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
|
||||||
|
|
||||||
if params.present? && update_issuable(issuable, params)
|
if (params.present? || time_spent) && update_issuable(issuable, params)
|
||||||
# We do not touch as it will affect a update on updated_at field
|
# We do not touch as it will affect a update on updated_at field
|
||||||
ActiveRecord::Base.no_touching do
|
ActiveRecord::Base.no_touching do
|
||||||
handle_common_system_notes(issuable, old_labels: old_labels)
|
handle_common_system_notes(issuable, old_labels: old_labels)
|
||||||
|
@ -251,6 +261,12 @@ class IssuableBaseService < BaseService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def change_time_spent(issuable)
|
||||||
|
time_spent = params.delete(:spend_time)
|
||||||
|
|
||||||
|
issuable.spend_time(time_spent, current_user) if time_spent
|
||||||
|
end
|
||||||
|
|
||||||
def has_changes?(issuable, old_labels: [])
|
def has_changes?(issuable, old_labels: [])
|
||||||
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
|
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
|
||||||
|
|
||||||
|
@ -272,6 +288,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
|
||||||
|
|
|
@ -243,6 +243,53 @@ 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|
|
||||||
|
reduce_time = raw_duration.sub!(/\A-/, '')
|
||||||
|
time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration)
|
||||||
|
|
||||||
|
if time_spent
|
||||||
|
time_spent *= -1 if reduce_time
|
||||||
|
|
||||||
|
@updates[:spend_time] = time_spent
|
||||||
|
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] = :reset
|
||||||
|
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
|
||||||
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
|
||||||
|
|
30
db/migrate/20161223034433_add_time_estimate_to_issuables.rb
Normal file
30
db/migrate/20161223034433_add_time_estimate_to_issuables.rb
Normal 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
|
38
db/migrate/20161223034646_create_timelogs.rb
Normal file
38
db/migrate/20161223034646_create_timelogs.rb
Normal 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
|
14
db/schema.rb
14
db/schema.rb
|
@ -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
|
||||||
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
|
||||||
|
|
||||||
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
|
||||||
|
|
|
@ -29,3 +29,7 @@ do.
|
||||||
| <code>/due <in 2 days | this Friday | December 31st></code> | Set due date |
|
| <code>/due <in 2 days | this Friday | December 31st></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 <1w 3d 2h 14m></code> | Set time estimate |
|
||||||
|
| `/remove_estimate` | Remove estimated time |
|
||||||
|
| <code>/spend <1h 30m | -1h 5m></code> | Add or substract spent time |
|
||||||
|
| `/remove_time_spent` | Remove time spent |
|
||||||
|
|
|
@ -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)
|
||||||
|
|
BIN
doc/workflow/time-tracking/time-tracking-example.png
Normal file
BIN
doc/workflow/time-tracking/time-tracking-example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
BIN
doc/workflow/time-tracking/time-tracking-sidebar.png
Normal file
BIN
doc/workflow/time-tracking/time-tracking-sidebar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
76
doc/workflow/time_tracking.md
Normal file
76
doc/workflow/time_tracking.md
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
# Time Tracking
|
||||||
|
|
||||||
|
> Introduced in GitLab 8.14.
|
||||||
|
|
||||||
|
Time Tracking lets teams stack their project estimates against their time spent.
|
||||||
|
|
||||||
|
Other interesting links:
|
||||||
|
|
||||||
|
- [Time Tracking landing page on about.gitlab.com][landing]
|
||||||
|
|
||||||
|
## 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
|
|
@ -6,6 +6,7 @@ project_tree:
|
||||||
- :events
|
- :events
|
||||||
- issues:
|
- issues:
|
||||||
- :events
|
- :events
|
||||||
|
- :timelogs
|
||||||
- notes:
|
- notes:
|
||||||
- :author
|
- :author
|
||||||
- :events
|
- :events
|
||||||
|
@ -27,6 +28,7 @@ project_tree:
|
||||||
- :events
|
- :events
|
||||||
- :merge_request_diff
|
- :merge_request_diff
|
||||||
- :events
|
- :events
|
||||||
|
- :timelogs
|
||||||
- label_links:
|
- label_links:
|
||||||
- label:
|
- label:
|
||||||
:priorities
|
:priorities
|
||||||
|
|
30
lib/gitlab/time_tracking_formatter.rb
Normal file
30
lib/gitlab/time_tracking_formatter.rb
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
module Gitlab
|
||||||
|
module TimeTrackingFormatter
|
||||||
|
extend self
|
||||||
|
|
||||||
|
def parse(string)
|
||||||
|
with_custom_config do
|
||||||
|
ChronicDuration.parse(string, default_unit: 'hours') rescue nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def output(seconds)
|
||||||
|
with_custom_config do
|
||||||
|
ChronicDuration.output(seconds, format: :short, limit_to_hours: false, weeks: true) rescue nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_custom_config
|
||||||
|
# We may want to configure it through project settings in a future version.
|
||||||
|
ChronicDuration.hours_per_day = 8
|
||||||
|
ChronicDuration.days_per_week = 5
|
||||||
|
|
||||||
|
result = yield
|
||||||
|
|
||||||
|
ChronicDuration.hours_per_day = 24
|
||||||
|
ChronicDuration.days_per_week = 7
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -326,6 +326,20 @@ describe Projects::IssuesController do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #create' do
|
describe 'POST #create' do
|
||||||
|
def post_new_issue(attrs = {})
|
||||||
|
sign_in(user)
|
||||||
|
project = create(:empty_project, :public)
|
||||||
|
project.team << [user, :developer]
|
||||||
|
|
||||||
|
post :create, {
|
||||||
|
namespace_id: project.namespace.to_param,
|
||||||
|
project_id: project.to_param,
|
||||||
|
issue: { title: 'Title', description: 'Description' }.merge(attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
project.issues.first
|
||||||
|
end
|
||||||
|
|
||||||
context 'resolving discussions in MergeRequest' do
|
context 'resolving discussions in MergeRequest' do
|
||||||
let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
|
let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
|
||||||
let(:merge_request) { discussion.noteable }
|
let(:merge_request) { discussion.noteable }
|
||||||
|
@ -369,13 +383,7 @@ describe Projects::IssuesController do
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_spam_issue
|
def post_spam_issue
|
||||||
sign_in(user)
|
post_new_issue(title: 'Spam Title', description: 'Spam lives here')
|
||||||
spam_project = create(:empty_project, :public)
|
|
||||||
post :create, {
|
|
||||||
namespace_id: spam_project.namespace.to_param,
|
|
||||||
project_id: spam_project.to_param,
|
|
||||||
issue: { title: 'Spam Title', description: 'Spam lives here' }
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects an issue recognized as spam' do
|
it 'rejects an issue recognized as spam' do
|
||||||
|
@ -396,20 +404,28 @@ describe Projects::IssuesController do
|
||||||
request.env['action_dispatch.remote_ip'] = '127.0.0.1'
|
request.env['action_dispatch.remote_ip'] = '127.0.0.1'
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_new_issue
|
|
||||||
sign_in(user)
|
|
||||||
project = create(:empty_project, :public)
|
|
||||||
post :create, {
|
|
||||||
namespace_id: project.namespace.to_param,
|
|
||||||
project_id: project.to_param,
|
|
||||||
issue: { title: 'Title', description: 'Description' }
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates a user agent detail' do
|
it 'creates a user agent detail' do
|
||||||
expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1)
|
expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when description has slash commands' do
|
||||||
|
before do
|
||||||
|
sign_in(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can add spent time' do
|
||||||
|
issue = post_new_issue(description: '/spend 1h')
|
||||||
|
|
||||||
|
expect(issue.total_time_spent).to eq(3600)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can set the time estimate' do
|
||||||
|
issue = post_new_issue(description: '/estimate 2h')
|
||||||
|
|
||||||
|
expect(issue.time_estimate).to eq(7200)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #mark_as_spam' do
|
describe 'POST #mark_as_spam' do
|
||||||
|
|
9
spec/factories/timelogs.rb
Normal file
9
spec/factories/timelogs.rb
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# Read about factories at https://github.com/thoughtbot/factory_girl
|
||||||
|
|
||||||
|
FactoryGirl.define do
|
||||||
|
factory :timelog do
|
||||||
|
time_spent 3600
|
||||||
|
user
|
||||||
|
association :trackable, factory: :issue
|
||||||
|
end
|
||||||
|
end
|
|
@ -126,6 +126,32 @@ feature 'Issues > User uses slash commands', feature: true, js: true do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'Issuable time tracking' do
|
||||||
|
let(:issue) { create(:issue, project: project) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
project.team << [user, :developer]
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'Issue' do
|
||||||
|
before do
|
||||||
|
visit namespace_project_issue_path(project.namespace, project, issue)
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'issuable time tracker'
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'Merge Request' do
|
||||||
|
let(:merge_request) { create(:merge_request, source_project: project) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'issuable time tracker'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe 'toggling the WIP prefix from the title from note' do
|
describe 'toggling the WIP prefix from the title from note' do
|
||||||
let(:issue) { create(:issue, project: project) }
|
let(:issue) { create(:issue, project: project) }
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ issues:
|
||||||
- events
|
- events
|
||||||
- merge_requests_closing_issues
|
- merge_requests_closing_issues
|
||||||
- metrics
|
- metrics
|
||||||
|
- timelogs
|
||||||
events:
|
events:
|
||||||
- author
|
- author
|
||||||
- project
|
- project
|
||||||
|
@ -77,6 +78,7 @@ merge_requests:
|
||||||
- events
|
- events
|
||||||
- merge_requests_closing_issues
|
- merge_requests_closing_issues
|
||||||
- metrics
|
- metrics
|
||||||
|
- timelogs
|
||||||
merge_request_diff:
|
merge_request_diff:
|
||||||
- merge_request
|
- merge_request
|
||||||
pipelines:
|
pipelines:
|
||||||
|
@ -198,3 +200,6 @@ award_emoji:
|
||||||
- user
|
- user
|
||||||
priorities:
|
priorities:
|
||||||
- label
|
- label
|
||||||
|
timelogs:
|
||||||
|
- trackable
|
||||||
|
- user
|
||||||
|
|
|
@ -20,6 +20,7 @@ Issue:
|
||||||
- lock_version
|
- lock_version
|
||||||
- milestone_id
|
- milestone_id
|
||||||
- weight
|
- weight
|
||||||
|
- time_estimate
|
||||||
Event:
|
Event:
|
||||||
- id
|
- id
|
||||||
- target_type
|
- target_type
|
||||||
|
@ -150,6 +151,7 @@ MergeRequest:
|
||||||
- milestone_id
|
- milestone_id
|
||||||
- approvals_before_merge
|
- approvals_before_merge
|
||||||
- rebase_commit_sha
|
- rebase_commit_sha
|
||||||
|
- time_estimate
|
||||||
MergeRequestDiff:
|
MergeRequestDiff:
|
||||||
- id
|
- id
|
||||||
- state
|
- state
|
||||||
|
@ -344,3 +346,11 @@ LabelPriority:
|
||||||
- priority
|
- priority
|
||||||
- created_at
|
- created_at
|
||||||
- updated_at
|
- updated_at
|
||||||
|
Timelog:
|
||||||
|
- id
|
||||||
|
- time_spent
|
||||||
|
- trackable_id
|
||||||
|
- trackable_type
|
||||||
|
- user_id
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
|
|
@ -408,4 +408,42 @@ describe Issue, "Issuable" do
|
||||||
expect(issue.assignee_or_author?(user)).to eq(false)
|
expect(issue.assignee_or_author?(user)).to eq(false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#spend_time' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
let(:issue) { create(:issue) }
|
||||||
|
|
||||||
|
def spend_time(seconds)
|
||||||
|
issue.spend_time(seconds, user)
|
||||||
|
issue.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'adding time' do
|
||||||
|
it 'should update the total time spent' do
|
||||||
|
spend_time(1800)
|
||||||
|
|
||||||
|
expect(issue.total_time_spent).to eq(1800)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'substracting time' do
|
||||||
|
before do
|
||||||
|
spend_time(1800)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'should update the total time spent' do
|
||||||
|
spend_time(-900)
|
||||||
|
|
||||||
|
expect(issue.total_time_spent).to eq(900)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when time to substract exceeds the total time spent' do
|
||||||
|
it 'should not alter the total time spent' do
|
||||||
|
spend_time(-3600)
|
||||||
|
|
||||||
|
expect(issue.total_time_spent).to eq(1800)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
10
spec/models/timelog_spec.rb
Normal file
10
spec/models/timelog_spec.rb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Timelog, type: :model do
|
||||||
|
subject { build(:timelog) }
|
||||||
|
|
||||||
|
it { is_expected.to be_valid }
|
||||||
|
|
||||||
|
it { is_expected.to validate_presence_of(:time_spent) }
|
||||||
|
it { is_expected.to validate_presence_of(:user) }
|
||||||
|
end
|
|
@ -86,6 +86,18 @@ describe Notes::SlashCommandsService, services: true do
|
||||||
expect(note.noteable).to be_open
|
expect(note.noteable).to be_open
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '/spend' do
|
||||||
|
let(:note_text) { '/spend 1h' }
|
||||||
|
|
||||||
|
it 'updates the spent time on the noteable' do
|
||||||
|
content, command_params = service.extract_commands(note)
|
||||||
|
service.execute(command_params, note)
|
||||||
|
|
||||||
|
expect(content).to eq ''
|
||||||
|
expect(note.noteable.time_spent).to eq(3600)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'note with command & text' do
|
describe 'note with command & text' do
|
||||||
|
|
|
@ -210,6 +210,46 @@ describe SlashCommands::InterpretService, services: true do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
shared_examples 'estimate command' do
|
||||||
|
it 'populates time_estimate: 3600 if content contains /estimate 1h' do
|
||||||
|
_, updates = service.execute(content, issuable)
|
||||||
|
|
||||||
|
expect(updates).to eq(time_estimate: 3600)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
shared_examples 'spend command' do
|
||||||
|
it 'populates spend_time: 3600 if content contains /spend 1h' do
|
||||||
|
_, updates = service.execute(content, issuable)
|
||||||
|
|
||||||
|
expect(updates).to eq(spend_time: 3600)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
shared_examples 'spend command with negative time' do
|
||||||
|
it 'populates spend_time: -1800 if content contains /spend -30m' do
|
||||||
|
_, updates = service.execute(content, issuable)
|
||||||
|
|
||||||
|
expect(updates).to eq(spend_time: -1800)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
shared_examples 'remove_estimate command' do
|
||||||
|
it 'populates time_estimate: 0 if content contains /remove_estimate' do
|
||||||
|
_, updates = service.execute(content, issuable)
|
||||||
|
|
||||||
|
expect(updates).to eq(time_estimate: 0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
shared_examples 'remove_time_spent command' do
|
||||||
|
it 'populates spend_time: :reset if content contains /remove_time_spent' do
|
||||||
|
_, updates = service.execute(content, issuable)
|
||||||
|
|
||||||
|
expect(updates).to eq(spend_time: :reset)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
shared_examples 'empty command' do
|
shared_examples 'empty command' do
|
||||||
it 'populates {} if content contains an unsupported command' do
|
it 'populates {} if content contains an unsupported command' do
|
||||||
_, updates = service.execute(content, issuable)
|
_, updates = service.execute(content, issuable)
|
||||||
|
@ -451,6 +491,51 @@ describe SlashCommands::InterpretService, services: true do
|
||||||
let(:issuable) { merge_request }
|
let(:issuable) { merge_request }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'estimate command' do
|
||||||
|
let(:content) { '/estimate 1h' }
|
||||||
|
let(:issuable) { issue }
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'empty command' do
|
||||||
|
let(:content) { '/estimate' }
|
||||||
|
let(:issuable) { issue }
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'empty command' do
|
||||||
|
let(:content) { '/estimate abc' }
|
||||||
|
let(:issuable) { issue }
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'spend command' do
|
||||||
|
let(:content) { '/spend 1h' }
|
||||||
|
let(:issuable) { issue }
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'spend command with negative time' do
|
||||||
|
let(:content) { '/spend -30m' }
|
||||||
|
let(:issuable) { issue }
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'empty command' do
|
||||||
|
let(:content) { '/spend' }
|
||||||
|
let(:issuable) { issue }
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'empty command' do
|
||||||
|
let(:content) { '/spend abc' }
|
||||||
|
let(:issuable) { issue }
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'remove_estimate command' do
|
||||||
|
let(:content) { '/remove_estimate' }
|
||||||
|
let(:issuable) { issue }
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'remove_time_spent command' do
|
||||||
|
let(:content) { '/remove_time_spent' }
|
||||||
|
let(:issuable) { issue }
|
||||||
|
end
|
||||||
|
|
||||||
context 'when current_user cannot :admin_issue' do
|
context 'when current_user cannot :admin_issue' do
|
||||||
let(:visitor) { create(:user) }
|
let(:visitor) { create(:user) }
|
||||||
let(:issue) { create(:issue, project: project, author: visitor) }
|
let(:issue) { create(:issue, project: project, author: visitor) }
|
||||||
|
|
|
@ -740,4 +740,69 @@ describe SystemNoteService, services: true do
|
||||||
expect(note.note).to include(issue.to_reference)
|
expect(note.note).to include(issue.to_reference)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '.change_time_estimate' do
|
||||||
|
subject { described_class.change_time_estimate(noteable, project, author) }
|
||||||
|
|
||||||
|
it_behaves_like 'a system note'
|
||||||
|
|
||||||
|
context 'with a time estimate' do
|
||||||
|
it 'sets the note text' do
|
||||||
|
noteable.update_attribute(:time_estimate, 277200)
|
||||||
|
|
||||||
|
expect(subject.note).to eq "Changed time estimate of this issue to 1w 4d 5h"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'without a time estimate' do
|
||||||
|
it 'sets the note text' do
|
||||||
|
expect(subject.note).to eq "Removed time estimate on this issue"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.change_time_spent' do
|
||||||
|
# We need a custom noteable in order to the shared examples to be green.
|
||||||
|
let(:noteable) do
|
||||||
|
mr = create(:merge_request, source_project: project)
|
||||||
|
mr.spend_time(1, author)
|
||||||
|
mr.save!
|
||||||
|
mr
|
||||||
|
end
|
||||||
|
|
||||||
|
subject do
|
||||||
|
described_class.change_time_spent(noteable, project, author)
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'a system note'
|
||||||
|
|
||||||
|
context 'when time was added' do
|
||||||
|
it 'sets the note text' do
|
||||||
|
spend_time!(277200)
|
||||||
|
|
||||||
|
expect(subject.note).to eq "Added 1w 4d 5h of time spent on this merge request"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when time was subtracted' do
|
||||||
|
it 'sets the note text' do
|
||||||
|
spend_time!(-277200)
|
||||||
|
|
||||||
|
expect(subject.note).to eq "Subtracted 1w 4d 5h of time spent on this merge request"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when time was removed' do
|
||||||
|
it 'sets the note text' do
|
||||||
|
spend_time!(:reset)
|
||||||
|
|
||||||
|
expect(subject.note).to eq "Removed time spent on this merge request"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def spend_time!(seconds)
|
||||||
|
noteable.spend_time(seconds, author)
|
||||||
|
noteable.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in a new issue