mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-29 18:38:28 +00:00 
			
		
		
		
	fix(issue): Replace stopwatch toggle with explicit start/stop actions (#34818)
This PR fixes a state de-synchronization bug with the issue stopwatch, it resolves the issue by replacing the ambiguous `/toggle` endpoint with two explicit endpoints: `/start` and `/stop`. - The "Start timer" button now exclusively calls the `/start` endpoint. - The "Stop timer" button now exclusively calls the `/stop` endpoint. This ensures the user's intent is clearly communicated to the server, eliminating the state inconsistency and fixing the bug. --------- Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -5,7 +5,6 @@ package issues | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"fmt" | ||||
| 	"time" | ||||
|  | ||||
| 	"code.gitea.io/gitea/models/db" | ||||
| @@ -15,20 +14,6 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/util" | ||||
| ) | ||||
|  | ||||
| // ErrIssueStopwatchNotExist represents an error that stopwatch is not exist | ||||
| type ErrIssueStopwatchNotExist struct { | ||||
| 	UserID  int64 | ||||
| 	IssueID int64 | ||||
| } | ||||
|  | ||||
| func (err ErrIssueStopwatchNotExist) Error() string { | ||||
| 	return fmt.Sprintf("issue stopwatch doesn't exist[uid: %d, issue_id: %d", err.UserID, err.IssueID) | ||||
| } | ||||
|  | ||||
| func (err ErrIssueStopwatchNotExist) Unwrap() error { | ||||
| 	return util.ErrNotExist | ||||
| } | ||||
|  | ||||
| // Stopwatch represents a stopwatch for time tracking. | ||||
| type Stopwatch struct { | ||||
| 	ID          int64              `xorm:"pk autoincr"` | ||||
| @@ -55,13 +40,11 @@ func getStopwatch(ctx context.Context, userID, issueID int64) (sw *Stopwatch, ex | ||||
| 	return sw, exists, err | ||||
| } | ||||
|  | ||||
| // UserIDCount is a simple coalition of UserID and Count | ||||
| type UserStopwatch struct { | ||||
| 	UserID      int64 | ||||
| 	StopWatches []*Stopwatch | ||||
| } | ||||
|  | ||||
| // GetUIDsAndNotificationCounts between the two provided times | ||||
| func GetUIDsAndStopwatch(ctx context.Context) ([]*UserStopwatch, error) { | ||||
| 	sws := []*Stopwatch{} | ||||
| 	if err := db.GetEngine(ctx).Where("issue_id != 0").Find(&sws); err != nil { | ||||
| @@ -87,7 +70,7 @@ func GetUIDsAndStopwatch(ctx context.Context) ([]*UserStopwatch, error) { | ||||
| 	return res, nil | ||||
| } | ||||
|  | ||||
| // GetUserStopwatches return list of all stopwatches of a user | ||||
| // GetUserStopwatches return list of the user's all stopwatches | ||||
| func GetUserStopwatches(ctx context.Context, userID int64, listOptions db.ListOptions) ([]*Stopwatch, error) { | ||||
| 	sws := make([]*Stopwatch, 0, 8) | ||||
| 	sess := db.GetEngine(ctx).Where("stopwatch.user_id = ?", userID) | ||||
| @@ -102,7 +85,7 @@ func GetUserStopwatches(ctx context.Context, userID int64, listOptions db.ListOp | ||||
| 	return sws, nil | ||||
| } | ||||
|  | ||||
| // CountUserStopwatches return count of all stopwatches of a user | ||||
| // CountUserStopwatches return count of the user's all stopwatches | ||||
| func CountUserStopwatches(ctx context.Context, userID int64) (int64, error) { | ||||
| 	return db.GetEngine(ctx).Where("user_id = ?", userID).Count(&Stopwatch{}) | ||||
| } | ||||
| @@ -136,43 +119,21 @@ func HasUserStopwatch(ctx context.Context, userID int64) (exists bool, sw *Stopw | ||||
| 	return exists, sw, issue, err | ||||
| } | ||||
|  | ||||
| // FinishIssueStopwatchIfPossible if stopwatch exist then finish it otherwise ignore | ||||
| func FinishIssueStopwatchIfPossible(ctx context.Context, user *user_model.User, issue *Issue) error { | ||||
| 	_, exists, err := getStopwatch(ctx, user.ID, issue.ID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if !exists { | ||||
| 		return nil | ||||
| 	} | ||||
| 	return FinishIssueStopwatch(ctx, user, issue) | ||||
| } | ||||
|  | ||||
| // CreateOrStopIssueStopwatch create an issue stopwatch if it's not exist, otherwise finish it | ||||
| func CreateOrStopIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue) error { | ||||
| 	_, exists, err := getStopwatch(ctx, user.ID, issue.ID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if exists { | ||||
| 		return FinishIssueStopwatch(ctx, user, issue) | ||||
| 	} | ||||
| 	return CreateIssueStopwatch(ctx, user, issue) | ||||
| } | ||||
|  | ||||
| // FinishIssueStopwatch if stopwatch exist then finish it otherwise return an error | ||||
| func FinishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue) error { | ||||
| // FinishIssueStopwatch if stopwatch exists, then finish it. | ||||
| func FinishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue) (ok bool, err error) { | ||||
| 	sw, exists, err := getStopwatch(ctx, user.ID, issue.ID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 		return false, err | ||||
| 	} else if !exists { | ||||
| 		return false, nil | ||||
| 	} | ||||
| 	if !exists { | ||||
| 		return ErrIssueStopwatchNotExist{ | ||||
| 			UserID:  user.ID, | ||||
| 			IssueID: issue.ID, | ||||
| 		} | ||||
| 	if err = finishIssueStopwatch(ctx, user, issue, sw); err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
| 	return true, nil | ||||
| } | ||||
|  | ||||
| func finishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue, sw *Stopwatch) error { | ||||
| 	// Create tracked time out of the time difference between start date and actual date | ||||
| 	timediff := time.Now().Unix() - int64(sw.CreatedUnix) | ||||
|  | ||||
| @@ -184,14 +145,12 @@ func FinishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Iss | ||||
| 		Time:    timediff, | ||||
| 	} | ||||
|  | ||||
| 	if err := db.Insert(ctx, tt); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err := issue.LoadRepo(ctx); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err := db.Insert(ctx, tt); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if _, err := CreateComment(ctx, &CreateCommentOptions{ | ||||
| 		Doer:    user, | ||||
| 		Issue:   issue, | ||||
| @@ -202,83 +161,65 @@ func FinishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Iss | ||||
| 	}); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	_, err = db.DeleteByBean(ctx, sw) | ||||
| 	_, err := db.DeleteByBean(ctx, sw) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| // CreateIssueStopwatch creates a stopwatch if not exist, otherwise return an error | ||||
| func CreateIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue) error { | ||||
| 	if err := issue.LoadRepo(ctx); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// if another stopwatch is running: stop it | ||||
| 	exists, _, otherIssue, err := HasUserStopwatch(ctx, user.ID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	if exists { | ||||
| 		if err := FinishIssueStopwatch(ctx, user, otherIssue); err != nil { | ||||
| 			return err | ||||
| // CreateIssueStopwatch creates a stopwatch if the issue doesn't have the user's stopwatch. | ||||
| // It also stops any other stopwatch that might be running for the user. | ||||
| func CreateIssueStopwatch(ctx context.Context, user *user_model.User, issue *Issue) (ok bool, err error) { | ||||
| 	{ // if another issue's stopwatch is running: stop it; if this issue has a stopwatch: return an error. | ||||
| 		exists, otherStopWatch, otherIssue, err := HasUserStopwatch(ctx, user.ID) | ||||
| 		if err != nil { | ||||
| 			return false, err | ||||
| 		} | ||||
| 		if exists { | ||||
| 			if otherStopWatch.IssueID == issue.ID { | ||||
| 				// don't allow starting stopwatch for the same issue | ||||
| 				return false, nil | ||||
| 			} | ||||
| 			// stop the other issue's stopwatch | ||||
| 			if err = finishIssueStopwatch(ctx, user, otherIssue, otherStopWatch); err != nil { | ||||
| 				return false, err | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Create stopwatch | ||||
| 	sw := &Stopwatch{ | ||||
| 		UserID:  user.ID, | ||||
| 		IssueID: issue.ID, | ||||
| 	if err = issue.LoadRepo(ctx); err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
|  | ||||
| 	if err := db.Insert(ctx, sw); err != nil { | ||||
| 		return err | ||||
| 	if err = db.Insert(ctx, &Stopwatch{UserID: user.ID, IssueID: issue.ID}); err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
|  | ||||
| 	if err := issue.LoadRepo(ctx); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if _, err := CreateComment(ctx, &CreateCommentOptions{ | ||||
| 	if _, err = CreateComment(ctx, &CreateCommentOptions{ | ||||
| 		Doer:  user, | ||||
| 		Issue: issue, | ||||
| 		Repo:  issue.Repo, | ||||
| 		Type:  CommentTypeStartTracking, | ||||
| 	}); err != nil { | ||||
| 		return err | ||||
| 		return false, err | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| 	return true, nil | ||||
| } | ||||
|  | ||||
| // CancelStopwatch removes the given stopwatch and logs it into issue's timeline. | ||||
| func CancelStopwatch(ctx context.Context, user *user_model.User, issue *Issue) error { | ||||
| 	ctx, committer, err := db.TxContext(ctx) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer committer.Close() | ||||
| 	if err := cancelStopwatch(ctx, user, issue); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return committer.Commit() | ||||
| } | ||||
|  | ||||
| func cancelStopwatch(ctx context.Context, user *user_model.User, issue *Issue) error { | ||||
| 	e := db.GetEngine(ctx) | ||||
| 	sw, exists, err := getStopwatch(ctx, user.ID, issue.ID) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if exists { | ||||
| 		if _, err := e.Delete(sw); err != nil { | ||||
| func CancelStopwatch(ctx context.Context, user *user_model.User, issue *Issue) (ok bool, err error) { | ||||
| 	err = db.WithTx(ctx, func(ctx context.Context) error { | ||||
| 		e := db.GetEngine(ctx) | ||||
| 		sw, exists, err := getStopwatch(ctx, user.ID, issue.ID) | ||||
| 		if err != nil { | ||||
| 			return err | ||||
| 		} else if !exists { | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		if err := issue.LoadRepo(ctx); err != nil { | ||||
| 		if err = issue.LoadRepo(ctx); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		if _, err := CreateComment(ctx, &CreateCommentOptions{ | ||||
| 		if _, err = e.Delete(sw); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if _, err = CreateComment(ctx, &CreateCommentOptions{ | ||||
| 			Doer:  user, | ||||
| 			Issue: issue, | ||||
| 			Repo:  issue.Repo, | ||||
| @@ -286,6 +227,8 @@ func cancelStopwatch(ctx context.Context, user *user_model.User, issue *Issue) e | ||||
| 		}); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| 	return nil | ||||
| 		ok = true | ||||
| 		return nil | ||||
| 	}) | ||||
| 	return ok, err | ||||
| } | ||||
|   | ||||
| @@ -10,7 +10,6 @@ import ( | ||||
| 	issues_model "code.gitea.io/gitea/models/issues" | ||||
| 	"code.gitea.io/gitea/models/unittest" | ||||
| 	user_model "code.gitea.io/gitea/models/user" | ||||
| 	"code.gitea.io/gitea/modules/timeutil" | ||||
|  | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
| @@ -18,26 +17,22 @@ import ( | ||||
| func TestCancelStopwatch(t *testing.T) { | ||||
| 	assert.NoError(t, unittest.PrepareTestDatabase()) | ||||
|  | ||||
| 	user1, err := user_model.GetUserByID(db.DefaultContext, 1) | ||||
| 	assert.NoError(t, err) | ||||
| 	user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) | ||||
| 	issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) | ||||
|  | ||||
| 	issue1, err := issues_model.GetIssueByID(db.DefaultContext, 1) | ||||
| 	assert.NoError(t, err) | ||||
| 	issue2, err := issues_model.GetIssueByID(db.DefaultContext, 2) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	err = issues_model.CancelStopwatch(db.DefaultContext, user1, issue1) | ||||
| 	ok, err := issues_model.CancelStopwatch(db.DefaultContext, user1, issue1) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.True(t, ok) | ||||
| 	unittest.AssertNotExistsBean(t, &issues_model.Stopwatch{UserID: user1.ID, IssueID: issue1.ID}) | ||||
| 	unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{Type: issues_model.CommentTypeCancelTracking, PosterID: user1.ID, IssueID: issue1.ID}) | ||||
|  | ||||
| 	_ = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{Type: issues_model.CommentTypeCancelTracking, PosterID: user1.ID, IssueID: issue1.ID}) | ||||
|  | ||||
| 	assert.NoError(t, issues_model.CancelStopwatch(db.DefaultContext, user1, issue2)) | ||||
| 	ok, err = issues_model.CancelStopwatch(db.DefaultContext, user1, issue1) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.False(t, ok) | ||||
| } | ||||
|  | ||||
| func TestStopwatchExists(t *testing.T) { | ||||
| 	assert.NoError(t, unittest.PrepareTestDatabase()) | ||||
|  | ||||
| 	assert.True(t, issues_model.StopwatchExists(db.DefaultContext, 1, 1)) | ||||
| 	assert.False(t, issues_model.StopwatchExists(db.DefaultContext, 1, 2)) | ||||
| } | ||||
| @@ -58,21 +53,35 @@ func TestHasUserStopwatch(t *testing.T) { | ||||
| func TestCreateOrStopIssueStopwatch(t *testing.T) { | ||||
| 	assert.NoError(t, unittest.PrepareTestDatabase()) | ||||
|  | ||||
| 	user2, err := user_model.GetUserByID(db.DefaultContext, 2) | ||||
| 	assert.NoError(t, err) | ||||
| 	org3, err := user_model.GetUserByID(db.DefaultContext, 3) | ||||
| 	assert.NoError(t, err) | ||||
| 	user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) | ||||
| 	issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) | ||||
| 	issue3 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) | ||||
|  | ||||
| 	issue1, err := issues_model.GetIssueByID(db.DefaultContext, 1) | ||||
| 	// create a new stopwatch | ||||
| 	ok, err := issues_model.CreateIssueStopwatch(db.DefaultContext, user4, issue1) | ||||
| 	assert.NoError(t, err) | ||||
| 	issue2, err := issues_model.GetIssueByID(db.DefaultContext, 2) | ||||
| 	assert.True(t, ok) | ||||
| 	unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: user4.ID, IssueID: issue1.ID}) | ||||
| 	// should not create a second stopwatch for the same issue | ||||
| 	ok, err = issues_model.CreateIssueStopwatch(db.DefaultContext, user4, issue1) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.False(t, ok) | ||||
| 	// on a different issue, it will finish the existing stopwatch and create a new one | ||||
| 	ok, err = issues_model.CreateIssueStopwatch(db.DefaultContext, user4, issue3) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.True(t, ok) | ||||
| 	unittest.AssertNotExistsBean(t, &issues_model.Stopwatch{UserID: user4.ID, IssueID: issue1.ID}) | ||||
| 	unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: user4.ID, IssueID: issue3.ID}) | ||||
|  | ||||
| 	assert.NoError(t, issues_model.CreateOrStopIssueStopwatch(db.DefaultContext, org3, issue1)) | ||||
| 	sw := unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: 3, IssueID: 1}) | ||||
| 	assert.LessOrEqual(t, sw.CreatedUnix, timeutil.TimeStampNow()) | ||||
|  | ||||
| 	assert.NoError(t, issues_model.CreateOrStopIssueStopwatch(db.DefaultContext, user2, issue2)) | ||||
| 	unittest.AssertNotExistsBean(t, &issues_model.Stopwatch{UserID: 2, IssueID: 2}) | ||||
| 	unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{UserID: 2, IssueID: 2}) | ||||
| 	// user2 already has a stopwatch in test fixture | ||||
| 	user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | ||||
| 	issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2}) | ||||
| 	ok, err = issues_model.FinishIssueStopwatch(db.DefaultContext, user2, issue2) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.True(t, ok) | ||||
| 	unittest.AssertNotExistsBean(t, &issues_model.Stopwatch{UserID: user2.ID, IssueID: issue2.ID}) | ||||
| 	unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{UserID: user2.ID, IssueID: issue2.ID}) | ||||
| 	ok, err = issues_model.FinishIssueStopwatch(db.DefaultContext, user2, issue2) | ||||
| 	assert.NoError(t, err) | ||||
| 	assert.False(t, ok) | ||||
| } | ||||
|   | ||||
| @@ -1726,6 +1726,8 @@ issues.remove_time_estimate_at = removed time estimate %s | ||||
| issues.time_estimate_invalid = Time estimate format is invalid | ||||
| issues.start_tracking_history = started working %s | ||||
| issues.tracker_auto_close = Timer will be stopped automatically when this issue gets closed | ||||
| issues.stopwatch_already_stopped = The timer for this issue is already stopped | ||||
| issues.stopwatch_already_created = The timer for this issue already exists | ||||
| issues.tracking_already_started = `You have already started time tracking on <a href="%s">another issue</a>!` | ||||
| issues.stop_tracking = Stop Timer | ||||
| issues.stop_tracking_history = worked for <b>%[1]s</b> %[2]s | ||||
|   | ||||
| @@ -4,7 +4,6 @@ | ||||
| package repo | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"net/http" | ||||
|  | ||||
| 	issues_model "code.gitea.io/gitea/models/issues" | ||||
| @@ -49,14 +48,17 @@ func StartIssueStopwatch(ctx *context.APIContext) { | ||||
| 	//   "409": | ||||
| 	//     description: Cannot start a stopwatch again if it already exists | ||||
|  | ||||
| 	issue, err := prepareIssueStopwatch(ctx, false) | ||||
| 	if err != nil { | ||||
| 	issue := prepareIssueForStopwatch(ctx) | ||||
| 	if ctx.Written() { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := issues_model.CreateIssueStopwatch(ctx, ctx.Doer, issue); err != nil { | ||||
| 	if ok, err := issues_model.CreateIssueStopwatch(ctx, ctx.Doer, issue); err != nil { | ||||
| 		ctx.APIErrorInternal(err) | ||||
| 		return | ||||
| 	} else if !ok { | ||||
| 		ctx.APIError(http.StatusConflict, "cannot start a stopwatch again if it already exists") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	ctx.Status(http.StatusCreated) | ||||
| @@ -96,18 +98,20 @@ func StopIssueStopwatch(ctx *context.APIContext) { | ||||
| 	//   "404": | ||||
| 	//     "$ref": "#/responses/notFound" | ||||
| 	//   "409": | ||||
| 	//     description:  Cannot stop a non existent stopwatch | ||||
| 	//     description:  Cannot stop a non-existent stopwatch | ||||
|  | ||||
| 	issue, err := prepareIssueStopwatch(ctx, true) | ||||
| 	if err != nil { | ||||
| 	issue := prepareIssueForStopwatch(ctx) | ||||
| 	if ctx.Written() { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := issues_model.FinishIssueStopwatch(ctx, ctx.Doer, issue); err != nil { | ||||
| 	if ok, err := issues_model.FinishIssueStopwatch(ctx, ctx.Doer, issue); err != nil { | ||||
| 		ctx.APIErrorInternal(err) | ||||
| 		return | ||||
| 	} else if !ok { | ||||
| 		ctx.APIError(http.StatusConflict, "cannot stop a non-existent stopwatch") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	ctx.Status(http.StatusCreated) | ||||
| } | ||||
|  | ||||
| @@ -145,22 +149,25 @@ func DeleteIssueStopwatch(ctx *context.APIContext) { | ||||
| 	//   "404": | ||||
| 	//     "$ref": "#/responses/notFound" | ||||
| 	//   "409": | ||||
| 	//     description:  Cannot cancel a non existent stopwatch | ||||
| 	//     description:  Cannot cancel a non-existent stopwatch | ||||
|  | ||||
| 	issue, err := prepareIssueStopwatch(ctx, true) | ||||
| 	if err != nil { | ||||
| 	issue := prepareIssueForStopwatch(ctx) | ||||
| 	if ctx.Written() { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := issues_model.CancelStopwatch(ctx, ctx.Doer, issue); err != nil { | ||||
| 	if ok, err := issues_model.CancelStopwatch(ctx, ctx.Doer, issue); err != nil { | ||||
| 		ctx.APIErrorInternal(err) | ||||
| 		return | ||||
| 	} else if !ok { | ||||
| 		ctx.APIError(http.StatusConflict, "cannot cancel a non-existent stopwatch") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	ctx.Status(http.StatusNoContent) | ||||
| } | ||||
|  | ||||
| func prepareIssueStopwatch(ctx *context.APIContext, shouldExist bool) (*issues_model.Issue, error) { | ||||
| func prepareIssueForStopwatch(ctx *context.APIContext) *issues_model.Issue { | ||||
| 	issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64("index")) | ||||
| 	if err != nil { | ||||
| 		if issues_model.IsErrIssueNotExist(err) { | ||||
| @@ -168,32 +175,19 @@ func prepareIssueStopwatch(ctx *context.APIContext, shouldExist bool) (*issues_m | ||||
| 		} else { | ||||
| 			ctx.APIErrorInternal(err) | ||||
| 		} | ||||
|  | ||||
| 		return nil, err | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { | ||||
| 		ctx.Status(http.StatusForbidden) | ||||
| 		return nil, errors.New("Unable to write to PRs") | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if !ctx.Repo.CanUseTimetracker(ctx, issue, ctx.Doer) { | ||||
| 		ctx.Status(http.StatusForbidden) | ||||
| 		return nil, errors.New("Cannot use time tracker") | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	if issues_model.StopwatchExists(ctx, ctx.Doer.ID, issue.ID) != shouldExist { | ||||
| 		if shouldExist { | ||||
| 			ctx.APIError(http.StatusConflict, "cannot stop/cancel a non existent stopwatch") | ||||
| 			err = errors.New("cannot stop/cancel a non existent stopwatch") | ||||
| 		} else { | ||||
| 			ctx.APIError(http.StatusConflict, "cannot start a stopwatch again if it already exists") | ||||
| 			err = errors.New("cannot start a stopwatch again if it already exists") | ||||
| 		} | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return issue, nil | ||||
| 	return issue | ||||
| } | ||||
|  | ||||
| // GetStopwatches get all stopwatches | ||||
|   | ||||
| @@ -10,33 +10,47 @@ import ( | ||||
| 	"code.gitea.io/gitea/services/context" | ||||
| ) | ||||
|  | ||||
| // IssueStopwatch creates or stops a stopwatch for the given issue. | ||||
| func IssueStopwatch(c *context.Context) { | ||||
| // IssueStartStopwatch creates a stopwatch for the given issue. | ||||
| func IssueStartStopwatch(c *context.Context) { | ||||
| 	issue := GetActionIssue(c) | ||||
| 	if c.Written() { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	var showSuccessMessage bool | ||||
|  | ||||
| 	if !issues_model.StopwatchExists(c, c.Doer.ID, issue.ID) { | ||||
| 		showSuccessMessage = true | ||||
| 	} | ||||
|  | ||||
| 	if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { | ||||
| 		c.NotFound(nil) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := issues_model.CreateOrStopIssueStopwatch(c, c.Doer, issue); err != nil { | ||||
| 		c.ServerError("CreateOrStopIssueStopwatch", err) | ||||
| 	if ok, err := issues_model.CreateIssueStopwatch(c, c.Doer, issue); err != nil { | ||||
| 		c.ServerError("CreateIssueStopwatch", err) | ||||
| 		return | ||||
| 	} else if !ok { | ||||
| 		c.Flash.Warning(c.Tr("repo.issues.stopwatch_already_created")) | ||||
| 	} else { | ||||
| 		c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) | ||||
| 	} | ||||
| 	c.JSONRedirect("") | ||||
| } | ||||
|  | ||||
| // IssueStopStopwatch stops a stopwatch for the given issue. | ||||
| func IssueStopStopwatch(c *context.Context) { | ||||
| 	issue := GetActionIssue(c) | ||||
| 	if c.Written() { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if showSuccessMessage { | ||||
| 		c.Flash.Success(c.Tr("repo.issues.tracker_auto_close")) | ||||
| 	if !c.Repo.CanUseTimetracker(c, issue, c.Doer) { | ||||
| 		c.NotFound(nil) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if ok, err := issues_model.FinishIssueStopwatch(c, c.Doer, issue); err != nil { | ||||
| 		c.ServerError("FinishIssueStopwatch", err) | ||||
| 		return | ||||
| 	} else if !ok { | ||||
| 		c.Flash.Warning(c.Tr("repo.issues.stopwatch_already_stopped")) | ||||
| 	} | ||||
| 	c.JSONRedirect("") | ||||
| } | ||||
|  | ||||
| @@ -51,7 +65,7 @@ func CancelStopwatch(c *context.Context) { | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := issues_model.CancelStopwatch(c, c.Doer, issue); err != nil { | ||||
| 	if _, err := issues_model.CancelStopwatch(c, c.Doer, issue); err != nil { | ||||
| 		c.ServerError("CancelStopwatch", err) | ||||
| 		return | ||||
| 	} | ||||
|   | ||||
| @@ -1258,13 +1258,8 @@ func CancelAutoMergePullRequest(ctx *context.Context) { | ||||
| } | ||||
|  | ||||
| func stopTimerIfAvailable(ctx *context.Context, user *user_model.User, issue *issues_model.Issue) error { | ||||
| 	if issues_model.StopwatchExists(ctx, user.ID, issue.ID) { | ||||
| 		if err := issues_model.CreateOrStopIssueStopwatch(ctx, user, issue); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| 	_, err := issues_model.FinishIssueStopwatch(ctx, user, issue) | ||||
| 	return err | ||||
| } | ||||
|  | ||||
| func PullsNewRedirect(ctx *context.Context) { | ||||
|   | ||||
| @@ -1253,7 +1253,8 @@ func registerWebRoutes(m *web.Router) { | ||||
| 					m.Post("/add", web.Bind(forms.AddTimeManuallyForm{}), repo.AddTimeManually) | ||||
| 					m.Post("/{timeid}/delete", repo.DeleteTime) | ||||
| 					m.Group("/stopwatch", func() { | ||||
| 						m.Post("/toggle", repo.IssueStopwatch) | ||||
| 						m.Post("/start", repo.IssueStartStopwatch) | ||||
| 						m.Post("/stop", repo.IssueStopStopwatch) | ||||
| 						m.Post("/cancel", repo.CancelStopwatch) | ||||
| 					}) | ||||
| 				}) | ||||
|   | ||||
| @@ -24,14 +24,14 @@ func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model | ||||
| 	comment, err := issues_model.CloseIssue(dbCtx, issue, doer) | ||||
| 	if err != nil { | ||||
| 		if issues_model.IsErrDependenciesLeft(err) { | ||||
| 			if err := issues_model.FinishIssueStopwatchIfPossible(dbCtx, doer, issue); err != nil { | ||||
| 			if _, err := issues_model.FinishIssueStopwatch(dbCtx, doer, issue); err != nil { | ||||
| 				log.Error("Unable to stop stopwatch for issue[%d]#%d: %v", issue.ID, issue.Index, err) | ||||
| 			} | ||||
| 		} | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err := issues_model.FinishIssueStopwatchIfPossible(dbCtx, doer, issue); err != nil { | ||||
| 	if _, err := issues_model.FinishIssueStopwatch(dbCtx, doer, issue); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -197,7 +197,7 @@ | ||||
| 					<span class="stopwatch-issue">{{$activeStopwatch.RepoSlug}}#{{$activeStopwatch.IssueIndex}}</span> | ||||
| 				</a> | ||||
| 				<div class="tw-flex tw-gap-1"> | ||||
| 					<form class="stopwatch-commit form-fetch-action" method="post" action="{{$activeStopwatch.IssueLink}}/times/stopwatch/toggle"> | ||||
| 					<form class="stopwatch-commit form-fetch-action" method="post" action="{{$activeStopwatch.IssueLink}}/times/stopwatch/stop"> | ||||
| 						{{.CsrfTokenHtml}} | ||||
| 						<button | ||||
| 							type="submit" | ||||
|   | ||||
| @@ -16,14 +16,14 @@ | ||||
| 					</a> | ||||
| 					<div class="divider"></div> | ||||
| 					{{if $.IsStopwatchRunning}} | ||||
| 					<a class="item issue-stop-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/toggle"> | ||||
| 					<a class="item issue-stop-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/stop"> | ||||
| 						{{svg "octicon-stopwatch"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_stop"}} | ||||
| 					</a> | ||||
| 					<a class="item issue-cancel-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/cancel"> | ||||
| 						{{svg "octicon-trash"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_discard"}} | ||||
| 					</a> | ||||
| 					{{else}} | ||||
| 					<a class="item issue-start-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/toggle"> | ||||
| 					<a class="item issue-start-time link-action" data-url="{{.Issue.Link}}/times/stopwatch/start"> | ||||
| 						{{svg "octicon-stopwatch"}} {{ctx.Locale.Tr "repo.issues.timetracker_timer_start"}} | ||||
| 					</a> | ||||
| 					<a class="item issue-add-time show-modal" data-modal="#issue-time-manually-add-modal"> | ||||
|   | ||||
							
								
								
									
										4
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								templates/swagger/v1_json.tmpl
									
									
									
										generated
									
									
									
								
							| @@ -11635,7 +11635,7 @@ | ||||
|             "$ref": "#/responses/notFound" | ||||
|           }, | ||||
|           "409": { | ||||
|             "description": "Cannot cancel a non existent stopwatch" | ||||
|             "description": "Cannot cancel a non-existent stopwatch" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
| @@ -11741,7 +11741,7 @@ | ||||
|             "$ref": "#/responses/notFound" | ||||
|           }, | ||||
|           "409": { | ||||
|             "description": "Cannot stop a non existent stopwatch" | ||||
|             "description": "Cannot stop a non-existent stopwatch" | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|   | ||||
| @@ -46,11 +46,11 @@ func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo | ||||
| 	AssertHTMLElement(t, htmlDoc, ".issue-add-time", canTrackTime) | ||||
|  | ||||
| 	issueLink := path.Join(user, repo, "issues", issue) | ||||
| 	req = NewRequestWithValues(t, "POST", path.Join(issueLink, "times", "stopwatch", "toggle"), map[string]string{ | ||||
| 	reqStart := NewRequestWithValues(t, "POST", path.Join(issueLink, "times", "stopwatch", "start"), map[string]string{ | ||||
| 		"_csrf": htmlDoc.GetCSRF(), | ||||
| 	}) | ||||
| 	if canTrackTime { | ||||
| 		session.MakeRequest(t, req, http.StatusOK) | ||||
| 		session.MakeRequest(t, reqStart, http.StatusOK) | ||||
|  | ||||
| 		req = NewRequest(t, "GET", issueLink) | ||||
| 		resp = session.MakeRequest(t, req, http.StatusOK) | ||||
| @@ -65,10 +65,10 @@ func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo | ||||
| 		// Sleep for 1 second to not get wrong order for stopping timer | ||||
| 		time.Sleep(time.Second) | ||||
|  | ||||
| 		req = NewRequestWithValues(t, "POST", path.Join(issueLink, "times", "stopwatch", "toggle"), map[string]string{ | ||||
| 		reqStop := NewRequestWithValues(t, "POST", path.Join(issueLink, "times", "stopwatch", "stop"), map[string]string{ | ||||
| 			"_csrf": htmlDoc.GetCSRF(), | ||||
| 		}) | ||||
| 		session.MakeRequest(t, req, http.StatusOK) | ||||
| 		session.MakeRequest(t, reqStop, http.StatusOK) | ||||
|  | ||||
| 		req = NewRequest(t, "GET", issueLink) | ||||
| 		resp = session.MakeRequest(t, req, http.StatusOK) | ||||
| @@ -77,6 +77,6 @@ func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo | ||||
| 		events = htmlDoc.doc.Find(".event > span.text") | ||||
| 		assert.Contains(t, events.Last().Text(), "worked for ") | ||||
| 	} else { | ||||
| 		session.MakeRequest(t, req, http.StatusNotFound) | ||||
| 		session.MakeRequest(t, reqStart, http.StatusNotFound) | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -134,7 +134,7 @@ function updateStopwatchData(data: any) { | ||||
|     const {repo_owner_name, repo_name, issue_index, seconds} = watch; | ||||
|     const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`; | ||||
|     document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl); | ||||
|     document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/toggle`); | ||||
|     document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`); | ||||
|     document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`); | ||||
|     const stopwatchIssue = document.querySelector('.stopwatch-issue'); | ||||
|     if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user