diff --git a/integrations/api_issue_stopwatch_test.go b/integrations/api_issue_stopwatch_test.go new file mode 100644 index 000000000..e0fe00c41 --- /dev/null +++ b/integrations/api_issue_stopwatch_test.go @@ -0,0 +1,83 @@ +// Copyright 2019 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" + "testing" + + "code.gitea.io/gitea/models" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestAPIListStopWatches(t *testing.T) { + defer prepareTestEnv(t)() + + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) + owner := models.AssertExistsAndLoadBean(t, &models.User{ID: repo.OwnerID}).(*models.User) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session) + req := NewRequestf(t, "GET", "/api/v1/user/stopwatches?token=%s", token) + resp := session.MakeRequest(t, req, http.StatusOK) + var apiWatches []*api.StopWatch + DecodeJSON(t, resp, &apiWatches) + expect := models.AssertExistsAndLoadBean(t, &models.Stopwatch{UserID: owner.ID}).(*models.Stopwatch) + expectAPI, _ := expect.APIFormat() + assert.Len(t, apiWatches, 1) + + assert.EqualValues(t, expectAPI.IssueIndex, apiWatches[0].IssueIndex) + assert.EqualValues(t, expectAPI.Created.Unix(), apiWatches[0].Created.Unix()) +} + +func TestAPIStopStopWatches(t *testing.T) { + defer prepareTestEnv(t)() + + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 2}).(*models.Issue) + _ = issue.LoadRepo() + owner := models.AssertExistsAndLoadBean(t, &models.User{ID: issue.Repo.OwnerID}).(*models.User) + user := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session) + + req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/issues/%d/stopwatch/stop?token=%s", owner.Name, issue.Repo.Name, issue.Index, token) + session.MakeRequest(t, req, http.StatusCreated) + session.MakeRequest(t, req, http.StatusConflict) +} + +func TestAPICancelStopWatches(t *testing.T) { + defer prepareTestEnv(t)() + + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1}).(*models.Issue) + _ = issue.LoadRepo() + owner := models.AssertExistsAndLoadBean(t, &models.User{ID: issue.Repo.OwnerID}).(*models.User) + user := models.AssertExistsAndLoadBean(t, &models.User{ID: 1}).(*models.User) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session) + + req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/%d/stopwatch/delete?token=%s", owner.Name, issue.Repo.Name, issue.Index, token) + session.MakeRequest(t, req, http.StatusNoContent) + session.MakeRequest(t, req, http.StatusConflict) +} + +func TestAPIStartStopWatches(t *testing.T) { + defer prepareTestEnv(t)() + + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 3}).(*models.Issue) + _ = issue.LoadRepo() + owner := models.AssertExistsAndLoadBean(t, &models.User{ID: issue.Repo.OwnerID}).(*models.User) + user := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + + session := loginUser(t, user.Name) + token := getTokenForLoggedInUser(t, session) + + req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/issues/%d/stopwatch/start?token=%s", owner.Name, issue.Repo.Name, issue.Index, token) + session.MakeRequest(t, req, http.StatusCreated) + session.MakeRequest(t, req, http.StatusConflict) +} diff --git a/models/fixtures/stopwatch.yml b/models/fixtures/stopwatch.yml index 397a8214d..b7919d6fb 100644 --- a/models/fixtures/stopwatch.yml +++ b/models/fixtures/stopwatch.yml @@ -2,10 +2,10 @@ id: 1 user_id: 1 issue_id: 1 - created_unix: 1500988502 + created_unix: 1500988001 - id: 2 user_id: 2 issue_id: 2 - created_unix: 1500988502 + created_unix: 1500988002 diff --git a/models/issue_stopwatch.go b/models/issue_stopwatch.go index d7c3a9f73..8047f122b 100644 --- a/models/issue_stopwatch.go +++ b/models/issue_stopwatch.go @@ -8,6 +8,7 @@ import ( "fmt" "time" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" ) @@ -19,6 +20,9 @@ type Stopwatch struct { CreatedUnix timeutil.TimeStamp `xorm:"created"` } +// Stopwatches is a List ful of Stopwatch +type Stopwatches []Stopwatch + func getStopwatch(e Engine, userID, issueID int64) (sw *Stopwatch, exists bool, err error) { sw = new(Stopwatch) exists, err = e. @@ -28,6 +32,16 @@ func getStopwatch(e Engine, userID, issueID int64) (sw *Stopwatch, exists bool, return } +// GetUserStopwatches return list of all stopwatches of a user +func GetUserStopwatches(userID int64) (sws *Stopwatches, err error) { + sws = new(Stopwatches) + err = x.Where("stopwatch.user_id = ?", userID).Find(sws) + if err != nil { + return nil, err + } + return sws, nil +} + // StopwatchExists returns true if the stopwatch exists func StopwatchExists(userID int64, issueID int64) bool { _, exists, _ := getStopwatch(x, userID, issueID) @@ -160,3 +174,28 @@ func SecToTime(duration int64) string { return hrs } + +// APIFormat convert Stopwatch type to api.StopWatch type +func (sw *Stopwatch) APIFormat() (api.StopWatch, error) { + issue, err := getIssueByID(x, sw.IssueID) + if err != nil { + return api.StopWatch{}, err + } + return api.StopWatch{ + Created: sw.CreatedUnix.AsTime(), + IssueIndex: issue.Index, + }, nil +} + +// APIFormat convert Stopwatches type to api.StopWatches type +func (sws Stopwatches) APIFormat() (api.StopWatches, error) { + result := api.StopWatches(make([]api.StopWatch, 0, len(sws))) + for _, sw := range sws { + apiSW, err := sw.APIFormat() + if err != nil { + return nil, err + } + result = append(result, apiSW) + } + return result, nil +} diff --git a/modules/structs/issue_stopwatch.go b/modules/structs/issue_stopwatch.go new file mode 100644 index 000000000..10510e36e --- /dev/null +++ b/modules/structs/issue_stopwatch.go @@ -0,0 +1,19 @@ +// Copyright 2019 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 structs + +import ( + "time" +) + +// StopWatch represent a running stopwatch +type StopWatch struct { + // swagger:strfmt date-time + Created time.Time `json:"created"` + IssueIndex int64 `json:"issue_index"` +} + +// StopWatches represent a list of stopwatches +type StopWatches []StopWatch diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index cd5fc1f3e..7526d3f5e 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -584,6 +584,8 @@ func RegisterRoutes(m *macaron.Macaron) { }) m.Get("/times", repo.ListMyTrackedTimes) + m.Get("/stopwatches", repo.GetStopwatches) + m.Get("/subscriptions", user.GetMyWatchedRepos) m.Get("/teams", org.ListUserTeams) @@ -691,6 +693,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Group("/stopwatch", func() { m.Post("/start", reqToken(), repo.StartIssueStopwatch) m.Post("/stop", reqToken(), repo.StopIssueStopwatch) + m.Delete("/delete", reqToken(), repo.DeleteIssueStopwatch) }) m.Group("/subscriptions", func() { m.Get("", repo.GetIssueSubscribers) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 186e66cb8..6972d447a 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -598,141 +598,3 @@ func UpdateIssueDeadline(ctx *context.APIContext, form api.EditDeadlineOption) { ctx.JSON(201, api.IssueDeadline{Deadline: &deadline}) } - -// StartIssueStopwatch creates a stopwatch for the given issue. -func StartIssueStopwatch(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/stopwatch/start issue issueStartStopWatch - // --- - // summary: Start stopwatch on an issue. - // consumes: - // - application/json - // produces: - // - application/json - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // - name: index - // in: path - // description: index of the issue to create the stopwatch on - // type: integer - // format: int64 - // required: true - // responses: - // "201": - // "$ref": "#/responses/empty" - // "403": - // description: Not repo writer, user does not have rights to toggle stopwatch - // "404": - // description: Issue not found - // "409": - // description: Cannot start a stopwatch again if it already exists - issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) - if err != nil { - if models.IsErrIssueNotExist(err) { - ctx.NotFound() - } else { - ctx.Error(500, "GetIssueByIndex", err) - } - - return - } - - if !ctx.Repo.CanWrite(models.UnitTypeIssues) { - ctx.Status(403) - return - } - - if !ctx.Repo.CanUseTimetracker(issue, ctx.User) { - ctx.Status(403) - return - } - - if models.StopwatchExists(ctx.User.ID, issue.ID) { - ctx.Error(409, "StopwatchExists", "a stopwatch has already been started for this issue") - return - } - - if err := models.CreateOrStopIssueStopwatch(ctx.User, issue); err != nil { - ctx.Error(500, "CreateOrStopIssueStopwatch", err) - return - } - - ctx.Status(201) -} - -// StopIssueStopwatch stops a stopwatch for the given issue. -func StopIssueStopwatch(ctx *context.APIContext) { - // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/stopwatch/stop issue issueStopWatch - // --- - // summary: Stop an issue's existing stopwatch. - // consumes: - // - application/json - // produces: - // - application/json - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // - name: index - // in: path - // description: index of the issue to stop the stopwatch on - // type: integer - // format: int64 - // required: true - // responses: - // "201": - // "$ref": "#/responses/empty" - // "403": - // description: Not repo writer, user does not have rights to toggle stopwatch - // "404": - // description: Issue not found - // "409": - // description: Cannot stop a non existent stopwatch - issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) - if err != nil { - if models.IsErrIssueNotExist(err) { - ctx.NotFound() - } else { - ctx.Error(500, "GetIssueByIndex", err) - } - - return - } - - if !ctx.Repo.CanWrite(models.UnitTypeIssues) { - ctx.Status(403) - return - } - - if !ctx.Repo.CanUseTimetracker(issue, ctx.User) { - ctx.Status(403) - return - } - - if !models.StopwatchExists(ctx.User.ID, issue.ID) { - ctx.Error(409, "StopwatchExists", "cannot stop a non existent stopwatch") - return - } - - if err := models.CreateOrStopIssueStopwatch(ctx.User, issue); err != nil { - ctx.Error(500, "CreateOrStopIssueStopwatch", err) - return - } - - ctx.Status(201) -} diff --git a/routers/api/v1/repo/issue_stopwatch.go b/routers/api/v1/repo/issue_stopwatch.go new file mode 100644 index 000000000..48b2f6498 --- /dev/null +++ b/routers/api/v1/repo/issue_stopwatch.go @@ -0,0 +1,216 @@ +// Copyright 2019 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" +) + +// StartIssueStopwatch creates a stopwatch for the given issue. +func StartIssueStopwatch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/stopwatch/start issue issueStartStopWatch + // --- + // summary: Start stopwatch on an issue. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the issue to create the stopwatch on + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // description: Not repo writer, user does not have rights to toggle stopwatch + // "404": + // description: Issue not found + // "409": + // description: Cannot start a stopwatch again if it already exists + issue, err := prepareIssueStopwatch(ctx, false) + if err != nil { + return + } + + if err := models.CreateOrStopIssueStopwatch(ctx.User, issue); err != nil { + ctx.Error(500, "CreateOrStopIssueStopwatch", err) + return + } + + ctx.Status(201) +} + +// StopIssueStopwatch stops a stopwatch for the given issue. +func StopIssueStopwatch(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/stopwatch/stop issue issueStopStopWatch + // --- + // summary: Stop an issue's existing stopwatch. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the issue to stop the stopwatch on + // type: integer + // format: int64 + // required: true + // responses: + // "201": + // "$ref": "#/responses/empty" + // "403": + // description: Not repo writer, user does not have rights to toggle stopwatch + // "404": + // description: Issue not found + // "409": + // description: Cannot stop a non existent stopwatch + issue, err := prepareIssueStopwatch(ctx, true) + if err != nil { + return + } + + if err := models.CreateOrStopIssueStopwatch(ctx.User, issue); err != nil { + ctx.Error(500, "CreateOrStopIssueStopwatch", err) + return + } + + ctx.Status(201) +} + +// DeleteIssueStopwatch delete a specific stopwatch +func DeleteIssueStopwatch(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/stopwatch/delete issue issueDeleteStopWatch + // --- + // summary: Delete an issue's existing stopwatch. + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the issue to stop the stopwatch on + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // description: Not repo writer, user does not have rights to toggle stopwatch + // "404": + // description: Issue not found + // "409": + // description: Cannot cancel a non existent stopwatch + issue, err := prepareIssueStopwatch(ctx, true) + if err != nil { + return + } + + if err := models.CancelStopwatch(ctx.User, issue); err != nil { + ctx.Error(500, "CancelStopwatch", err) + return + } + + ctx.Status(204) +} + +func prepareIssueStopwatch(ctx *context.APIContext, shouldExist bool) (*models.Issue, error) { + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(500, "GetIssueByIndex", err) + } + + return nil, err + } + + if !ctx.Repo.CanWrite(models.UnitTypeIssues) { + ctx.Status(403) + return nil, err + } + + if !ctx.Repo.CanUseTimetracker(issue, ctx.User) { + ctx.Status(403) + return nil, err + } + + if models.StopwatchExists(ctx.User.ID, issue.ID) != shouldExist { + if shouldExist { + ctx.Error(409, "StopwatchExists", "cannot stop/cancel a non existent stopwatch") + } else { + ctx.Error(409, "StopwatchExists", "cannot start a stopwatch again if it already exists") + } + return nil, err + } + + return issue, nil +} + +// GetStopwatches get all stopwatches +func GetStopwatches(ctx *context.APIContext) { + // swagger:operation GET /user/stopwatches user userGetStopWatches + // --- + // summary: Get list of all existing stopwatches + // consumes: + // - application/json + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/StopWatchList" + + sws, err := models.GetUserStopwatches(ctx.User.ID) + if err != nil { + ctx.Error(500, "GetUserStopwatches", err) + return + } + + apiSWs, err := sws.APIFormat() + if err != nil { + ctx.Error(500, "APIFormat", err) + return + } + + ctx.JSON(200, apiSWs) +} diff --git a/routers/api/v1/swagger/issue.go b/routers/api/v1/swagger/issue.go index a78c2982f..68c0a9a38 100644 --- a/routers/api/v1/swagger/issue.go +++ b/routers/api/v1/swagger/issue.go @@ -85,6 +85,20 @@ type swaggerIssueDeadline struct { Body api.IssueDeadline `json:"body"` } +// StopWatch +// swagger:response StopWatch +type swaggerResponseStopWatch struct { + // in:body + Body api.StopWatch `json:"body"` +} + +// StopWatchList +// swagger:response StopWatchList +type swaggerResponseStopWatchList struct { + // in:body + Body []api.StopWatch `json:"body"` +} + // EditReactionOption // swagger:response EditReactionOption type swaggerEditReactionOption struct { diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 9c8db2881..7ed43b450 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3972,6 +3972,59 @@ } } }, + "/repos/{owner}/{repo}/issues/{index}/stopwatch/delete": { + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Delete an issue's existing stopwatch.", + "operationId": "issueDeleteStopWatch", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "index of the issue to stop the stopwatch on", + "name": "index", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "description": "Not repo writer, user does not have rights to toggle stopwatch" + }, + "404": { + "description": "Issue not found" + }, + "409": { + "description": "Cannot cancel a non existent stopwatch" + } + } + } + }, "/repos/{owner}/{repo}/issues/{index}/stopwatch/start": { "post": { "consumes": [ @@ -4037,7 +4090,7 @@ "issue" ], "summary": "Stop an issue's existing stopwatch.", - "operationId": "issueStopWatch", + "operationId": "issueStopStopWatch", "parameters": [ { "type": "string", @@ -7174,6 +7227,26 @@ } } }, + "/user/stopwatches": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get list of all existing stopwatches", + "operationId": "userGetStopWatches", + "responses": { + "200": { + "$ref": "#/responses/StopWatchList" + } + } + } + }, "/user/subscriptions": { "get": { "produces": [ @@ -10808,6 +10881,23 @@ "type": "string", "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "StopWatch": { + "description": "StopWatch represent a running stopwatch", + "type": "object", + "properties": { + "created": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "issue_index": { + "type": "integer", + "format": "int64", + "x-go-name": "IssueIndex" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Tag": { "description": "Tag represents a repository tag", "type": "object", @@ -11553,6 +11643,21 @@ } } }, + "StopWatch": { + "description": "StopWatch", + "schema": { + "$ref": "#/definitions/StopWatch" + } + }, + "StopWatchList": { + "description": "StopWatchList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/StopWatch" + } + } + }, "Tag": { "description": "Tag", "schema": {