mirror of
https://github.com/go-gitea/gitea
synced 2025-07-22 18:28:37 +00:00
Feature: Timetracking (#2211)
* Added comment's hashtag to url for mail notifications. * Added explanation to return statement + documentation. * Replacing in-line link generation with HTMLURL. (+gofmt) * Replaced action-based model with nil-based model. (+gofmt) * Replaced mailIssueActionToParticipants with mailIssueCommentToParticipants. * Updating comment for mailIssueCommentToParticipants * Added link to comment in "Dashboard" * Deleting feed entry if a comment is going to be deleted * Added migration * Added improved migration to add a CommentID column to action. * Added improved links to comments in feed entries. * Fixes #1956 by filtering for deleted comments that are referenced in actions. * Introducing "IsDeleted" column to action. * Adding design draft (not functional) * Adding database models for stopwatches and trackedtimes * See go-gitea/gitea#967 * Adding design draft (not functional) * Adding translations and improving design * Implementing stopwatch (for timetracking) * Make UI functional * Add hints in timeline for time tracking events * Implementing timetracking feature * Adding "Add time manual" option * Improved stopwatch * Created report of total spent time by user * Only showing total time spent if theire is something to show. * Adding license headers. * Improved error handling for "Add Time Manual" * Adding @sapks 's changes, refactoring * Adding API for feature tracking * Adding unit test * Adding DISABLE/ENABLE option to Repository settings page * Improving translations * Applying @sapk 's changes * Removing repo_unit and using IssuesSetting for disabling/enabling timetracker * Adding DEFAULT_ENABLE_TIMETRACKER to config, installation and admin menu * Improving documentation * Fixing vendor/ folder * Changing timtracking routes by adding subgroups /times and /times/stopwatch (Proposed by @lafriks ) * Restricting write access to timetracking based on the repo settings (Proposed by @lafriks ) * Fixed minor permissions bug. * Adding CanUseTimetracker and IsTimetrackerEnabled in ctx.Repo * Allow assignees and authors to track there time too. * Fixed some build-time-errors + logical errors. * Removing unused Get...ByID functions * Moving IsTimetrackerEnabled from context.Repository to models.Repository * Adding a seperate file for issue related repo functions * Adding license headers * Fixed GetUserByParams return 404 * Moving /users/:username/times to /repos/:username/:reponame/times/:username for security reasons * Adding /repos/:username/times to get all tracked times of the repo * Updating sdk-dependency * Updating swagger.v1.json * Adding warning if user has already a running stopwatch (auto-timetracker) * Replacing GetTrackedTimesBy... with GetTrackedTimes(options FindTrackedTimesOptions) * Changing code.gitea.io/sdk back to code.gitea.io/sdk * Correcting spelling mistake * Updating vendor.json * Changing GET stopwatch/toggle to POST stopwatch/toggle * Changing GET stopwatch/cancel to POST stopwatch/cancel * Added migration for stopwatches/timetracking * Fixed some access bugs for read-only users * Added default allow only contributors to track time value to config * Fixed migration by chaging x.Iterate to x.Find * Resorted imports * Moved Add Time Manually form to repo_form.go * Removed "Seconds" field from Add Time Manually * Resorted imports * Improved permission checking * Fixed some bugs * Added integration test * gofmt * Adding integration test by @lafriks * Added created_unix to comment fixtures * Using last event instead of a fixed event * Adding another integration test by @lafriks * Fixing bug Timetracker enabled causing error 500 at sidebar.tpl * Fixed a refactoring bug that resulted in hiding "HasUserStopwatch" warning. * Returning TrackedTime instead of AddTimeOption at AddTime. * Updating SDK from go-gitea/go-sdk#69 * Resetting Go-SDK back to default repository * Fixing test-vendor by changing ini back to original repository * Adding "tags" to swagger spec * govendor sync * Removed duplicate * Formatting templates * Adding IsTimetrackingEnabled checks to API * Improving translations / english texts * Improving documentation * Updating swagger spec * Fixing integration test caused be translation-changes * Removed encoding issues in local_en-US.ini. * "Added" copyright line * Moved unit.IssuesConfig().EnableTimetracker into a != nil check * Removed some other encoding issues in local_en-US.ini * Improved javascript by checking if data-context exists * Replaced manual comment creation with CreateComment * Removed unnecessary code * Improved error checking * Small cosmetic changes * Replaced int>string>duration parsing with int>duration parsing * Fixed encoding issues * Removed unused imports Signed-off-by: Jonas Franz <info@jonasfranz.software>
This commit is contained in:
@@ -768,6 +768,50 @@ func (err ErrCommentNotExist) Error() string {
|
||||
return fmt.Sprintf("comment does not exist [id: %d, issue_id: %d]", err.ID, err.IssueID)
|
||||
}
|
||||
|
||||
// _________ __ __ .__
|
||||
// / _____// |_ ____ ________ _ _______ _/ |_ ____ | |__
|
||||
// \_____ \\ __\/ _ \\____ \ \/ \/ /\__ \\ __\/ ___\| | \
|
||||
// / \| | ( <_> ) |_> > / / __ \| | \ \___| Y \
|
||||
// /_______ /|__| \____/| __/ \/\_/ (____ /__| \___ >___| /
|
||||
// \/ |__| \/ \/ \/
|
||||
|
||||
// ErrStopwatchNotExist represents a "Stopwatch Not Exist" kind of error.
|
||||
type ErrStopwatchNotExist struct {
|
||||
ID int64
|
||||
}
|
||||
|
||||
// IsErrStopwatchNotExist checks if an error is a ErrStopwatchNotExist.
|
||||
func IsErrStopwatchNotExist(err error) bool {
|
||||
_, ok := err.(ErrStopwatchNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrStopwatchNotExist) Error() string {
|
||||
return fmt.Sprintf("stopwatch does not exist [id: %d]", err.ID)
|
||||
}
|
||||
|
||||
// ___________ __ .______________.__
|
||||
// \__ ___/___________ ____ | | __ ____ __| _/\__ ___/|__| _____ ____
|
||||
// | | \_ __ \__ \ _/ ___\| |/ // __ \ / __ | | | | |/ \_/ __ \
|
||||
// | | | | \// __ \\ \___| <\ ___// /_/ | | | | | Y Y \ ___/
|
||||
// |____| |__| (____ /\___ >__|_ \\___ >____ | |____| |__|__|_| /\___ >
|
||||
// \/ \/ \/ \/ \/ \/ \/
|
||||
|
||||
// ErrTrackedTimeNotExist represents a "TrackedTime Not Exist" kind of error.
|
||||
type ErrTrackedTimeNotExist struct {
|
||||
ID int64
|
||||
}
|
||||
|
||||
// IsErrTrackedTimeNotExist checks if an error is a ErrTrackedTimeNotExist.
|
||||
func IsErrTrackedTimeNotExist(err error) bool {
|
||||
_, ok := err.(ErrTrackedTimeNotExist)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (err ErrTrackedTimeNotExist) Error() string {
|
||||
return fmt.Sprintf("tracked time does not exist [id: %d]", err.ID)
|
||||
}
|
||||
|
||||
// .____ ___. .__
|
||||
// | | _____ \_ |__ ____ | |
|
||||
// | | \__ \ | __ \_/ __ \| |
|
||||
|
@@ -5,15 +5,18 @@
|
||||
issue_id: 1 # in repo_id 1
|
||||
label_id: 1
|
||||
content: "1"
|
||||
created_unix: 946684810
|
||||
-
|
||||
id: 2
|
||||
type: 0 # comment
|
||||
poster_id: 3 # user not watching (see watch.yml)
|
||||
issue_id: 1 # in repo_id 1
|
||||
content: "good work!"
|
||||
created_unix: 946684811
|
||||
-
|
||||
id: 3
|
||||
type: 0 # comment
|
||||
poster_id: 5 # user not watching (see watch.yml)
|
||||
issue_id: 1 # in repo_id 1
|
||||
content: "meh..."
|
||||
created_unix: 946684812
|
||||
|
@@ -57,3 +57,16 @@
|
||||
content: content5
|
||||
is_closed: true
|
||||
is_pull: false
|
||||
-
|
||||
id: 6
|
||||
repo_id: 3
|
||||
index: 1
|
||||
poster_id: 1
|
||||
assignee_id: 1
|
||||
name: issue6
|
||||
content: content6
|
||||
is_closed: false
|
||||
is_pull: false
|
||||
num_comments: 0
|
||||
created_unix: 946684800
|
||||
updated_unix: 978307200
|
||||
|
@@ -11,7 +11,7 @@
|
||||
repo_id: 1
|
||||
type: 2
|
||||
index: 1
|
||||
config: "{}"
|
||||
config: "{\"EnableTimetracker\":true,\"AllowOnlyContributorsToTrackTime\":true}"
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
@@ -51,7 +51,7 @@
|
||||
repo_id: 3
|
||||
type: 2
|
||||
index: 1
|
||||
config: "{}"
|
||||
config: "{\"EnableTimetracker\":false,\"AllowOnlyContributorsToTrackTime\":false}"
|
||||
created_unix: 946684810
|
||||
|
||||
-
|
||||
|
@@ -29,7 +29,7 @@
|
||||
lower_name: repo3
|
||||
name: repo3
|
||||
is_private: true
|
||||
num_issues: 0
|
||||
num_issues: 1
|
||||
num_closed_issues: 0
|
||||
num_pulls: 0
|
||||
num_closed_pulls: 0
|
||||
|
11
models/fixtures/stopwatch.yml
Normal file
11
models/fixtures/stopwatch.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
-
|
||||
id: 1
|
||||
user_id: 1
|
||||
issue_id: 1
|
||||
created_unix: 1500988502
|
||||
|
||||
-
|
||||
id: 2
|
||||
user_id: 2
|
||||
issue_id: 2
|
||||
created_unix: 1500988502
|
34
models/fixtures/tracked_time.yml
Normal file
34
models/fixtures/tracked_time.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
-
|
||||
id: 1
|
||||
user_id: 1
|
||||
issue_id: 1
|
||||
time: 400
|
||||
created_unix: 946684800
|
||||
|
||||
-
|
||||
id: 2
|
||||
user_id: 2
|
||||
issue_id: 2
|
||||
time: 3661
|
||||
created_unix: 946684801
|
||||
|
||||
-
|
||||
id: 3
|
||||
user_id: 2
|
||||
issue_id: 2
|
||||
time: 1
|
||||
created_unix: 946684802
|
||||
|
||||
-
|
||||
id: 4
|
||||
user_id: -1
|
||||
issue_id: 4
|
||||
time: 1
|
||||
created_unix: 946684802
|
||||
|
||||
-
|
||||
id: 5
|
||||
user_id: 2
|
||||
issue_id: 5
|
||||
time: 1
|
||||
created_unix: 946684802
|
@@ -52,6 +52,14 @@ const (
|
||||
CommentTypeChangeTitle
|
||||
// Delete Branch
|
||||
CommentTypeDeleteBranch
|
||||
// Start a stopwatch for time tracking
|
||||
CommentTypeStartTracking
|
||||
// Stop a stopwatch for time tracking
|
||||
CommentTypeStopTracking
|
||||
// Add time manual for time tracking
|
||||
CommentTypeAddTimeManual
|
||||
// Cancel a stopwatch for time tracking
|
||||
CommentTypeCancelTracking
|
||||
)
|
||||
|
||||
// CommentTag defines comment tag type
|
||||
@@ -672,7 +680,6 @@ func DeleteComment(comment *Comment) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := sess.Where("comment_id = ?", comment.ID).Cols("is_deleted").Update(&Action{IsDeleted: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
170
models/issue_stopwatch.go
Normal file
170
models/issue_stopwatch.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-xorm/xorm"
|
||||
)
|
||||
|
||||
// Stopwatch represents a stopwatch for time tracking.
|
||||
type Stopwatch struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IssueID int64 `xorm:"INDEX"`
|
||||
UserID int64 `xorm:"INDEX"`
|
||||
Created time.Time `xorm:"-"`
|
||||
CreatedUnix int64
|
||||
}
|
||||
|
||||
// BeforeInsert will be invoked by XORM before inserting a record
|
||||
// representing this object.
|
||||
func (s *Stopwatch) BeforeInsert() {
|
||||
s.CreatedUnix = time.Now().Unix()
|
||||
}
|
||||
|
||||
// AfterSet is invoked from XORM after setting the value of a field of this object.
|
||||
func (s *Stopwatch) AfterSet(colName string, _ xorm.Cell) {
|
||||
switch colName {
|
||||
|
||||
case "created_unix":
|
||||
s.Created = time.Unix(s.CreatedUnix, 0).Local()
|
||||
}
|
||||
}
|
||||
|
||||
func getStopwatch(e Engine, userID, issueID int64) (sw *Stopwatch, exists bool, err error) {
|
||||
sw = new(Stopwatch)
|
||||
exists, err = e.
|
||||
Where("user_id = ?", userID).
|
||||
And("issue_id = ?", issueID).
|
||||
Get(sw)
|
||||
return
|
||||
}
|
||||
|
||||
// StopwatchExists returns true if the stopwatch exists
|
||||
func StopwatchExists(userID int64, issueID int64) bool {
|
||||
_, exists, _ := getStopwatch(x, userID, issueID)
|
||||
return exists
|
||||
}
|
||||
|
||||
// HasUserStopwatch returns true if the user has a stopwatch
|
||||
func HasUserStopwatch(userID int64) (exists bool, sw *Stopwatch, err error) {
|
||||
sw = new(Stopwatch)
|
||||
exists, err = x.
|
||||
Where("user_id = ?", userID).
|
||||
Get(sw)
|
||||
return
|
||||
}
|
||||
|
||||
// CreateOrStopIssueStopwatch will create or remove a stopwatch and will log it into issue's timeline.
|
||||
func CreateOrStopIssueStopwatch(user *User, issue *Issue) error {
|
||||
sw, exists, err := getStopwatch(x, user.ID, issue.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
// Create tracked time out of the time difference between start date and actual date
|
||||
timediff := time.Now().Unix() - sw.CreatedUnix
|
||||
|
||||
// Create TrackedTime
|
||||
tt := &TrackedTime{
|
||||
Created: time.Now(),
|
||||
IssueID: issue.ID,
|
||||
UserID: user.ID,
|
||||
Time: timediff,
|
||||
}
|
||||
|
||||
if _, err := x.Insert(tt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := CreateComment(&CreateCommentOptions{
|
||||
Doer: user,
|
||||
Issue: issue,
|
||||
Repo: issue.Repo,
|
||||
Content: secToTime(timediff),
|
||||
Type: CommentTypeStopTracking,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := x.Delete(sw); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Create stopwatch
|
||||
sw = &Stopwatch{
|
||||
UserID: user.ID,
|
||||
IssueID: issue.ID,
|
||||
Created: time.Now(),
|
||||
}
|
||||
|
||||
if _, err := x.Insert(sw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := CreateComment(&CreateCommentOptions{
|
||||
Doer: user,
|
||||
Issue: issue,
|
||||
Repo: issue.Repo,
|
||||
Type: CommentTypeStartTracking,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelStopwatch removes the given stopwatch and logs it into issue's timeline.
|
||||
func CancelStopwatch(user *User, issue *Issue) error {
|
||||
sw, exists, err := getStopwatch(x, user.ID, issue.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
if _, err := x.Delete(sw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := CreateComment(&CreateCommentOptions{
|
||||
Doer: user,
|
||||
Issue: issue,
|
||||
Repo: issue.Repo,
|
||||
Type: CommentTypeCancelTracking,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func secToTime(duration int64) string {
|
||||
seconds := duration % 60
|
||||
minutes := (duration / (60)) % 60
|
||||
hours := duration / (60 * 60)
|
||||
|
||||
var hrs string
|
||||
|
||||
if hours > 0 {
|
||||
hrs = fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
if minutes > 0 {
|
||||
if hours == 0 {
|
||||
hrs = fmt.Sprintf("%dmin", minutes)
|
||||
} else {
|
||||
hrs = fmt.Sprintf("%s %dmin", hrs, minutes)
|
||||
}
|
||||
}
|
||||
if seconds > 0 {
|
||||
if hours == 0 && minutes == 0 {
|
||||
hrs = fmt.Sprintf("%ds", seconds)
|
||||
} else {
|
||||
hrs = fmt.Sprintf("%s %ds", hrs, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
return hrs
|
||||
}
|
70
models/issue_stopwatch_test.go
Normal file
70
models/issue_stopwatch_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCancelStopwatch(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
user1, err := GetUserByID(1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
issue1, err := GetIssueByID(1)
|
||||
assert.NoError(t, err)
|
||||
issue2, err := GetIssueByID(2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = CancelStopwatch(user1, issue1)
|
||||
assert.NoError(t, err)
|
||||
AssertNotExistsBean(t, &Stopwatch{UserID: user1.ID, IssueID: issue1.ID})
|
||||
|
||||
_ = AssertExistsAndLoadBean(t, &Comment{Type: CommentTypeCancelTracking, PosterID: user1.ID, IssueID: issue1.ID})
|
||||
|
||||
assert.Nil(t, CancelStopwatch(user1, issue2))
|
||||
}
|
||||
|
||||
func TestStopwatchExists(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
assert.True(t, StopwatchExists(1, 1))
|
||||
assert.False(t, StopwatchExists(1, 2))
|
||||
}
|
||||
|
||||
func TestHasUserStopwatch(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
exists, sw, err := HasUserStopwatch(1)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, int64(1), sw.ID)
|
||||
|
||||
exists, _, err = HasUserStopwatch(3)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestCreateOrStopIssueStopwatch(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
user2, err := GetUserByID(2)
|
||||
assert.NoError(t, err)
|
||||
user3, err := GetUserByID(3)
|
||||
assert.NoError(t, err)
|
||||
|
||||
issue1, err := GetIssueByID(1)
|
||||
assert.NoError(t, err)
|
||||
issue2, err := GetIssueByID(2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, CreateOrStopIssueStopwatch(user3, issue1))
|
||||
sw := AssertExistsAndLoadBean(t, &Stopwatch{UserID: 3, IssueID: 1}).(*Stopwatch)
|
||||
assert.Equal(t, true, sw.Created.Before(time.Now()))
|
||||
|
||||
assert.NoError(t, CreateOrStopIssueStopwatch(user2, issue2))
|
||||
AssertNotExistsBean(t, &Stopwatch{UserID: 2, IssueID: 2})
|
||||
AssertExistsAndLoadBean(t, &TrackedTime{UserID: 2, IssueID: 2})
|
||||
}
|
117
models/issue_tracked_time.go
Normal file
117
models/issue_tracked_time.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-xorm/builder"
|
||||
"github.com/go-xorm/xorm"
|
||||
)
|
||||
|
||||
// TrackedTime represents a time that was spent for a specific issue.
|
||||
type TrackedTime struct {
|
||||
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||
IssueID int64 `xorm:"INDEX" json:"issue_id"`
|
||||
UserID int64 `xorm:"INDEX" json:"user_id"`
|
||||
Created time.Time `xorm:"-" json:"created"`
|
||||
CreatedUnix int64 `json:"-"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
// BeforeInsert will be invoked by XORM before inserting a record
|
||||
// representing this object.
|
||||
func (t *TrackedTime) BeforeInsert() {
|
||||
t.CreatedUnix = time.Now().Unix()
|
||||
}
|
||||
|
||||
// AfterSet is invoked from XORM after setting the value of a field of this object.
|
||||
func (t *TrackedTime) AfterSet(colName string, _ xorm.Cell) {
|
||||
switch colName {
|
||||
case "created_unix":
|
||||
t.Created = time.Unix(t.CreatedUnix, 0).Local()
|
||||
}
|
||||
}
|
||||
|
||||
// FindTrackedTimesOptions represent the filters for tracked times. If an ID is 0 it will be ignored.
|
||||
type FindTrackedTimesOptions struct {
|
||||
IssueID int64
|
||||
UserID int64
|
||||
RepositoryID int64
|
||||
}
|
||||
|
||||
// ToCond will convert each condition into a xorm-Cond
|
||||
func (opts *FindTrackedTimesOptions) ToCond() builder.Cond {
|
||||
cond := builder.NewCond()
|
||||
if opts.IssueID != 0 {
|
||||
cond = cond.And(builder.Eq{"issue_id": opts.IssueID})
|
||||
}
|
||||
if opts.UserID != 0 {
|
||||
cond = cond.And(builder.Eq{"user_id": opts.UserID})
|
||||
}
|
||||
if opts.RepositoryID != 0 {
|
||||
cond = cond.And(builder.Eq{"issue.repo_id": opts.RepositoryID})
|
||||
}
|
||||
return cond
|
||||
}
|
||||
|
||||
// GetTrackedTimes returns all tracked times that fit to the given options.
|
||||
func GetTrackedTimes(options FindTrackedTimesOptions) (trackedTimes []*TrackedTime, err error) {
|
||||
if options.RepositoryID > 0 {
|
||||
err = x.Join("INNER", "issue", "issue.id = tracked_time.issue_id").Where(options.ToCond()).Find(&trackedTimes)
|
||||
return
|
||||
}
|
||||
err = x.Where(options.ToCond()).Find(&trackedTimes)
|
||||
return
|
||||
}
|
||||
|
||||
// AddTime will add the given time (in seconds) to the issue
|
||||
func AddTime(user *User, issue *Issue, time int64) (*TrackedTime, error) {
|
||||
tt := &TrackedTime{
|
||||
IssueID: issue.ID,
|
||||
UserID: user.ID,
|
||||
Time: time,
|
||||
}
|
||||
if _, err := x.Insert(tt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := CreateComment(&CreateCommentOptions{
|
||||
Issue: issue,
|
||||
Repo: issue.Repo,
|
||||
Doer: user,
|
||||
Content: secToTime(time),
|
||||
Type: CommentTypeAddTimeManual,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tt, nil
|
||||
}
|
||||
|
||||
// TotalTimes returns the spent time for each user by an issue
|
||||
func TotalTimes(options FindTrackedTimesOptions) (map[*User]string, error) {
|
||||
trackedTimes, err := GetTrackedTimes(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//Adding total time per user ID
|
||||
totalTimesByUser := make(map[int64]int64)
|
||||
for _, t := range trackedTimes {
|
||||
totalTimesByUser[t.UserID] += t.Time
|
||||
}
|
||||
|
||||
totalTimes := make(map[*User]string)
|
||||
//Fetching User and making time human readable
|
||||
for userID, total := range totalTimesByUser {
|
||||
user, err := GetUserByID(userID)
|
||||
if err != nil {
|
||||
if IsErrUserNotExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
totalTimes[user] = secToTime(total)
|
||||
}
|
||||
return totalTimes, nil
|
||||
}
|
103
models/issue_tracked_time_test.go
Normal file
103
models/issue_tracked_time_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAddTime(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
user3, err := GetUserByID(3)
|
||||
assert.NoError(t, err)
|
||||
|
||||
issue1, err := GetIssueByID(1)
|
||||
assert.NoError(t, err)
|
||||
|
||||
//3661 = 1h 1min 1s
|
||||
trackedTime, err := AddTime(user3, issue1, 3661)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(3), trackedTime.UserID)
|
||||
assert.Equal(t, int64(1), trackedTime.IssueID)
|
||||
assert.Equal(t, int64(3661), trackedTime.Time)
|
||||
|
||||
tt := AssertExistsAndLoadBean(t, &TrackedTime{UserID: 3, IssueID: 1}).(*TrackedTime)
|
||||
assert.Equal(t, tt.Time, int64(3661))
|
||||
|
||||
comment := AssertExistsAndLoadBean(t, &Comment{Type: CommentTypeAddTimeManual, PosterID: 3, IssueID: 1}).(*Comment)
|
||||
assert.Equal(t, comment.Content, "1h 1min 1s")
|
||||
}
|
||||
|
||||
func TestGetTrackedTimes(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
// by Issue
|
||||
times, err := GetTrackedTimes(FindTrackedTimesOptions{IssueID: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, times, 1)
|
||||
assert.Equal(t, times[0].Time, int64(400))
|
||||
|
||||
times, err = GetTrackedTimes(FindTrackedTimesOptions{IssueID: -1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, times, 0)
|
||||
|
||||
// by User
|
||||
times, err = GetTrackedTimes(FindTrackedTimesOptions{UserID: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, times, 1)
|
||||
assert.Equal(t, times[0].Time, int64(400))
|
||||
|
||||
times, err = GetTrackedTimes(FindTrackedTimesOptions{UserID: 3})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, times, 0)
|
||||
|
||||
// by Repo
|
||||
times, err = GetTrackedTimes(FindTrackedTimesOptions{RepositoryID: 2})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, times, 1)
|
||||
assert.Equal(t, times[0].Time, int64(1))
|
||||
issue, err := GetIssueByID(times[0].IssueID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, issue.RepoID, int64(2))
|
||||
|
||||
times, err = GetTrackedTimes(FindTrackedTimesOptions{RepositoryID: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, times, 4)
|
||||
|
||||
times, err = GetTrackedTimes(FindTrackedTimesOptions{RepositoryID: 10})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, times, 0)
|
||||
}
|
||||
|
||||
func TestTotalTimes(t *testing.T) {
|
||||
assert.NoError(t, PrepareTestDatabase())
|
||||
|
||||
total, err := TotalTimes(FindTrackedTimesOptions{IssueID: 1})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, total, 1)
|
||||
for user, time := range total {
|
||||
assert.Equal(t, int64(1), user.ID)
|
||||
assert.Equal(t, "6min 40s", time)
|
||||
}
|
||||
|
||||
total, err = TotalTimes(FindTrackedTimesOptions{IssueID: 2})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, total, 1)
|
||||
for user, time := range total {
|
||||
assert.Equal(t, int64(2), user.ID)
|
||||
assert.Equal(t, "1h 1min 2s", time)
|
||||
}
|
||||
|
||||
total, err = TotalTimes(FindTrackedTimesOptions{IssueID: 5})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, total, 1)
|
||||
for user, time := range total {
|
||||
assert.Equal(t, int64(2), user.ID)
|
||||
assert.Equal(t, "1s", time)
|
||||
}
|
||||
|
||||
total, err = TotalTimes(FindTrackedTimesOptions{IssueID: 4})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, total, 0)
|
||||
}
|
@@ -126,6 +126,8 @@ var migrations = []Migration{
|
||||
NewMigration("unescape user full names", unescapeUserFullNames),
|
||||
// v38 -> v39
|
||||
NewMigration("remove commits and settings unit types", removeCommitsUnitType),
|
||||
// v39 -> v40
|
||||
NewMigration("adds time tracking and stopwatches", addTimetracking),
|
||||
}
|
||||
|
||||
// Migrate database to current version
|
||||
|
@@ -19,9 +19,9 @@ type RepoUnit struct {
|
||||
RepoID int64 `xorm:"INDEX(s)"`
|
||||
Type int `xorm:"INDEX(s)"`
|
||||
Index int
|
||||
Config map[string]string `xorm:"JSON"`
|
||||
CreatedUnix int64 `xorm:"INDEX CREATED"`
|
||||
Created time.Time `xorm:"-"`
|
||||
Config map[string]interface{} `xorm:"JSON"`
|
||||
CreatedUnix int64 `xorm:"INDEX CREATED"`
|
||||
Created time.Time `xorm:"-"`
|
||||
}
|
||||
|
||||
// Enumerate all the unit types
|
||||
@@ -95,7 +95,7 @@ func addUnitsToTables(x *xorm.Engine) error {
|
||||
continue
|
||||
}
|
||||
|
||||
var config = make(map[string]string)
|
||||
var config = make(map[string]interface{})
|
||||
switch i {
|
||||
case V16UnitTypeExternalTracker:
|
||||
config["ExternalTrackerURL"] = repo.ExternalTrackerURL
|
||||
|
65
models/migrations/v39.go
Normal file
65
models/migrations/v39.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
|
||||
"github.com/go-xorm/xorm"
|
||||
)
|
||||
|
||||
// Stopwatch see models/issue_stopwatch.go
|
||||
type Stopwatch struct {
|
||||
ID int64 `xorm:"pk autoincr"`
|
||||
IssueID int64 `xorm:"INDEX"`
|
||||
UserID int64 `xorm:"INDEX"`
|
||||
Created time.Time `xorm:"-"`
|
||||
CreatedUnix int64
|
||||
}
|
||||
|
||||
// TrackedTime see models/issue_tracked_time.go
|
||||
type TrackedTime struct {
|
||||
ID int64 `xorm:"pk autoincr" json:"id"`
|
||||
IssueID int64 `xorm:"INDEX" json:"issue_id"`
|
||||
UserID int64 `xorm:"INDEX" json:"user_id"`
|
||||
Created time.Time `xorm:"-" json:"created"`
|
||||
CreatedUnix int64 `json:"-"`
|
||||
Time int64 `json:"time"`
|
||||
}
|
||||
|
||||
func addTimetracking(x *xorm.Engine) error {
|
||||
if err := x.Sync2(new(Stopwatch)); err != nil {
|
||||
return fmt.Errorf("Sync2: %v", err)
|
||||
}
|
||||
if err := x.Sync2(new(TrackedTime)); err != nil {
|
||||
return fmt.Errorf("Sync2: %v", err)
|
||||
}
|
||||
//Updating existing issue units
|
||||
var units []*RepoUnit
|
||||
x.Where("type = ?", V16UnitTypeIssues).Find(&units)
|
||||
for _, unit := range units {
|
||||
if unit.Config == nil {
|
||||
unit.Config = make(map[string]interface{})
|
||||
}
|
||||
changes := false
|
||||
if _, ok := unit.Config["EnableTimetracker"]; !ok {
|
||||
unit.Config["EnableTimetracker"] = setting.Service.DefaultEnableTimetracking
|
||||
changes = true
|
||||
}
|
||||
if _, ok := unit.Config["AllowOnlyContributorsToTrackTime"]; !ok {
|
||||
unit.Config["AllowOnlyContributorsToTrackTime"] = setting.Service.DefaultAllowOnlyContributorsToTrackTime
|
||||
changes = true
|
||||
}
|
||||
if changes {
|
||||
if _, err := x.Id(unit.ID).Cols("config").Update(unit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
@@ -112,6 +112,8 @@ func init() {
|
||||
new(UserOpenID),
|
||||
new(IssueWatch),
|
||||
new(CommitStatus),
|
||||
new(Stopwatch),
|
||||
new(TrackedTime),
|
||||
)
|
||||
|
||||
gonicNames := []string{"SSL", "UID"}
|
||||
|
@@ -32,8 +32,8 @@ import (
|
||||
"github.com/Unknwon/cae/zip"
|
||||
"github.com/Unknwon/com"
|
||||
"github.com/go-xorm/xorm"
|
||||
version "github.com/mcuadros/go-version"
|
||||
ini "gopkg.in/ini.v1"
|
||||
"github.com/mcuadros/go-version"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -1224,11 +1224,21 @@ func createRepository(e *xorm.Session, doer, u *User, repo *Repository) (err err
|
||||
// insert units for repo
|
||||
var units = make([]RepoUnit, 0, len(defaultRepoUnits))
|
||||
for i, tp := range defaultRepoUnits {
|
||||
units = append(units, RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Index: i,
|
||||
})
|
||||
if tp == UnitTypeIssues {
|
||||
units = append(units, RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Index: i,
|
||||
Config: &IssuesConfig{EnableTimetracker: setting.Service.DefaultEnableTimetracking, AllowOnlyContributorsToTrackTime: setting.Service.DefaultAllowOnlyContributorsToTrackTime},
|
||||
})
|
||||
} else {
|
||||
units = append(units, RepoUnit{
|
||||
RepoID: repo.ID,
|
||||
Type: tp,
|
||||
Index: i,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if _, err = e.Insert(&units); err != nil {
|
||||
|
34
models/repo_issue.go
Normal file
34
models/repo_issue.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package models
|
||||
|
||||
import "code.gitea.io/gitea/modules/setting"
|
||||
|
||||
// ___________.__ ___________ __
|
||||
// \__ ___/|__| _____ ___\__ ___/___________ ____ | | __ ___________
|
||||
// | | | |/ \_/ __ \| | \_ __ \__ \ _/ ___\| |/ // __ \_ __ \
|
||||
// | | | | Y Y \ ___/| | | | \// __ \\ \___| <\ ___/| | \/
|
||||
// |____| |__|__|_| /\___ >____| |__| (____ /\___ >__|_ \\___ >__|
|
||||
// \/ \/ \/ \/ \/ \/
|
||||
|
||||
// IsTimetrackerEnabled returns whether or not the timetracker is enabled. It returns the default value from config if an error occurs.
|
||||
func (repo *Repository) IsTimetrackerEnabled() bool {
|
||||
var u *RepoUnit
|
||||
var err error
|
||||
if u, err = repo.GetUnit(UnitTypeIssues); err != nil {
|
||||
return setting.Service.DefaultEnableTimetracking
|
||||
}
|
||||
return u.IssuesConfig().EnableTimetracker
|
||||
}
|
||||
|
||||
// AllowOnlyContributorsToTrackTime returns value of IssuesConfig or the default value
|
||||
func (repo *Repository) AllowOnlyContributorsToTrackTime() bool {
|
||||
var u *RepoUnit
|
||||
var err error
|
||||
if u, err = repo.GetUnit(UnitTypeIssues); err != nil {
|
||||
return setting.Service.DefaultAllowOnlyContributorsToTrackTime
|
||||
}
|
||||
return u.IssuesConfig().AllowOnlyContributorsToTrackTime
|
||||
}
|
@@ -70,18 +70,36 @@ func (cfg *ExternalTrackerConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
// IssuesConfig describes issues config
|
||||
type IssuesConfig struct {
|
||||
EnableTimetracker bool
|
||||
AllowOnlyContributorsToTrackTime bool
|
||||
}
|
||||
|
||||
// FromDB fills up a IssuesConfig from serialized format.
|
||||
func (cfg *IssuesConfig) FromDB(bs []byte) error {
|
||||
return json.Unmarshal(bs, &cfg)
|
||||
}
|
||||
|
||||
// ToDB exports a IssuesConfig to a serialized format.
|
||||
func (cfg *IssuesConfig) ToDB() ([]byte, error) {
|
||||
return json.Marshal(cfg)
|
||||
}
|
||||
|
||||
// BeforeSet is invoked from XORM before setting the value of a field of this object.
|
||||
func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
|
||||
switch colName {
|
||||
case "type":
|
||||
switch UnitType(Cell2Int64(val)) {
|
||||
case UnitTypeCode, UnitTypeIssues, UnitTypePullRequests, UnitTypeReleases,
|
||||
case UnitTypeCode, UnitTypePullRequests, UnitTypeReleases,
|
||||
UnitTypeWiki:
|
||||
r.Config = new(UnitConfig)
|
||||
case UnitTypeExternalWiki:
|
||||
r.Config = new(ExternalWikiConfig)
|
||||
case UnitTypeExternalTracker:
|
||||
r.Config = new(ExternalTrackerConfig)
|
||||
case UnitTypeIssues:
|
||||
r.Config = new(IssuesConfig)
|
||||
default:
|
||||
panic("unrecognized repo unit type: " + com.ToStr(*val))
|
||||
}
|
||||
@@ -106,11 +124,6 @@ func (r *RepoUnit) CodeConfig() *UnitConfig {
|
||||
return r.Config.(*UnitConfig)
|
||||
}
|
||||
|
||||
// IssuesConfig returns config for UnitTypeIssues
|
||||
func (r *RepoUnit) IssuesConfig() *UnitConfig {
|
||||
return r.Config.(*UnitConfig)
|
||||
}
|
||||
|
||||
// PullRequestsConfig returns config for UnitTypePullRequests
|
||||
func (r *RepoUnit) PullRequestsConfig() *UnitConfig {
|
||||
return r.Config.(*UnitConfig)
|
||||
@@ -126,6 +139,11 @@ func (r *RepoUnit) ExternalWikiConfig() *ExternalWikiConfig {
|
||||
return r.Config.(*ExternalWikiConfig)
|
||||
}
|
||||
|
||||
// IssuesConfig returns config for UnitTypeIssues
|
||||
func (r *RepoUnit) IssuesConfig() *IssuesConfig {
|
||||
return r.Config.(*IssuesConfig)
|
||||
}
|
||||
|
||||
// ExternalTrackerConfig returns config for UnitTypeExternalTracker
|
||||
func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig {
|
||||
return r.Config.(*ExternalTrackerConfig)
|
||||
|
Reference in New Issue
Block a user