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:
parent
69dfe43ffc
commit
5ccecb44ad
42 changed files with 1523 additions and 72 deletions
6
conf/app.ini
vendored
6
conf/app.ini
vendored
|
@ -265,6 +265,12 @@ DEFAULT_KEEP_EMAIL_PRIVATE = false
|
||||||
; Default value for AllowCreateOrganization
|
; Default value for AllowCreateOrganization
|
||||||
; New user will have rights set to create organizations depending on this setting
|
; New user will have rights set to create organizations depending on this setting
|
||||||
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||||
|
; Default value for EnableTimetracking
|
||||||
|
; Repositories will use timetracking by default depending on this setting
|
||||||
|
DEFAULT_ENABLE_TIMETRACKING = true
|
||||||
|
; Default value for AllowOnlyContributorsToTrackTime
|
||||||
|
; Only users with write permissions could track time if this is true
|
||||||
|
DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME = true
|
||||||
; Default value for the domain part of the user's email address in the git log
|
; Default value for the domain part of the user's email address in the git log
|
||||||
; if he has set KeepEmailPrivate true. The user's email replaced with a
|
; if he has set KeepEmailPrivate true. The user's email replaced with a
|
||||||
; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS.
|
; concatenation of the user name in lower case, "@" and NO_REPLY_ADDRESS.
|
||||||
|
|
|
@ -40,3 +40,13 @@ func (doc *HTMLDoc) GetInputValueByName(name string) string {
|
||||||
func (doc *HTMLDoc) GetCSRF() string {
|
func (doc *HTMLDoc) GetCSRF() string {
|
||||||
return doc.GetInputValueByName("_csrf")
|
return doc.GetInputValueByName("_csrf")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssertElement check if element by selector exists or does not exist depending on checkExists
|
||||||
|
func (doc *HTMLDoc) AssertElement(t testing.TB, selector string, checkExists bool) {
|
||||||
|
sel := doc.doc.Find(selector)
|
||||||
|
if checkExists {
|
||||||
|
assert.Equal(t, 1, sel.Length())
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 0, sel.Length())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
74
integrations/timetracking_test.go
Normal file
74
integrations/timetracking_test.go
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
// 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 integrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestViewTimetrackingControls(t *testing.T) {
|
||||||
|
prepareTestEnv(t)
|
||||||
|
session := loginUser(t, "user2")
|
||||||
|
testViewTimetrackingControls(t, session, "user2", "repo1", "1", true)
|
||||||
|
//user2/repo1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotViewTimetrackingControls(t *testing.T) {
|
||||||
|
prepareTestEnv(t)
|
||||||
|
session := loginUser(t, "user5")
|
||||||
|
testViewTimetrackingControls(t, session, "user2", "repo1", "1", false)
|
||||||
|
//user2/repo1
|
||||||
|
}
|
||||||
|
func TestViewTimetrackingControlsDisabled(t *testing.T) {
|
||||||
|
prepareTestEnv(t)
|
||||||
|
session := loginUser(t, "user2")
|
||||||
|
testViewTimetrackingControls(t, session, "user3", "repo3", "1", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo, issue string, canTrackTime bool) {
|
||||||
|
req := NewRequest(t, "GET", path.Join(user, repo, "issues", issue))
|
||||||
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||||
|
|
||||||
|
htmlDoc.AssertElement(t, ".timetrack .start-add .start", canTrackTime)
|
||||||
|
htmlDoc.AssertElement(t, ".timetrack .start-add .add-time", canTrackTime)
|
||||||
|
|
||||||
|
req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{
|
||||||
|
"_csrf": htmlDoc.GetCSRF(),
|
||||||
|
})
|
||||||
|
if canTrackTime {
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", RedirectURL(t, resp))
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
htmlDoc = NewHTMLParser(t, resp.Body)
|
||||||
|
|
||||||
|
events := htmlDoc.doc.Find(".event > span.text")
|
||||||
|
assert.Contains(t, events.Last().Text(), "started working")
|
||||||
|
|
||||||
|
htmlDoc.AssertElement(t, ".timetrack .stop-cancel .stop", true)
|
||||||
|
htmlDoc.AssertElement(t, ".timetrack .stop-cancel .cancel", true)
|
||||||
|
|
||||||
|
req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{
|
||||||
|
"_csrf": htmlDoc.GetCSRF(),
|
||||||
|
})
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusSeeOther)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", RedirectURL(t, resp))
|
||||||
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
||||||
|
htmlDoc = NewHTMLParser(t, resp.Body)
|
||||||
|
|
||||||
|
events = htmlDoc.doc.Find(".event > span.text")
|
||||||
|
assert.Contains(t, events.Last().Text(), "stopped working")
|
||||||
|
htmlDoc.AssertElement(t, ".event .detail .octicon-clock", true)
|
||||||
|
} else {
|
||||||
|
session.MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
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
|
issue_id: 1 # in repo_id 1
|
||||||
label_id: 1
|
label_id: 1
|
||||||
content: "1"
|
content: "1"
|
||||||
|
created_unix: 946684810
|
||||||
-
|
-
|
||||||
id: 2
|
id: 2
|
||||||
type: 0 # comment
|
type: 0 # comment
|
||||||
poster_id: 3 # user not watching (see watch.yml)
|
poster_id: 3 # user not watching (see watch.yml)
|
||||||
issue_id: 1 # in repo_id 1
|
issue_id: 1 # in repo_id 1
|
||||||
content: "good work!"
|
content: "good work!"
|
||||||
|
created_unix: 946684811
|
||||||
-
|
-
|
||||||
id: 3
|
id: 3
|
||||||
type: 0 # comment
|
type: 0 # comment
|
||||||
poster_id: 5 # user not watching (see watch.yml)
|
poster_id: 5 # user not watching (see watch.yml)
|
||||||
issue_id: 1 # in repo_id 1
|
issue_id: 1 # in repo_id 1
|
||||||
content: "meh..."
|
content: "meh..."
|
||||||
|
created_unix: 946684812
|
||||||
|
|
|
@ -57,3 +57,16 @@
|
||||||
content: content5
|
content: content5
|
||||||
is_closed: true
|
is_closed: true
|
||||||
is_pull: false
|
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
|
repo_id: 1
|
||||||
type: 2
|
type: 2
|
||||||
index: 1
|
index: 1
|
||||||
config: "{}"
|
config: "{\"EnableTimetracker\":true,\"AllowOnlyContributorsToTrackTime\":true}"
|
||||||
created_unix: 946684810
|
created_unix: 946684810
|
||||||
|
|
||||||
-
|
-
|
||||||
|
@ -51,7 +51,7 @@
|
||||||
repo_id: 3
|
repo_id: 3
|
||||||
type: 2
|
type: 2
|
||||||
index: 1
|
index: 1
|
||||||
config: "{}"
|
config: "{\"EnableTimetracker\":false,\"AllowOnlyContributorsToTrackTime\":false}"
|
||||||
created_unix: 946684810
|
created_unix: 946684810
|
||||||
|
|
||||||
-
|
-
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
lower_name: repo3
|
lower_name: repo3
|
||||||
name: repo3
|
name: repo3
|
||||||
is_private: true
|
is_private: true
|
||||||
num_issues: 0
|
num_issues: 1
|
||||||
num_closed_issues: 0
|
num_closed_issues: 0
|
||||||
num_pulls: 0
|
num_pulls: 0
|
||||||
num_closed_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
|
CommentTypeChangeTitle
|
||||||
// Delete Branch
|
// Delete Branch
|
||||||
CommentTypeDeleteBranch
|
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
|
// CommentTag defines comment tag type
|
||||||
|
@ -672,7 +680,6 @@ func DeleteComment(comment *Comment) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := sess.Where("comment_id = ?", comment.ID).Cols("is_deleted").Update(&Action{IsDeleted: true}); err != nil {
|
if _, err := sess.Where("comment_id = ?", comment.ID).Cols("is_deleted").Update(&Action{IsDeleted: true}); err != nil {
|
||||||
return err
|
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),
|
NewMigration("unescape user full names", unescapeUserFullNames),
|
||||||
// v38 -> v39
|
// v38 -> v39
|
||||||
NewMigration("remove commits and settings unit types", removeCommitsUnitType),
|
NewMigration("remove commits and settings unit types", removeCommitsUnitType),
|
||||||
|
// v39 -> v40
|
||||||
|
NewMigration("adds time tracking and stopwatches", addTimetracking),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate database to current version
|
// Migrate database to current version
|
||||||
|
|
|
@ -19,7 +19,7 @@ type RepoUnit struct {
|
||||||
RepoID int64 `xorm:"INDEX(s)"`
|
RepoID int64 `xorm:"INDEX(s)"`
|
||||||
Type int `xorm:"INDEX(s)"`
|
Type int `xorm:"INDEX(s)"`
|
||||||
Index int
|
Index int
|
||||||
Config map[string]string `xorm:"JSON"`
|
Config map[string]interface{} `xorm:"JSON"`
|
||||||
CreatedUnix int64 `xorm:"INDEX CREATED"`
|
CreatedUnix int64 `xorm:"INDEX CREATED"`
|
||||||
Created time.Time `xorm:"-"`
|
Created time.Time `xorm:"-"`
|
||||||
}
|
}
|
||||||
|
@ -95,7 +95,7 @@ func addUnitsToTables(x *xorm.Engine) error {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var config = make(map[string]string)
|
var config = make(map[string]interface{})
|
||||||
switch i {
|
switch i {
|
||||||
case V16UnitTypeExternalTracker:
|
case V16UnitTypeExternalTracker:
|
||||||
config["ExternalTrackerURL"] = repo.ExternalTrackerURL
|
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(UserOpenID),
|
||||||
new(IssueWatch),
|
new(IssueWatch),
|
||||||
new(CommitStatus),
|
new(CommitStatus),
|
||||||
|
new(Stopwatch),
|
||||||
|
new(TrackedTime),
|
||||||
)
|
)
|
||||||
|
|
||||||
gonicNames := []string{"SSL", "UID"}
|
gonicNames := []string{"SSL", "UID"}
|
||||||
|
|
|
@ -32,8 +32,8 @@ import (
|
||||||
"github.com/Unknwon/cae/zip"
|
"github.com/Unknwon/cae/zip"
|
||||||
"github.com/Unknwon/com"
|
"github.com/Unknwon/com"
|
||||||
"github.com/go-xorm/xorm"
|
"github.com/go-xorm/xorm"
|
||||||
version "github.com/mcuadros/go-version"
|
"github.com/mcuadros/go-version"
|
||||||
ini "gopkg.in/ini.v1"
|
"gopkg.in/ini.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -1224,6 +1224,14 @@ func createRepository(e *xorm.Session, doer, u *User, repo *Repository) (err err
|
||||||
// insert units for repo
|
// insert units for repo
|
||||||
var units = make([]RepoUnit, 0, len(defaultRepoUnits))
|
var units = make([]RepoUnit, 0, len(defaultRepoUnits))
|
||||||
for i, tp := range defaultRepoUnits {
|
for i, tp := range defaultRepoUnits {
|
||||||
|
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{
|
units = append(units, RepoUnit{
|
||||||
RepoID: repo.ID,
|
RepoID: repo.ID,
|
||||||
Type: tp,
|
Type: tp,
|
||||||
|
@ -1231,6 +1239,8 @@ func createRepository(e *xorm.Session, doer, u *User, repo *Repository) (err err
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = e.Insert(&units); err != nil {
|
if _, err = e.Insert(&units); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
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)
|
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.
|
// BeforeSet is invoked from XORM before setting the value of a field of this object.
|
||||||
func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
|
func (r *RepoUnit) BeforeSet(colName string, val xorm.Cell) {
|
||||||
switch colName {
|
switch colName {
|
||||||
case "type":
|
case "type":
|
||||||
switch UnitType(Cell2Int64(val)) {
|
switch UnitType(Cell2Int64(val)) {
|
||||||
case UnitTypeCode, UnitTypeIssues, UnitTypePullRequests, UnitTypeReleases,
|
case UnitTypeCode, UnitTypePullRequests, UnitTypeReleases,
|
||||||
UnitTypeWiki:
|
UnitTypeWiki:
|
||||||
r.Config = new(UnitConfig)
|
r.Config = new(UnitConfig)
|
||||||
case UnitTypeExternalWiki:
|
case UnitTypeExternalWiki:
|
||||||
r.Config = new(ExternalWikiConfig)
|
r.Config = new(ExternalWikiConfig)
|
||||||
case UnitTypeExternalTracker:
|
case UnitTypeExternalTracker:
|
||||||
r.Config = new(ExternalTrackerConfig)
|
r.Config = new(ExternalTrackerConfig)
|
||||||
|
case UnitTypeIssues:
|
||||||
|
r.Config = new(IssuesConfig)
|
||||||
default:
|
default:
|
||||||
panic("unrecognized repo unit type: " + com.ToStr(*val))
|
panic("unrecognized repo unit type: " + com.ToStr(*val))
|
||||||
}
|
}
|
||||||
|
@ -106,11 +124,6 @@ func (r *RepoUnit) CodeConfig() *UnitConfig {
|
||||||
return r.Config.(*UnitConfig)
|
return r.Config.(*UnitConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssuesConfig returns config for UnitTypeIssues
|
|
||||||
func (r *RepoUnit) IssuesConfig() *UnitConfig {
|
|
||||||
return r.Config.(*UnitConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PullRequestsConfig returns config for UnitTypePullRequests
|
// PullRequestsConfig returns config for UnitTypePullRequests
|
||||||
func (r *RepoUnit) PullRequestsConfig() *UnitConfig {
|
func (r *RepoUnit) PullRequestsConfig() *UnitConfig {
|
||||||
return r.Config.(*UnitConfig)
|
return r.Config.(*UnitConfig)
|
||||||
|
@ -126,6 +139,11 @@ func (r *RepoUnit) ExternalWikiConfig() *ExternalWikiConfig {
|
||||||
return r.Config.(*ExternalWikiConfig)
|
return r.Config.(*ExternalWikiConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IssuesConfig returns config for UnitTypeIssues
|
||||||
|
func (r *RepoUnit) IssuesConfig() *IssuesConfig {
|
||||||
|
return r.Config.(*IssuesConfig)
|
||||||
|
}
|
||||||
|
|
||||||
// ExternalTrackerConfig returns config for UnitTypeExternalTracker
|
// ExternalTrackerConfig returns config for UnitTypeExternalTracker
|
||||||
func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig {
|
func (r *RepoUnit) ExternalTrackerConfig() *ExternalTrackerConfig {
|
||||||
return r.Config.(*ExternalTrackerConfig)
|
return r.Config.(*ExternalTrackerConfig)
|
||||||
|
|
|
@ -12,7 +12,7 @@ import (
|
||||||
"code.gitea.io/gitea/models"
|
"code.gitea.io/gitea/models"
|
||||||
"github.com/Unknwon/com"
|
"github.com/Unknwon/com"
|
||||||
"github.com/go-macaron/binding"
|
"github.com/go-macaron/binding"
|
||||||
macaron "gopkg.in/macaron.v1"
|
"gopkg.in/macaron.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// _______________________________________ _________.______________________ _______________.___.
|
// _______________________________________ _________.______________________ _______________.___.
|
||||||
|
@ -104,6 +104,8 @@ type RepoSettingForm struct {
|
||||||
TrackerURLFormat string
|
TrackerURLFormat string
|
||||||
TrackerIssueStyle string
|
TrackerIssueStyle string
|
||||||
EnablePulls bool
|
EnablePulls bool
|
||||||
|
EnableTimetracker bool
|
||||||
|
AllowOnlyContributorsToTrackTime bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates the fields
|
// Validate validates the fields
|
||||||
|
@ -423,3 +425,21 @@ type DeleteRepoFileForm struct {
|
||||||
func (f *DeleteRepoFileForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
|
func (f *DeleteRepoFileForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
|
||||||
return validate(errs, ctx.Data, f, ctx.Locale)
|
return validate(errs, ctx.Data, f, ctx.Locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ___________.__ ___________ __
|
||||||
|
// \__ ___/|__| _____ ____ \__ ___/___________ ____ | | __ ___________
|
||||||
|
// | | | |/ \_/ __ \ | | \_ __ \__ \ _/ ___\| |/ // __ \_ __ \
|
||||||
|
// | | | | Y Y \ ___/ | | | | \// __ \\ \___| <\ ___/| | \/
|
||||||
|
// |____| |__|__|_| /\___ > |____| |__| (____ /\___ >__|_ \\___ >__|
|
||||||
|
// \/ \/ \/ \/ \/ \/
|
||||||
|
|
||||||
|
// AddTimeManuallyForm form that adds spent time manually.
|
||||||
|
type AddTimeManuallyForm struct {
|
||||||
|
Hours int `binding:"Range(0,1000)"`
|
||||||
|
Minutes int `binding:"Range(0,1000)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the fields
|
||||||
|
func (f *AddTimeManuallyForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
|
||||||
|
return validate(errs, ctx.Data, f, ctx.Locale)
|
||||||
|
}
|
||||||
|
|
|
@ -48,6 +48,7 @@ type InstallForm struct {
|
||||||
RequireSignInView bool
|
RequireSignInView bool
|
||||||
DefaultKeepEmailPrivate bool
|
DefaultKeepEmailPrivate bool
|
||||||
DefaultAllowCreateOrganization bool
|
DefaultAllowCreateOrganization bool
|
||||||
|
DefaultEnableTimetracking bool
|
||||||
NoReplyAddress string
|
NoReplyAddress string
|
||||||
|
|
||||||
AdminName string `binding:"OmitEmpty;AlphaDashDot;MaxSize(30)" locale:"install.admin_name"`
|
AdminName string `binding:"OmitEmpty;AlphaDashDot;MaxSize(30)" locale:"install.admin_name"`
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// Copyright 2014 The Gogs Authors. All rights reserved.
|
// Copyright 2014 The Gogs Authors. All rights reserved.
|
||||||
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
// Use of this source code is governed by a MIT-style
|
// Use of this source code is governed by a MIT-style
|
||||||
// license that can be found in the LICENSE file.
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
@ -14,8 +15,8 @@ import (
|
||||||
"code.gitea.io/gitea/models"
|
"code.gitea.io/gitea/models"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"github.com/Unknwon/com"
|
"github.com/Unknwon/com"
|
||||||
editorconfig "gopkg.in/editorconfig/editorconfig-core-go.v1"
|
"gopkg.in/editorconfig/editorconfig-core-go.v1"
|
||||||
macaron "gopkg.in/macaron.v1"
|
"gopkg.in/macaron.v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PullRequest contains informations to make a pull request
|
// PullRequest contains informations to make a pull request
|
||||||
|
@ -85,6 +86,15 @@ func (r *Repository) CanCommitToBranch() (bool, error) {
|
||||||
return r.CanEnableEditor() && !protectedBranch, nil
|
return r.CanEnableEditor() && !protectedBranch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanUseTimetracker returns whether or not a user can use the timetracker.
|
||||||
|
func (r *Repository) CanUseTimetracker(issue *models.Issue, user *models.User) bool {
|
||||||
|
// Checking for following:
|
||||||
|
// 1. Is timetracker enabled
|
||||||
|
// 2. Is the user a contributor, admin, poster or assignee and do the repository policies require this?
|
||||||
|
return r.Repository.IsTimetrackerEnabled() && (!r.Repository.AllowOnlyContributorsToTrackTime() ||
|
||||||
|
r.IsWriter() || issue.IsPoster(user.ID) || issue.AssigneeID == user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// GetEditorconfig returns the .editorconfig definition if found in the
|
// GetEditorconfig returns the .editorconfig definition if found in the
|
||||||
// HEAD of the default repo branch.
|
// HEAD of the default repo branch.
|
||||||
func (r *Repository) GetEditorconfig() (*editorconfig.Editorconfig, error) {
|
func (r *Repository) GetEditorconfig() (*editorconfig.Editorconfig, error) {
|
||||||
|
|
|
@ -34,7 +34,7 @@ import (
|
||||||
"github.com/go-macaron/session"
|
"github.com/go-macaron/session"
|
||||||
_ "github.com/go-macaron/session/redis" // redis plugin for store session
|
_ "github.com/go-macaron/session/redis" // redis plugin for store session
|
||||||
"github.com/go-xorm/core"
|
"github.com/go-xorm/core"
|
||||||
ini "gopkg.in/ini.v1"
|
"gopkg.in/ini.v1"
|
||||||
"strk.kbt.io/projects/go/libravatar"
|
"strk.kbt.io/projects/go/libravatar"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1028,6 +1028,8 @@ var Service struct {
|
||||||
EnableCaptcha bool
|
EnableCaptcha bool
|
||||||
DefaultKeepEmailPrivate bool
|
DefaultKeepEmailPrivate bool
|
||||||
DefaultAllowCreateOrganization bool
|
DefaultAllowCreateOrganization bool
|
||||||
|
DefaultEnableTimetracking bool
|
||||||
|
DefaultAllowOnlyContributorsToTrackTime bool
|
||||||
NoReplyAddress string
|
NoReplyAddress string
|
||||||
|
|
||||||
// OpenID settings
|
// OpenID settings
|
||||||
|
@ -1049,6 +1051,8 @@ func newService() {
|
||||||
Service.EnableCaptcha = sec.Key("ENABLE_CAPTCHA").MustBool()
|
Service.EnableCaptcha = sec.Key("ENABLE_CAPTCHA").MustBool()
|
||||||
Service.DefaultKeepEmailPrivate = sec.Key("DEFAULT_KEEP_EMAIL_PRIVATE").MustBool()
|
Service.DefaultKeepEmailPrivate = sec.Key("DEFAULT_KEEP_EMAIL_PRIVATE").MustBool()
|
||||||
Service.DefaultAllowCreateOrganization = sec.Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").MustBool(true)
|
Service.DefaultAllowCreateOrganization = sec.Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").MustBool(true)
|
||||||
|
Service.DefaultEnableTimetracking = sec.Key("DEFAULT_ENABLE_TIMETRACKING").MustBool(true)
|
||||||
|
Service.DefaultAllowOnlyContributorsToTrackTime = sec.Key("DEFAULT_ALLOW_ONLY_CONTRIBUTORS_TO_TRACK_TIME").MustBool(true)
|
||||||
Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply.example.org")
|
Service.NoReplyAddress = sec.Key("NO_REPLY_ADDRESS").MustString("noreply.example.org")
|
||||||
|
|
||||||
sec = Cfg.Section("openid")
|
sec = Cfg.Section("openid")
|
||||||
|
|
|
@ -144,6 +144,8 @@ default_keep_email_private = Default Value for Keep Email Private
|
||||||
default_keep_email_private_popup = This is the default value for the visibility of the user's email address. If set to true the email address of all new users will be hidden until the user changes his setting.
|
default_keep_email_private_popup = This is the default value for the visibility of the user's email address. If set to true the email address of all new users will be hidden until the user changes his setting.
|
||||||
default_allow_create_organization = Default permission value for new users to create organizations
|
default_allow_create_organization = Default permission value for new users to create organizations
|
||||||
default_allow_create_organization_popup = This is default permission value that will be assigned for new users. If set to true new users will be allowed to create Organizations.
|
default_allow_create_organization_popup = This is default permission value that will be assigned for new users. If set to true new users will be allowed to create Organizations.
|
||||||
|
default_enable_timetracking = Enable time tracking by default
|
||||||
|
default_enable_timetracking_popup = Repositories will have time tracking enabled by default depending on this setting
|
||||||
no_reply_address = No-reply Address
|
no_reply_address = No-reply Address
|
||||||
no_reply_address_helper = Domain for the user's email address in git logs if he keeps his email address private. E.g. user 'joe' and 'noreply.example.org' will be 'joe@noreply.example.org'
|
no_reply_address_helper = Domain for the user's email address in git logs if he keeps his email address private. E.g. user 'joe' and 'noreply.example.org' will be 'joe@noreply.example.org'
|
||||||
|
|
||||||
|
@ -704,6 +706,23 @@ issues.attachment.open_tab = `Click to see "%s" in a new tab`
|
||||||
issues.attachment.download = `Click to download "%s"`
|
issues.attachment.download = `Click to download "%s"`
|
||||||
issues.subscribe = Subscribe
|
issues.subscribe = Subscribe
|
||||||
issues.unsubscribe = Unsubscribe
|
issues.unsubscribe = Unsubscribe
|
||||||
|
issues.tracker = Time tracker
|
||||||
|
issues.start_tracking_short = Start
|
||||||
|
issues.start_tracking = Start time tracking
|
||||||
|
issues.start_tracking_history = `started working %s`
|
||||||
|
issues.tracking_already_started = `You have already started time tracking on this <a href="%s">issue</a>!`
|
||||||
|
issues.stop_tracking = Stop
|
||||||
|
issues.stop_tracking_history = `stopped working %s`
|
||||||
|
issues.add_time = Add time manually
|
||||||
|
issues.add_time_short = Add
|
||||||
|
issues.add_time_cancel = Cancel
|
||||||
|
issues.add_time_history = `added spent time %s`
|
||||||
|
issues.add_time_hours = Hours
|
||||||
|
issues.add_time_minutes = Minutes
|
||||||
|
issues.add_time_sum_to_small = No time was entered
|
||||||
|
issues.cancel_tracking = Cancel
|
||||||
|
issues.cancel_tracking_history = `cancelled time tracking %s`
|
||||||
|
issues.time_spent_total = Total time spent
|
||||||
|
|
||||||
pulls.desc = Pulls management your code review and merge requests
|
pulls.desc = Pulls management your code review and merge requests
|
||||||
pulls.new = New Pull Request
|
pulls.new = New Pull Request
|
||||||
|
@ -818,6 +837,8 @@ settings.tracker_issue_style = External Issue Tracker Naming Style:
|
||||||
settings.tracker_issue_style.numeric = Numeric
|
settings.tracker_issue_style.numeric = Numeric
|
||||||
settings.tracker_issue_style.alphanumeric = Alphanumeric
|
settings.tracker_issue_style.alphanumeric = Alphanumeric
|
||||||
settings.tracker_url_format_desc = You can use placeholder <code>{user} {repo} {index}</code> for user name, repository name and issue index.
|
settings.tracker_url_format_desc = You can use placeholder <code>{user} {repo} {index}</code> for user name, repository name and issue index.
|
||||||
|
settings.enable_timetracker = Enable time tracker
|
||||||
|
settings.allow_only_contributors_to_track_time = Allow only contributors to track time
|
||||||
settings.pulls_desc = Enable pull requests to accept public contributions
|
settings.pulls_desc = Enable pull requests to accept public contributions
|
||||||
settings.danger_zone = Danger Zone
|
settings.danger_zone = Danger Zone
|
||||||
settings.new_owner_has_same_repo = The new owner already has a repository with same name. Please choose another name.
|
settings.new_owner_has_same_repo = The new owner already has a repository with same name. Please choose another name.
|
||||||
|
@ -1308,6 +1329,8 @@ config.active_code_lives = Active Code Lives
|
||||||
config.reset_password_code_lives = Reset Password Code Expiry Time
|
config.reset_password_code_lives = Reset Password Code Expiry Time
|
||||||
config.default_keep_email_private = Default Value for Keep Email Private
|
config.default_keep_email_private = Default Value for Keep Email Private
|
||||||
config.default_allow_create_organization = Default permission to create organizations
|
config.default_allow_create_organization = Default permission to create organizations
|
||||||
|
config.default_enable_timetracking = Enable time tracking by default
|
||||||
|
config.default_allow_only_contributors_to_track_time = Allow only contributors to track time by default
|
||||||
config.no_reply_address = No-reply Address
|
config.no_reply_address = No-reply Address
|
||||||
|
|
||||||
config.webhook_config = Webhook Configuration
|
config.webhook_config = Webhook Configuration
|
||||||
|
|
|
@ -404,15 +404,19 @@ function initRepository() {
|
||||||
$('.enable-system').change(function () {
|
$('.enable-system').change(function () {
|
||||||
if (this.checked) {
|
if (this.checked) {
|
||||||
$($(this).data('target')).removeClass('disabled');
|
$($(this).data('target')).removeClass('disabled');
|
||||||
|
if (!$(this).data('context')) $($(this).data('context')).addClass('disabled');
|
||||||
} else {
|
} else {
|
||||||
$($(this).data('target')).addClass('disabled');
|
$($(this).data('target')).addClass('disabled');
|
||||||
|
if (!$(this).data('context')) $($(this).data('context')).removeClass('disabled');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$('.enable-system-radio').change(function () {
|
$('.enable-system-radio').change(function () {
|
||||||
if (this.value == 'false') {
|
if (this.value == 'false') {
|
||||||
$($(this).data('target')).addClass('disabled');
|
$($(this).data('target')).addClass('disabled');
|
||||||
|
if (typeof $(this).data('context') !== 'undefined') $($(this).data('context')).removeClass('disabled');
|
||||||
} else if (this.value == 'true') {
|
} else if (this.value == 'true') {
|
||||||
$($(this).data('target')).removeClass('disabled');
|
$($(this).data('target')).removeClass('disabled');
|
||||||
|
if (typeof $(this).data('context') !== 'undefined') $($(this).data('context')).addClass('disabled');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1826,3 +1830,20 @@ function initVueApp() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function timeAddManual() {
|
||||||
|
$('.mini.modal')
|
||||||
|
.modal({
|
||||||
|
duration: 200,
|
||||||
|
onApprove: function() {
|
||||||
|
$('#add_time_manual_form').submit();
|
||||||
|
}
|
||||||
|
}).modal('show')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleStopwatch() {
|
||||||
|
$("#toggle_stopwatch_form").submit();
|
||||||
|
}
|
||||||
|
function cancelStopwatch() {
|
||||||
|
$("#cancel_stopwatch_form").submit();
|
||||||
|
}
|
||||||
|
|
125
public/swagger.v1.json
vendored
125
public/swagger.v1.json
vendored
|
@ -1372,6 +1372,65 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{username}/{reponame}/issues/{issue}/times": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"operationId": "issueTrackedTimes",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/TrackedTimes"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"operationId": "addTime",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"x-go-name": "Time",
|
||||||
|
"name": "time",
|
||||||
|
"in": "body",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/TrackedTime"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repos/{username}/{reponame}/mirror-sync": {
|
"/repos/{username}/{reponame}/mirror-sync": {
|
||||||
"post": {
|
"post": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
@ -1435,6 +1494,53 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/repos/{username}/{reponame}/times": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"repository"
|
||||||
|
],
|
||||||
|
"operationId": "repoTrackedTimes",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/TrackedTimes"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/repos/{username}/{reponame}/times/{timetrackingusername}": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"operationId": "userTrackedTimes",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/TrackedTimes"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/repositories/{id}": {
|
"/repositories/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
@ -1951,6 +2057,25 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/user/times": {
|
||||||
|
"get": {
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"operationId": "userTrackedTimes",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/responses/TrackedTimes"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/responses/error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/users/:username/followers": {
|
"/users/:username/followers": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
|
|
@ -350,6 +350,7 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
m.Delete("", user.Unstar)
|
m.Delete("", user.Unstar)
|
||||||
}, repoAssignment())
|
}, repoAssignment())
|
||||||
})
|
})
|
||||||
|
m.Get("/times", repo.ListMyTrackedTimes)
|
||||||
|
|
||||||
m.Get("/subscriptions", user.GetMyWatchedRepos)
|
m.Get("/subscriptions", user.GetMyWatchedRepos)
|
||||||
}, reqToken())
|
}, reqToken())
|
||||||
|
@ -395,6 +396,11 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
m.Combo("/:id").Get(repo.GetDeployKey).
|
m.Combo("/:id").Get(repo.GetDeployKey).
|
||||||
Delete(repo.DeleteDeploykey)
|
Delete(repo.DeleteDeploykey)
|
||||||
}, reqToken(), reqRepoWriter())
|
}, reqToken(), reqRepoWriter())
|
||||||
|
m.Group("/times", func() {
|
||||||
|
m.Combo("").Get(repo.ListTrackedTimesByRepository)
|
||||||
|
m.Combo("/:timetrackingusername").Get(repo.ListTrackedTimesByUser)
|
||||||
|
|
||||||
|
}, mustEnableIssues)
|
||||||
m.Group("/issues", func() {
|
m.Group("/issues", func() {
|
||||||
m.Combo("").Get(repo.ListIssues).
|
m.Combo("").Get(repo.ListIssues).
|
||||||
Post(reqToken(), bind(api.CreateIssueOption{}), repo.CreateIssue)
|
Post(reqToken(), bind(api.CreateIssueOption{}), repo.CreateIssue)
|
||||||
|
@ -422,6 +428,11 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
m.Delete("/:id", reqToken(), repo.DeleteIssueLabel)
|
m.Delete("/:id", reqToken(), repo.DeleteIssueLabel)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
m.Group("/times", func() {
|
||||||
|
m.Combo("").Get(repo.ListTrackedTimes).
|
||||||
|
Post(reqToken(), bind(api.AddTimeOption{}), repo.AddTime)
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
}, mustEnableIssues)
|
}, mustEnableIssues)
|
||||||
m.Group("/labels", func() {
|
m.Group("/labels", func() {
|
||||||
|
|
158
routers/api/v1/repo/issue_tracked_time.go
Normal file
158
routers/api/v1/repo/issue_tracked_time.go
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
// 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 repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
api "code.gitea.io/sdk/gitea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListTrackedTimes list all the tracked times of an issue
|
||||||
|
func ListTrackedTimes(ctx *context.APIContext) {
|
||||||
|
// swagger:route GET /repos/{username}/{reponame}/issues/{issue}/times repository issueTrackedTimes
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTimes
|
||||||
|
// 404: error
|
||||||
|
// 500: error
|
||||||
|
if !ctx.Repo.Repository.IsTimetrackerEnabled() {
|
||||||
|
ctx.Error(404, "IsTimetrackerEnabled", "Timetracker is diabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
|
||||||
|
if err != nil {
|
||||||
|
if models.IsErrIssueNotExist(err) {
|
||||||
|
ctx.Error(404, "GetIssueByIndex", err)
|
||||||
|
} else {
|
||||||
|
ctx.Error(500, "GetIssueByIndex", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil {
|
||||||
|
ctx.Error(500, "GetTrackedTimesByIssue", err)
|
||||||
|
} else {
|
||||||
|
ctx.JSON(200, &trackedTimes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTime adds time manual to the given issue
|
||||||
|
func AddTime(ctx *context.APIContext, form api.AddTimeOption) {
|
||||||
|
// swagger:route Post /repos/{username}/{reponame}/issues/{issue}/times repository addTime
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTime
|
||||||
|
// 400: error
|
||||||
|
// 403: error
|
||||||
|
// 404: error
|
||||||
|
// 500: error
|
||||||
|
issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
|
||||||
|
if err != nil {
|
||||||
|
if models.IsErrIssueNotExist(err) {
|
||||||
|
ctx.Error(404, "GetIssueByIndex", err)
|
||||||
|
} else {
|
||||||
|
ctx.Error(500, "GetIssueByIndex", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ctx.Repo.CanUseTimetracker(issue, ctx.User) {
|
||||||
|
if !ctx.Repo.Repository.IsTimetrackerEnabled() {
|
||||||
|
ctx.JSON(400, struct{ Message string }{Message: "time tracking disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Status(403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tt *models.TrackedTime
|
||||||
|
if tt, err = models.AddTime(ctx.User, issue, form.Time); err != nil {
|
||||||
|
ctx.Error(500, "AddTime", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(200, tt)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTrackedTimesByUser lists all tracked times of the user
|
||||||
|
func ListTrackedTimesByUser(ctx *context.APIContext) {
|
||||||
|
// swagger:route GET /repos/{username}/{reponame}/times/{timetrackingusername} user userTrackedTimes
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTimes
|
||||||
|
// 400: error
|
||||||
|
// 404: error
|
||||||
|
// 500: error
|
||||||
|
if !ctx.Repo.Repository.IsTimetrackerEnabled() {
|
||||||
|
ctx.JSON(400, struct{ Message string }{Message: "time tracking disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := models.GetUserByName(ctx.Params(":timetrackingusername"))
|
||||||
|
if err != nil {
|
||||||
|
if models.IsErrUserNotExist(err) {
|
||||||
|
ctx.Error(404, "GetUserByName", err)
|
||||||
|
} else {
|
||||||
|
ctx.Error(500, "GetUserByName", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
ctx.Status(404)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{UserID: user.ID, RepositoryID: ctx.Repo.Repository.ID}); err != nil {
|
||||||
|
ctx.Error(500, "GetTrackedTimesByUser", err)
|
||||||
|
} else {
|
||||||
|
ctx.JSON(200, &trackedTimes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTrackedTimesByRepository lists all tracked times of the user
|
||||||
|
func ListTrackedTimesByRepository(ctx *context.APIContext) {
|
||||||
|
// swagger:route GET /repos/{username}/{reponame}/times repository repoTrackedTimes
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTimes
|
||||||
|
// 400: error
|
||||||
|
// 500: error
|
||||||
|
if !ctx.Repo.Repository.IsTimetrackerEnabled() {
|
||||||
|
ctx.JSON(400, struct{ Message string }{Message: "time tracking disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{RepositoryID: ctx.Repo.Repository.ID}); err != nil {
|
||||||
|
ctx.Error(500, "GetTrackedTimesByUser", err)
|
||||||
|
} else {
|
||||||
|
ctx.JSON(200, &trackedTimes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMyTrackedTimes lists all tracked times of the current user
|
||||||
|
func ListMyTrackedTimes(ctx *context.APIContext) {
|
||||||
|
// swagger:route GET /user/times user userTrackedTimes
|
||||||
|
//
|
||||||
|
// Produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// Responses:
|
||||||
|
// 200: TrackedTimes
|
||||||
|
// 500: error
|
||||||
|
if trackedTimes, err := models.GetTrackedTimes(models.FindTrackedTimesOptions{UserID: ctx.User.ID}); err != nil {
|
||||||
|
ctx.Error(500, "GetTrackedTimesByUser", err)
|
||||||
|
} else {
|
||||||
|
ctx.JSON(200, &trackedTimes)
|
||||||
|
}
|
||||||
|
}
|
|
@ -115,6 +115,7 @@ func Install(ctx *context.Context) {
|
||||||
form.RequireSignInView = setting.Service.RequireSignInView
|
form.RequireSignInView = setting.Service.RequireSignInView
|
||||||
form.DefaultKeepEmailPrivate = setting.Service.DefaultKeepEmailPrivate
|
form.DefaultKeepEmailPrivate = setting.Service.DefaultKeepEmailPrivate
|
||||||
form.DefaultAllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization
|
form.DefaultAllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization
|
||||||
|
form.DefaultEnableTimetracking = setting.Service.DefaultEnableTimetracking
|
||||||
form.NoReplyAddress = setting.Service.NoReplyAddress
|
form.NoReplyAddress = setting.Service.NoReplyAddress
|
||||||
|
|
||||||
auth.AssignForm(form, ctx.Data)
|
auth.AssignForm(form, ctx.Data)
|
||||||
|
@ -301,6 +302,7 @@ func InstallPost(ctx *context.Context, form auth.InstallForm) {
|
||||||
cfg.Section("service").Key("REQUIRE_SIGNIN_VIEW").SetValue(com.ToStr(form.RequireSignInView))
|
cfg.Section("service").Key("REQUIRE_SIGNIN_VIEW").SetValue(com.ToStr(form.RequireSignInView))
|
||||||
cfg.Section("service").Key("DEFAULT_KEEP_EMAIL_PRIVATE").SetValue(com.ToStr(form.DefaultKeepEmailPrivate))
|
cfg.Section("service").Key("DEFAULT_KEEP_EMAIL_PRIVATE").SetValue(com.ToStr(form.DefaultKeepEmailPrivate))
|
||||||
cfg.Section("service").Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").SetValue(com.ToStr(form.DefaultAllowCreateOrganization))
|
cfg.Section("service").Key("DEFAULT_ALLOW_CREATE_ORGANIZATION").SetValue(com.ToStr(form.DefaultAllowCreateOrganization))
|
||||||
|
cfg.Section("service").Key("DEFAULT_ENABLE_TIMETRACKING").SetValue(com.ToStr(form.DefaultEnableTimetracking))
|
||||||
cfg.Section("service").Key("NO_REPLY_ADDRESS").SetValue(com.ToStr(form.NoReplyAddress))
|
cfg.Section("service").Key("NO_REPLY_ADDRESS").SetValue(com.ToStr(form.NoReplyAddress))
|
||||||
|
|
||||||
cfg.Section("").Key("RUN_MODE").SetValue("prod")
|
cfg.Section("").Key("RUN_MODE").SetValue("prod")
|
||||||
|
|
|
@ -589,6 +589,38 @@ func ViewIssue(ctx *context.Context) {
|
||||||
comment *models.Comment
|
comment *models.Comment
|
||||||
participants = make([]*models.User, 1, 10)
|
participants = make([]*models.User, 1, 10)
|
||||||
)
|
)
|
||||||
|
if ctx.Repo.Repository.IsTimetrackerEnabled() {
|
||||||
|
if ctx.IsSigned {
|
||||||
|
// Deal with the stopwatch
|
||||||
|
ctx.Data["IsStopwatchRunning"] = models.StopwatchExists(ctx.User.ID, issue.ID)
|
||||||
|
if !ctx.Data["IsStopwatchRunning"].(bool) {
|
||||||
|
var exists bool
|
||||||
|
var sw *models.Stopwatch
|
||||||
|
if exists, sw, err = models.HasUserStopwatch(ctx.User.ID); err != nil {
|
||||||
|
ctx.Handle(500, "HasUserStopwatch", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Data["HasUserStopwatch"] = exists
|
||||||
|
if exists {
|
||||||
|
// Add warning if the user has already a stopwatch
|
||||||
|
var otherIssue *models.Issue
|
||||||
|
if otherIssue, err = models.GetIssueByID(sw.IssueID); err != nil {
|
||||||
|
ctx.Handle(500, "GetIssueByID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Add link to the issue of the already running stopwatch
|
||||||
|
ctx.Data["OtherStopwatchURL"] = otherIssue.HTMLURL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Data["CanUseTimetracker"] = ctx.Repo.CanUseTimetracker(issue, ctx.User)
|
||||||
|
} else {
|
||||||
|
ctx.Data["CanUseTimetracker"] = false
|
||||||
|
}
|
||||||
|
if ctx.Data["WorkingUsers"], err = models.TotalTimes(models.FindTrackedTimesOptions{IssueID: issue.ID}); err != nil {
|
||||||
|
ctx.Handle(500, "TotalTimes", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render comments and and fetch participants.
|
// Render comments and and fetch participants.
|
||||||
participants[0] = issue.Poster
|
participants[0] = issue.Poster
|
||||||
|
@ -683,7 +715,8 @@ func ViewIssue(ctx *context.Context) {
|
||||||
ctx.HTML(200, tplIssueView)
|
ctx.HTML(200, tplIssueView)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getActionIssue(ctx *context.Context) *models.Issue {
|
// GetActionIssue will return the issue which is used in the context.
|
||||||
|
func GetActionIssue(ctx *context.Context) *models.Issue {
|
||||||
issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
|
issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if models.IsErrIssueNotExist(err) {
|
if models.IsErrIssueNotExist(err) {
|
||||||
|
@ -720,7 +753,7 @@ func getActionIssues(ctx *context.Context) []*models.Issue {
|
||||||
|
|
||||||
// UpdateIssueTitle change issue's title
|
// UpdateIssueTitle change issue's title
|
||||||
func UpdateIssueTitle(ctx *context.Context) {
|
func UpdateIssueTitle(ctx *context.Context) {
|
||||||
issue := getActionIssue(ctx)
|
issue := GetActionIssue(ctx)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -748,7 +781,7 @@ func UpdateIssueTitle(ctx *context.Context) {
|
||||||
|
|
||||||
// UpdateIssueContent change issue's content
|
// UpdateIssueContent change issue's content
|
||||||
func UpdateIssueContent(ctx *context.Context) {
|
func UpdateIssueContent(ctx *context.Context) {
|
||||||
issue := getActionIssue(ctx)
|
issue := GetActionIssue(ctx)
|
||||||
if ctx.Written() {
|
if ctx.Written() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
50
routers/repo/issue_stopwatch.go
Normal file
50
routers/repo/issue_stopwatch.go
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
// 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 repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IssueStopwatch creates or stops a stopwatch for the given issue.
|
||||||
|
func IssueStopwatch(c *context.Context) {
|
||||||
|
issueIndex := c.ParamsInt64("index")
|
||||||
|
issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.CreateOrStopIssueStopwatch(c.User, issue); err != nil {
|
||||||
|
c.Handle(http.StatusInternalServerError, "CreateOrStopIssueStopwatch", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url := issue.HTMLURL()
|
||||||
|
c.Redirect(url, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelStopwatch cancel the stopwatch
|
||||||
|
func CancelStopwatch(c *context.Context) {
|
||||||
|
issueIndex := c.ParamsInt64("index")
|
||||||
|
issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := models.CancelStopwatch(c.User, issue); err != nil {
|
||||||
|
c.Handle(http.StatusInternalServerError, "CancelStopwatch", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url := issue.HTMLURL()
|
||||||
|
c.Redirect(url, http.StatusSeeOther)
|
||||||
|
}
|
50
routers/repo/issue_timetrack.go
Normal file
50
routers/repo/issue_timetrack.go
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
// 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 repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models"
|
||||||
|
"code.gitea.io/gitea/modules/auth"
|
||||||
|
"code.gitea.io/gitea/modules/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddTimeManually tracks time manually
|
||||||
|
func AddTimeManually(c *context.Context, form auth.AddTimeManuallyForm) {
|
||||||
|
issueIndex := c.ParamsInt64("index")
|
||||||
|
issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex)
|
||||||
|
if err != nil {
|
||||||
|
if models.IsErrIssueNotExist(err) {
|
||||||
|
c.Handle(http.StatusNotFound, "GetIssueByIndex", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url := issue.HTMLURL()
|
||||||
|
|
||||||
|
if c.HasError() {
|
||||||
|
c.Flash.Error(c.GetErrMsg())
|
||||||
|
c.Redirect(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
total := time.Duration(form.Hours)*time.Hour + time.Duration(form.Minutes)*time.Minute
|
||||||
|
|
||||||
|
if total <= 0 {
|
||||||
|
c.Flash.Error(c.Tr("repo.issues.add_time_sum_to_small"))
|
||||||
|
c.Redirect(url, http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := models.AddTime(c.User, issue, int64(total)); err != nil {
|
||||||
|
c.Handle(http.StatusInternalServerError, "AddTime", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(url, http.StatusSeeOther)
|
||||||
|
}
|
|
@ -201,7 +201,10 @@ func SettingsPost(ctx *context.Context, form auth.RepoSettingForm) {
|
||||||
RepoID: repo.ID,
|
RepoID: repo.ID,
|
||||||
Type: models.UnitTypeIssues,
|
Type: models.UnitTypeIssues,
|
||||||
Index: int(models.UnitTypeIssues),
|
Index: int(models.UnitTypeIssues),
|
||||||
Config: new(models.UnitConfig),
|
Config: &models.IssuesConfig{
|
||||||
|
EnableTimetracker: form.EnableTimetracker,
|
||||||
|
AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -484,6 +484,19 @@ func RegisterRoutes(m *macaron.Macaron) {
|
||||||
m.Post("/content", repo.UpdateIssueContent)
|
m.Post("/content", repo.UpdateIssueContent)
|
||||||
m.Post("/watch", repo.IssueWatch)
|
m.Post("/watch", repo.IssueWatch)
|
||||||
m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment)
|
m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment)
|
||||||
|
m.Group("/times", func() {
|
||||||
|
m.Post("/add", bindIgnErr(auth.AddTimeManuallyForm{}), repo.AddTimeManually)
|
||||||
|
m.Group("/stopwatch", func() {
|
||||||
|
m.Post("/toggle", repo.IssueStopwatch)
|
||||||
|
m.Post("/cancel", repo.CancelStopwatch)
|
||||||
|
})
|
||||||
|
|
||||||
|
}, func(ctx *context.Context) {
|
||||||
|
if !ctx.Repo.CanUseTimetracker(repo.GetActionIssue(ctx), ctx.User) {
|
||||||
|
ctx.Handle(404, ctx.Req.RequestURI, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
m.Post("/labels", repo.UpdateIssueLabel, reqRepoWriter)
|
m.Post("/labels", repo.UpdateIssueLabel, reqRepoWriter)
|
||||||
|
|
|
@ -132,6 +132,10 @@
|
||||||
<dd><i class="fa fa{{if .Service.DefaultKeepEmailPrivate}}-check{{end}}-square-o"></i></dd>
|
<dd><i class="fa fa{{if .Service.DefaultKeepEmailPrivate}}-check{{end}}-square-o"></i></dd>
|
||||||
<dt>{{.i18n.Tr "admin.config.default_allow_create_organization"}}</dt>
|
<dt>{{.i18n.Tr "admin.config.default_allow_create_organization"}}</dt>
|
||||||
<dd><i class="fa fa{{if .Service.DefaultAllowCreateOrganization}}-check{{end}}-square-o"></i></dd>
|
<dd><i class="fa fa{{if .Service.DefaultAllowCreateOrganization}}-check{{end}}-square-o"></i></dd>
|
||||||
|
<dt>{{.i18n.Tr "admin.config.default_enable_timetracking"}}</dt>
|
||||||
|
<dd><i class="fa fa{{if .Service.DefaultEnableTimetracking}}-check{{end}}-square-o"></i></dd>
|
||||||
|
<dt>{{.i18n.Tr "admin.config.default_allow_only_contributors_to_track_time"}}</dt>
|
||||||
|
<dd><i class="fa fa{{if .Service.DefaultAllowOnlyContributorsToTrackTime}}-check{{end}}-square-o"></i></dd>
|
||||||
<dt>{{.i18n.Tr "admin.config.no_reply_address"}}</dt>
|
<dt>{{.i18n.Tr "admin.config.no_reply_address"}}</dt>
|
||||||
<dd>{{if .Service.NoReplyAddress}}{{.Service.NoReplyAddress}}{{else}}-{{end}}</dd>
|
<dd>{{if .Service.NoReplyAddress}}{{.Service.NoReplyAddress}}{{else}}-{{end}}</dd>
|
||||||
<div class="ui divider"></div>
|
<div class="ui divider"></div>
|
||||||
|
|
6
templates/install.tmpl
vendored
6
templates/install.tmpl
vendored
|
@ -230,6 +230,12 @@
|
||||||
<input name="default_allow_create_organization" type="checkbox" {{if .default_allow_create_organization}}checked{{end}}>
|
<input name="default_allow_create_organization" type="checkbox" {{if .default_allow_create_organization}}checked{{end}}>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="inline field">
|
||||||
|
<div class="ui checkbox">
|
||||||
|
<label class="poping up" data-content="{{.i18n.Tr "install.default_enable_timetracking_popup"}}"><strong>{{.i18n.Tr "install.default_enable_timetracking"}}</strong></label>
|
||||||
|
<input name="default_enable_timetracking" type="checkbox" {{if .default_enable_timetracking}}checked{{end}}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="inline field">
|
<div class="inline field">
|
||||||
<label for="no_reply_address">{{.i18n.Tr "install.no_reply_address"}}</label>
|
<label for="no_reply_address">{{.i18n.Tr "install.no_reply_address"}}</label>
|
||||||
<input id="_no_reply_address" name="no_reply_address" value="{{.no_reply_address}}">
|
<input id="_no_reply_address" name="no_reply_address" value="{{.no_reply_address}}">
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{{range .Issue.Comments}}
|
{{range .Issue.Comments}}
|
||||||
{{ $createdStr:= TimeSince .Created $.Lang }}
|
{{ $createdStr:= TimeSince .Created $.Lang }}
|
||||||
|
|
||||||
<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL -->
|
<!-- 0 = COMMENT, 1 = REOPEN, 2 = CLOSE, 3 = ISSUE_REF, 4 = COMMIT_REF, 5 = COMMENT_REF, 6 = PULL_REF, 7 = COMMENT_LABEL, 12 = START_TRACKING, 13 = STOP_TRACKING, 14 = ADD_TIME_MANUAL -->
|
||||||
{{if eq .Type 0}}
|
{{if eq .Type 0}}
|
||||||
<div class="comment" id="{{.HashTag}}">
|
<div class="comment" id="{{.HashTag}}">
|
||||||
<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}>
|
<a class="avatar" {{if gt .Poster.ID 0}}href="{{.Poster.HomeLink}}"{{end}}>
|
||||||
|
@ -58,6 +58,7 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{else if eq .Type 1}}
|
{{else if eq .Type 1}}
|
||||||
<div class="event">
|
<div class="event">
|
||||||
<span class="octicon octicon-primitive-dot"></span>
|
<span class="octicon octicon-primitive-dot"></span>
|
||||||
|
@ -140,5 +141,46 @@
|
||||||
<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a>
|
<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a>
|
||||||
{{$.i18n.Tr "repo.issues.delete_branch_at" .CommitSHA $createdStr | Safe}}
|
{{$.i18n.Tr "repo.issues.delete_branch_at" .CommitSHA $createdStr | Safe}}
|
||||||
</span>
|
</span>
|
||||||
|
{{else if eq .Type 12}}
|
||||||
|
<div class="event">
|
||||||
|
<span class="octicon octicon-primitive-dot"></span>
|
||||||
|
<a class="ui avatar image" href="{{.Poster.HomeLink}}">
|
||||||
|
<img src="{{.Poster.RelAvatarLink}}">
|
||||||
|
</a>
|
||||||
|
<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.start_tracking_history" $createdStr | Safe}}</span>
|
||||||
|
</div>
|
||||||
|
{{else if eq .Type 13}}
|
||||||
|
<div class="event">
|
||||||
|
<span class="octicon octicon-primitive-dot"></span>
|
||||||
|
<a class="ui avatar image" href="{{.Poster.HomeLink}}">
|
||||||
|
<img src="{{.Poster.RelAvatarLink}}">
|
||||||
|
</a>
|
||||||
|
<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.stop_tracking_history" $createdStr | Safe}}</span>
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<span class="octicon octicon-clock"></span>
|
||||||
|
<span class="text grey">{{.Content}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else if eq .Type 14}}
|
||||||
|
<div class="event">
|
||||||
|
<span class="octicon octicon-primitive-dot"></span>
|
||||||
|
<a class="ui avatar image" href="{{.Poster.HomeLink}}">
|
||||||
|
<img src="{{.Poster.RelAvatarLink}}">
|
||||||
|
</a>
|
||||||
|
<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.add_time_history" $createdStr | Safe}}</span>
|
||||||
|
<div class="detail">
|
||||||
|
<span class="octicon octicon-clock"></span>
|
||||||
|
<span class="text grey">{{.Content}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else if eq .Type 15}}
|
||||||
|
<div class="event">
|
||||||
|
<span class="octicon octicon-primitive-dot"></span>
|
||||||
|
<a class="ui avatar image" href="{{.Poster.HomeLink}}">
|
||||||
|
<img src="{{.Poster.RelAvatarLink}}">
|
||||||
|
</a>
|
||||||
|
<span class="text grey"><a href="{{.Poster.HomeLink}}">{{.Poster.Name}}</a> {{$.i18n.Tr "repo.issues.cancel_tracking_history" $createdStr | Safe}}</span>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
@ -123,5 +123,72 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if .Repository.IsTimetrackerEnabled }}
|
||||||
|
{{if .CanUseTimetracker }}
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
<div class="ui timetrack">
|
||||||
|
<span class="text"><strong>{{.i18n.Tr "repo.issues.tracker"}}</strong></span>
|
||||||
|
<div>
|
||||||
|
<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/times/stopwatch/toggle" id="toggle_stopwatch_form">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/times/stopwatch/cancel" id="cancel_stopwatch_form">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
</form>
|
||||||
|
{{if $.IsStopwatchRunning}}
|
||||||
|
<div class="ui buttons fluid stop-cancel">
|
||||||
|
<button onclick="this.disabled=true;toggleStopwatch()" class="ui button stop">{{.i18n.Tr "repo.issues.stop_tracking"}}</button>
|
||||||
|
<button onclick="this.disabled=true;cancelStopwatch()" class="ui negative button cancel">{{.i18n.Tr "repo.issues.cancel_tracking"}}</button>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
{{if .HasUserStopwatch}}
|
||||||
|
<div class="ui warning message">
|
||||||
|
{{.i18n.Tr "repo.issues.tracking_already_started" .OtherStopwatchURL | Safe}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="ui buttons two fluid start-add">
|
||||||
|
<button onclick="this.disabled=true;toggleStopwatch()" class="ui button poping up start" data-content='{{.i18n.Tr "repo.issues.start_tracking"}}' data-position="top center" data-variation="small inverted">{{.i18n.Tr "repo.issues.start_tracking_short"}}</button>
|
||||||
|
<button onclick="timeAddManual()" class="ui button green poping up add-time" data-content='{{.i18n.Tr "repo.issues.add_time"}}' data-position="top center" data-variation="small inverted">{{.i18n.Tr "repo.issues.add_time_short"}}</button>
|
||||||
|
<div class="ui mini modal">
|
||||||
|
<div class="header">{{.i18n.Tr "repo.issues.add_time"}}</div>
|
||||||
|
<div class="content">
|
||||||
|
<form method="POST" id="add_time_manual_form" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/times/add" class="ui action input fluid">
|
||||||
|
{{$.CsrfTokenHtml}}
|
||||||
|
<input placeholder='{{.i18n.Tr "repo.issues.add_time_hours"}}' type="number" name="hours">
|
||||||
|
<input placeholder='{{.i18n.Tr "repo.issues.add_time_minutes"}}' type="number" name="minutes" class="ui compact">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="ui green approve button">{{.i18n.Tr "repo.issues.add_time_short"}}</div>
|
||||||
|
<div class="ui red cancel button">{{.i18n.Tr "repo.issues.add_time_cancel"}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if gt (len .WorkingUsers) 0}}
|
||||||
|
<div class="ui divider"></div>
|
||||||
|
<div class="ui participants comments">
|
||||||
|
<span class="text"><strong>{{.i18n.Tr "repo.issues.time_spent_total"}}</strong></span>
|
||||||
|
<div>
|
||||||
|
{{range $user, $trackedtime := .WorkingUsers}}
|
||||||
|
<div class="comment">
|
||||||
|
<a class="avatar">
|
||||||
|
<img src="{{$user.RelAvatarLink}}">
|
||||||
|
</a>
|
||||||
|
<div class="content">
|
||||||
|
<a class="author">{{$user.DisplayName}}</a>
|
||||||
|
<div class="text">
|
||||||
|
{{$trackedtime}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -134,13 +134,28 @@
|
||||||
<div class="field {{if not $isIssuesEnabled}}disabled{{end}}" id="issue_box">
|
<div class="field {{if not $isIssuesEnabled}}disabled{{end}}" id="issue_box">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="ui radio checkbox">
|
<div class="ui radio checkbox">
|
||||||
<input class="hidden enable-system-radio" tabindex="0" name="enable_external_tracker" type="radio" value="false" data-target="#external_issue_box" {{if not (.Repository.UnitEnabled $.UnitTypeExternalTracker)}}checked{{end}}/>
|
<input class="hidden enable-system-radio" tabindex="0" name="enable_external_tracker" type="radio" value="false" data-context="#internal_issue_box" data-target="#external_issue_box" {{if not (.Repository.UnitEnabled $.UnitTypeExternalTracker)}}checked{{end}}/>
|
||||||
<label>{{.i18n.Tr "repo.settings.use_internal_issue_tracker"}}</label>
|
<label>{{.i18n.Tr "repo.settings.use_internal_issue_tracker"}}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field {{if (.Repository.UnitEnabled $.UnitTypeExternalTracker)}}disabled{{end}}" id="internal_issue_box">
|
||||||
|
<div class="field">
|
||||||
|
<div class="ui checkbox">
|
||||||
|
<input name="enable_timetracker" class="enable-system" data-target="#only_contributors" type="checkbox" {{if .Repository.IsTimetrackerEnabled}}checked{{end}}>
|
||||||
|
<label>{{.i18n.Tr "repo.settings.enable_timetracker"}}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field {{if not .Repository.IsTimetrackerEnabled}}disabled{{end}}" id="only_contributors">
|
||||||
|
<div class="ui checkbox">
|
||||||
|
|
||||||
|
<input name="allow_only_contributors_to_track_time" type="checkbox" {{if .Repository.AllowOnlyContributorsToTrackTime}}checked{{end}}>
|
||||||
|
<label>{{.i18n.Tr "repo.settings.allow_only_contributors_to_track_time"}}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="ui radio checkbox">
|
<div class="ui radio checkbox">
|
||||||
<input class="hidden enable-system-radio" tabindex="0" name="enable_external_tracker" type="radio" value="true" data-target="#external_issue_box" {{if .Repository.UnitEnabled $.UnitTypeExternalTracker}}checked{{end}}/>
|
<input class="hidden enable-system-radio" tabindex="0" name="enable_external_tracker" type="radio" value="true" data-context="#internal_issue_box" data-target="#external_issue_box" {{if .Repository.UnitEnabled $.UnitTypeExternalTracker}}checked{{end}}/>
|
||||||
<label>{{.i18n.Tr "repo.settings.use_external_issue_tracker"}}</label>
|
<label>{{.i18n.Tr "repo.settings.use_external_issue_tracker"}}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in a new issue