diff --git a/models/issues/comment.go b/models/issues/comment.go index e781931261..e730308704 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -823,6 +823,11 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, IsForcePush: opts.IsForcePush, Invalidated: opts.Invalidated, } + if opts.Issue.NoAutoTime { + comment.CreatedUnix = opts.Issue.UpdatedUnix + comment.UpdatedUnix = opts.Issue.UpdatedUnix + e.NoAutoTime() + } if _, err = e.Insert(comment); err != nil { return nil, err } @@ -1101,9 +1106,17 @@ func UpdateComment(c *Comment, doer *user_model.User) error { return err } defer committer.Close() - sess := db.GetEngine(ctx) - if _, err := sess.ID(c.ID).AllCols().Update(c); err != nil { + sess := db.GetEngine(ctx).ID(c.ID).AllCols() + if c.Issue.NoAutoTime { + // update the DataBase + sess = sess.NoAutoTime().SetExpr("updated_unix", c.Issue.UpdatedUnix) + // the UpdatedUnix value of the Comment also has to be set, + // to return the adequate valuè + // see https://codeberg.org/forgejo/forgejo/pulls/764#issuecomment-1023801 + c.UpdatedUnix = c.Issue.UpdatedUnix + } + if _, err := sess.Update(c); err != nil { return err } if err := c.LoadIssue(ctx); err != nil { diff --git a/models/issues/issue.go b/models/issues/issue.go index f000f4c660..acd6eb15f9 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -125,6 +125,7 @@ type Issue struct { CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` ClosedUnix timeutil.TimeStamp `xorm:"INDEX"` + NoAutoTime bool `xorm:"-"` Attachments []*repo_model.Attachment `xorm:"-"` Comments CommentList `xorm:"-"` diff --git a/models/issues/issue_update.go b/models/issues/issue_update.go index 9607b21a67..f7f7518272 100644 --- a/models/issues/issue_update.go +++ b/models/issues/issue_update.go @@ -27,7 +27,12 @@ import ( // UpdateIssueCols updates cols of issue func UpdateIssueCols(ctx context.Context, issue *Issue, cols ...string) error { - if _, err := db.GetEngine(ctx).ID(issue.ID).Cols(cols...).Update(issue); err != nil { + sess := db.GetEngine(ctx).ID(issue.ID) + if issue.NoAutoTime { + cols = append(cols, []string{"updated_unix"}...) + sess.NoAutoTime() + } + if _, err := sess.Cols(cols...).Update(issue); err != nil { return err } return nil @@ -71,7 +76,11 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use } if issue.IsClosed { - issue.ClosedUnix = timeutil.TimeStampNow() + if issue.NoAutoTime { + issue.ClosedUnix = issue.UpdatedUnix + } else { + issue.ClosedUnix = timeutil.TimeStampNow() + } } else { issue.ClosedUnix = 0 } @@ -92,8 +101,14 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use // Update issue count of milestone if issue.MilestoneID > 0 { - if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { - return nil, err + if issue.NoAutoTime { + if err := UpdateMilestoneCountersWithDate(ctx, issue.MilestoneID, issue.UpdatedUnix); err != nil { + return nil, err + } + } else { + if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { + return nil, err + } } } @@ -259,8 +274,12 @@ func ChangeIssueContent(issue *Issue, doer *user_model.User, content string) (er return fmt.Errorf("UpdateIssueCols: %w", err) } + historyDate := timeutil.TimeStampNow() + if issue.NoAutoTime { + historyDate = issue.UpdatedUnix + } if err = SaveIssueContentHistory(ctx, doer.ID, issue.ID, 0, - timeutil.TimeStampNow(), issue.Content, false); err != nil { + historyDate, issue.Content, false); err != nil { return fmt.Errorf("SaveIssueContentHistory: %w", err) } @@ -449,10 +468,13 @@ func UpdateIssueByAPI(issue *Issue, doer *user_model.User) (statusChangeComment return nil, false, err } - if _, err := db.GetEngine(ctx).ID(issue.ID).Cols( - "name", "content", "milestone_id", "priority", - "deadline_unix", "updated_unix", "is_locked"). - Update(issue); err != nil { + sess := db.GetEngine(ctx).ID(issue.ID) + cols := []string{"name", "content", "milestone_id", "priority", "deadline_unix", "is_locked"} + if issue.NoAutoTime { + cols = append(cols, "updated_unix") + sess.NoAutoTime() + } + if _, err := sess.Cols(cols...).Update(issue); err != nil { return nil, false, err } @@ -498,7 +520,7 @@ func UpdateIssueDeadline(issue *Issue, deadlineUnix timeutil.TimeStamp, doer *us defer committer.Close() // Update the deadline - if err = UpdateIssueCols(ctx, &Issue{ID: issue.ID, DeadlineUnix: deadlineUnix}, "deadline_unix"); err != nil { + if err = UpdateIssueCols(ctx, &Issue{ID: issue.ID, DeadlineUnix: deadlineUnix, NoAutoTime: issue.NoAutoTime, UpdatedUnix: issue.UpdatedUnix}, "deadline_unix"); err != nil { return err } diff --git a/models/issues/issue_xref.go b/models/issues/issue_xref.go index a1086f9e81..d580c75fb8 100644 --- a/models/issues/issue_xref.go +++ b/models/issues/issue_xref.go @@ -110,6 +110,10 @@ func (issue *Issue) createCrossReferences(stdCtx context.Context, ctx *crossRefe if ctx.OrigComment != nil { refCommentID = ctx.OrigComment.ID } + if ctx.OrigIssue.NoAutoTime { + xref.Issue.NoAutoTime = true + xref.Issue.UpdatedUnix = ctx.OrigIssue.UpdatedUnix + } opts := &CreateCommentOptions{ Type: ctx.Type, Doer: ctx.Doer, diff --git a/models/issues/milestone.go b/models/issues/milestone.go index 1418e0869d..9d30a0f871 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -188,10 +188,9 @@ func updateMilestone(ctx context.Context, m *Milestone) error { return UpdateMilestoneCounters(ctx, m.ID) } -// UpdateMilestoneCounters calculates NumIssues, NumClosesIssues and Completeness -func UpdateMilestoneCounters(ctx context.Context, id int64) error { +func updateMilestoneCounters(ctx context.Context, id int64, noAutoTime bool, updatedUnix timeutil.TimeStamp) error { e := db.GetEngine(ctx) - _, err := e.ID(id). + sess := e.ID(id). SetExpr("num_issues", builder.Select("count(*)").From("issue").Where( builder.Eq{"milestone_id": id}, )). @@ -200,8 +199,11 @@ func UpdateMilestoneCounters(ctx context.Context, id int64) error { "milestone_id": id, "is_closed": true, }, - )). - Update(&Milestone{}) + )) + if noAutoTime { + sess.SetExpr("updated_unix", updatedUnix).NoAutoTime() + } + _, err := sess.Update(&Milestone{}) if err != nil { return err } @@ -211,6 +213,16 @@ func UpdateMilestoneCounters(ctx context.Context, id int64) error { return err } +// UpdateMilestoneCounters calculates NumIssues, NumClosesIssues and Completeness +func UpdateMilestoneCounters(ctx context.Context, id int64) error { + return updateMilestoneCounters(ctx, id, false, 0) +} + +// UpdateMilestoneCountersWithDate calculates NumIssues, NumClosesIssues and Completeness and set the UpdatedUnix date +func UpdateMilestoneCountersWithDate(ctx context.Context, id int64, updatedUnix timeutil.TimeStamp) error { + return updateMilestoneCounters(ctx, id, true, updatedUnix) +} + // ChangeMilestoneStatusByRepoIDAndID changes a milestone open/closed status if the milestone ID is in the repo. func ChangeMilestoneStatusByRepoIDAndID(repoID, milestoneID int64, isClosed bool) error { ctx, committer, err := db.TxContext(db.DefaultContext) diff --git a/models/repo/attachment.go b/models/repo/attachment.go index df3b9cd213..5e5a6ca0f8 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -28,6 +28,7 @@ type Attachment struct { Name string DownloadCount int64 `xorm:"DEFAULT 0"` Size int64 `xorm:"DEFAULT 0"` + NoAutoTime bool `xorm:"-"` CreatedUnix timeutil.TimeStamp `xorm:"created"` CustomDownloadURL string `xorm:"-"` } diff --git a/modules/structs/issue.go b/modules/structs/issue.go index 1aec5cc6b8..552496e652 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -110,6 +110,8 @@ type EditIssueOption struct { // swagger:strfmt date-time Deadline *time.Time `json:"due_date"` RemoveDeadline *bool `json:"unset_due_date"` + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` } // EditDeadlineOption options for creating a deadline diff --git a/modules/structs/issue_comment.go b/modules/structs/issue_comment.go index 9e8f5c4bf3..9ecb4a1789 100644 --- a/modules/structs/issue_comment.go +++ b/modules/structs/issue_comment.go @@ -28,12 +28,16 @@ type Comment struct { type CreateIssueCommentOption struct { // required:true Body string `json:"body" binding:"Required"` + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` } // EditIssueCommentOption options for editing a comment type EditIssueCommentOption struct { // required: true Body string `json:"body" binding:"Required"` + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` } // TimelineComment represents a timeline comment (comment of any type) on a commit or issue diff --git a/modules/structs/issue_label.go b/modules/structs/issue_label.go index bf68726d79..b64e375961 100644 --- a/modules/structs/issue_label.go +++ b/modules/structs/issue_label.go @@ -4,6 +4,10 @@ package structs +import ( + "time" +) + // Label a label to an issue or a pr // swagger:model type Label struct { @@ -45,10 +49,18 @@ type EditLabelOption struct { IsArchived *bool `json:"is_archived"` } +// DeleteLabelOption options for deleting a label +type DeleteLabelsOption struct { + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` +} + // IssueLabelsOption a collection of labels type IssueLabelsOption struct { // list of label IDs Labels []int64 `json:"labels"` + // swagger:strfmt date-time + Updated *time.Time `json:"updated_at"` } // LabelTemplate info of a Label template diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 9613bd610d..48ad691cc4 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1201,8 +1201,8 @@ func Routes() *web.Route { m.Combo("").Get(repo.ListIssueLabels). Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels). Put(reqToken(), bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels). - Delete(reqToken(), repo.ClearIssueLabels) - m.Delete("/{id}", reqToken(), repo.DeleteIssueLabel) + Delete(reqToken(), bind(api.DeleteLabelsOption{}), repo.ClearIssueLabels) + m.Delete("/{id}", reqToken(), bind(api.DeleteLabelsOption{}), repo.DeleteIssueLabel) }) m.Group("/times", func() { m.Combo(""). diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index a08fdf5940..9001f13af2 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -774,6 +774,12 @@ func EditIssue(ctx *context.APIContext) { return } + err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + oldTitle := issue.Title if len(form.Title) > 0 { issue.Title = form.Title diff --git a/routers/api/v1/repo/issue_attachment.go b/routers/api/v1/repo/issue_attachment.go index ad83c206d9..4e0949206e 100644 --- a/routers/api/v1/repo/issue_attachment.go +++ b/routers/api/v1/repo/issue_attachment.go @@ -5,6 +5,7 @@ package repo import ( "net/http" + "time" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" @@ -141,6 +142,11 @@ func CreateIssueAttachment(ctx *context.APIContext) { // description: name of the attachment // type: string // required: false + // - name: updated_at + // in: query + // description: time of the attachment's creation. This is a timestamp in RFC 3339 format + // type: string + // format: date-time // - name: attachment // in: formData // description: attachment to upload @@ -163,6 +169,20 @@ func CreateIssueAttachment(ctx *context.APIContext) { return } + updatedAt := ctx.Req.FormValue("updated_at") + if len(updatedAt) != 0 { + updated, err := time.Parse(time.RFC3339, updatedAt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "time.Parse", err) + return + } + err = issue_service.SetIssueUpdateDate(ctx, issue, &updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + } + // Get uploaded file from request file, header, err := ctx.Req.FormFile("attachment") if err != nil { @@ -177,10 +197,12 @@ func CreateIssueAttachment(ctx *context.APIContext) { } attachment, err := attachment.UploadAttachment(file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ - Name: filename, - UploaderID: ctx.Doer.ID, - RepoID: ctx.Repo.Repository.ID, - IssueID: issue.ID, + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + IssueID: issue.ID, + NoAutoTime: issue.NoAutoTime, + CreatedUnix: issue.UpdatedUnix, }) if err != nil { ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 1f8e16147f..a73f5c7a94 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -362,6 +362,12 @@ func CreateIssueComment(ctx *context.APIContext) { return } + err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil) if err != nil { ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err) @@ -554,6 +560,17 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption) return } + err = comment.LoadIssue(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + err = issue_service.SetIssueUpdateDate(ctx, comment.Issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + oldContent := comment.Content comment.Content = form.Body if err := issue_service.UpdateComment(ctx, comment, ctx.Doer, oldContent); err != nil { diff --git a/routers/api/v1/repo/issue_comment_attachment.go b/routers/api/v1/repo/issue_comment_attachment.go index 121e3f10e0..10ae4f9167 100644 --- a/routers/api/v1/repo/issue_comment_attachment.go +++ b/routers/api/v1/repo/issue_comment_attachment.go @@ -5,6 +5,7 @@ package repo import ( "net/http" + "time" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" @@ -144,6 +145,11 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { // description: name of the attachment // type: string // required: false + // - name: updated_at + // in: query + // description: time of the attachment's creation. This is a timestamp in RFC 3339 format + // type: string + // format: date-time // - name: attachment // in: formData // description: attachment to upload @@ -167,6 +173,25 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { return } + updatedAt := ctx.Req.FormValue("updated_at") + if len(updatedAt) != 0 { + updated, err := time.Parse(time.RFC3339, updatedAt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "time.Parse", err) + return + } + err = comment.LoadIssue(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "LoadIssue", err) + return + } + err = issue_service.SetIssueUpdateDate(ctx, comment.Issue, &updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + } + // Get uploaded file from request file, header, err := ctx.Req.FormFile("attachment") if err != nil { @@ -181,11 +206,13 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { } attachment, err := attachment.UploadAttachment(file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{ - Name: filename, - UploaderID: ctx.Doer.ID, - RepoID: ctx.Repo.Repository.ID, - IssueID: comment.IssueID, - CommentID: comment.ID, + Name: filename, + UploaderID: ctx.Doer.ID, + RepoID: ctx.Repo.Repository.ID, + IssueID: comment.IssueID, + CommentID: comment.ID, + NoAutoTime: comment.Issue.NoAutoTime, + CreatedUnix: comment.Issue.UpdatedUnix, }) if err != nil { ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) diff --git a/routers/api/v1/repo/issue_label.go b/routers/api/v1/repo/issue_label.go index a2814a03db..f54af94a2d 100644 --- a/routers/api/v1/repo/issue_label.go +++ b/routers/api/v1/repo/issue_label.go @@ -149,6 +149,10 @@ func DeleteIssueLabel(ctx *context.APIContext) { // type: integer // format: int64 // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/DeleteLabelsOption" // responses: // "204": // "$ref": "#/responses/empty" @@ -156,6 +160,7 @@ func DeleteIssueLabel(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "422": // "$ref": "#/responses/validationError" + form := web.GetForm(ctx).(*api.DeleteLabelsOption) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -172,6 +177,11 @@ func DeleteIssueLabel(ctx *context.APIContext) { return } + if err := issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer); err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + label, err := issues_model.GetLabelByID(ctx, ctx.ParamsInt64(":id")) if err != nil { if issues_model.IsErrLabelNotExist(err) { @@ -269,11 +279,16 @@ func ClearIssueLabels(ctx *context.APIContext) { // type: integer // format: int64 // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/DeleteLabelsOption" // responses: // "204": // "$ref": "#/responses/empty" // "403": // "$ref": "#/responses/forbidden" + form := web.GetForm(ctx).(*api.DeleteLabelsOption) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -290,6 +305,11 @@ func ClearIssueLabels(ctx *context.APIContext) { return } + if err := issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer); err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return + } + if err := issue_service.ClearLabels(issue, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "ClearLabels", err) return @@ -320,5 +340,11 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption) return nil, nil, nil } + err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer) + if err != nil { + ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err) + return nil, nil, err + } + return issue, labels, err } diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 8e7e6ec3df..4a2213cd36 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -47,6 +47,9 @@ type swaggerParameterBodies struct { // in:body IssueLabelsOption api.IssueLabelsOption + // in:body + DeleteLabelsOption api.DeleteLabelsOption + // in:body CreateKeyOption api.CreateKeyOption diff --git a/services/attachment/attachment.go b/services/attachment/attachment.go index 3e7df0cee0..4482e7deed 100644 --- a/services/attachment/attachment.go +++ b/services/attachment/attachment.go @@ -32,7 +32,12 @@ func NewAttachment(attach *repo_model.Attachment, file io.Reader, size int64) (* } attach.Size = size - return db.Insert(ctx, attach) + eng := db.GetEngine(ctx) + if attach.NoAutoTime { + eng.NoAutoTime() + } + _, err = eng.Insert(attach) + return err }) return attach, err diff --git a/services/issue/comments.go b/services/issue/comments.go index 2c5ef0f5dc..c89031a1d8 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -89,7 +89,11 @@ func UpdateComment(ctx context.Context, c *issues_model.Comment, doer *user_mode } if needsContentHistory { - err := issues_model.SaveIssueContentHistory(ctx, doer.ID, c.IssueID, c.ID, timeutil.TimeStampNow(), c.Content, false) + historyDate := timeutil.TimeStampNow() + if c.Issue.NoAutoTime { + historyDate = c.Issue.UpdatedUnix + } + err := issues_model.SaveIssueContentHistory(ctx, doer.ID, c.IssueID, c.ID, historyDate, c.Content, false) if err != nil { return err } diff --git a/services/issue/issue.go b/services/issue/issue.go index 35409589ef..2c1350e7fd 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -6,6 +6,7 @@ package issue import ( "context" "fmt" + "time" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" @@ -18,6 +19,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/timeutil" ) // NewIssue creates new issue with labels for repository. @@ -304,3 +306,40 @@ func deleteIssue(ctx context.Context, issue *issues_model.Issue) error { return committer.Commit() } + +// Set the UpdatedUnix date and the NoAutoTime field of an Issue if a non +// nil 'updated' time is provided +// +// In order to set a specific update time, the DB will be updated with +// NoAutoTime(). A 'NoAutoTime' boolean field in the Issue struct is used to +// propagate down to the DB update calls the will to apply autoupdate or not. +func SetIssueUpdateDate(ctx context.Context, issue *issues_model.Issue, updated *time.Time, doer *user_model.User) error { + issue.NoAutoTime = false + if updated == nil { + return nil + } + + if err := issue.LoadRepo(ctx); err != nil { + return err + } + + // Check if the poster is allowed to set an update date + perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) + if err != nil { + return err + } + if !perm.IsAdmin() && !perm.IsOwner() { + return fmt.Errorf("user needs to have admin or owner right") + } + + // A simple guard against potential inconsistent calls + updatedUnix := timeutil.TimeStamp(updated.Unix()) + if updatedUnix < issue.CreatedUnix || updatedUnix > timeutil.TimeStampNow() { + return fmt.Errorf("unallowed update date") + } + + issue.UpdatedUnix = updatedUnix + issue.NoAutoTime = true + + return nil +} diff --git a/services/issue/milestone.go b/services/issue/milestone.go index a9be8bd887..55e9cb69c1 100644 --- a/services/issue/milestone.go +++ b/services/issue/milestone.go @@ -13,6 +13,32 @@ import ( "code.gitea.io/gitea/modules/notification" ) +func updateMilestoneCounters(ctx context.Context, issue *issues_model.Issue, id int64) error { + if issue.NoAutoTime { + // We set the milestone's update date to the max of the + // milestone and issue update dates. + // Note: we can not call UpdateMilestoneCounters() if the + // milestone's update date is to be kept, because that function + // auto-updates the dates. + milestone, err := issues_model.GetMilestoneByRepoID(ctx, issue.RepoID, id) + if err != nil { + return fmt.Errorf("GetMilestoneByRepoID: %w", err) + } + updatedUnix := milestone.UpdatedUnix + if issue.UpdatedUnix > updatedUnix { + updatedUnix = issue.UpdatedUnix + } + if err := issues_model.UpdateMilestoneCountersWithDate(ctx, id, updatedUnix); err != nil { + return err + } + } else { + if err := issues_model.UpdateMilestoneCounters(ctx, id); err != nil { + return err + } + } + return nil +} + func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, oldMilestoneID int64) error { // Only check if milestone exists if we don't remove it. if issue.MilestoneID > 0 { @@ -30,13 +56,13 @@ func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *is } if oldMilestoneID > 0 { - if err := issues_model.UpdateMilestoneCounters(ctx, oldMilestoneID); err != nil { + if err := updateMilestoneCounters(ctx, issue, oldMilestoneID); err != nil { return err } } if issue.MilestoneID > 0 { - if err := issues_model.UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { + if err := updateMilestoneCounters(ctx, issue, issue.MilestoneID); err != nil { return err } } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 5e75f6f8b4..ac84ddd922 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6120,6 +6120,13 @@ "name": "name", "in": "query" }, + { + "type": "string", + "format": "date-time", + "description": "time of the attachment's creation. This is a timestamp in RFC 3339 format", + "name": "updated_at", + "in": "query" + }, { "type": "file", "description": "attachment to upload", @@ -6718,6 +6725,13 @@ "name": "name", "in": "query" }, + { + "type": "string", + "format": "date-time", + "description": "time of the attachment's creation. This is a timestamp in RFC 3339 format", + "name": "updated_at", + "in": "query" + }, { "type": "file", "description": "attachment to upload", @@ -7651,6 +7665,13 @@ "name": "index", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/DeleteLabelsOption" + } } ], "responses": { @@ -7703,6 +7724,13 @@ "name": "id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/DeleteLabelsOption" + } } ], "responses": { @@ -17026,6 +17054,11 @@ "body": { "type": "string", "x-go-name": "Body" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -17784,6 +17817,18 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "DeleteLabelsOption": { + "description": "DeleteLabelOption options for deleting a label", + "type": "object", + "properties": { + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "DeployKey": { "description": "DeployKey a deploy key", "type": "object", @@ -18037,6 +18082,11 @@ "body": { "type": "string", "x-go-name": "Body" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -18086,6 +18136,11 @@ "unset_due_date": { "type": "boolean", "x-go-name": "RemoveDeadline" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -19500,6 +19555,11 @@ "format": "int64" }, "x-go-name": "Labels" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" diff --git a/tests/integration/api_comment_attachment_test.go b/tests/integration/api_comment_attachment_test.go index e211376c3c..9761e06987 100644 --- a/tests/integration/api_comment_attachment_test.go +++ b/tests/integration/api_comment_attachment_test.go @@ -11,6 +11,7 @@ import ( "mime/multipart" "net/http" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -111,6 +112,82 @@ func TestAPICreateCommentAttachment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID}) } +func TestAPICreateCommentAttachmentAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets?token=%s", + repoOwner.Name, repo.Name, comment.ID, token) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiAttachment.Created) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + urlStr += fmt.Sprintf("&updated_at=%s", updatedAt.UTC().Format(time.RFC3339)) + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID}) + assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + func TestAPIEditCommentAttachment(t *testing.T) { defer tests.PrepareTestEnv(t)() diff --git a/tests/integration/api_comment_test.go b/tests/integration/api_comment_test.go index ee648210e5..339ffdbe0e 100644 --- a/tests/integration/api_comment_test.go +++ b/tests/integration/api_comment_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -110,6 +111,58 @@ func TestAPICreateComment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) } +func TestAPICreateCommentAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments?token=%s", + repoOwner.Name, repo.Name, issue.Index, token) + const commentBody = "Comment body" + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "body": commentBody, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(updatedComment.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{ + Body: commentBody, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + func TestAPIGetComment(t *testing.T) { defer tests.PrepareTestEnv(t)() @@ -161,6 +214,60 @@ func TestAPIEditComment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) } +func TestAPIEditCommentWithDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{}, + unittest.Cond("type = ?", issues_model.CommentTypeComment)) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d?token=%s", + repoOwner.Name, repo.Name, comment.ID, token) + const newCommentBody = "This is the new comment body" + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{ + "body": newCommentBody, + }) + resp := MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(updatedComment.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) + updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + + req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{ + Body: newCommentBody, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ)) + commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) + assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + func TestAPIDeleteComment(t *testing.T) { defer tests.PrepareTestEnv(t)() diff --git a/tests/integration/api_issue_attachment_test.go b/tests/integration/api_issue_attachment_test.go index 3b43ba2c41..2250646354 100644 --- a/tests/integration/api_issue_attachment_test.go +++ b/tests/integration/api_issue_attachment_test.go @@ -11,6 +11,7 @@ import ( "mime/multipart" "net/http" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" issues_model "code.gitea.io/gitea/models/issues" @@ -100,6 +101,82 @@ func TestAPICreateIssueAttachment(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) } +func TestAPICreateIssueAttachmentAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID}) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, repoOwner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets?token=%s", + repoOwner.Name, repo.Name, issue.Index, token) + + filename := "image.png" + buff := generateImg() + body := &bytes.Buffer{} + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiAttachment.Created) + assert.LessOrEqual(t, updatedSince, time.Minute) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index}) + updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + urlStr += fmt.Sprintf("&updated_at=%s", updatedAt.UTC().Format(time.RFC3339)) + + // Setup multi-part + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("attachment", filename) + assert.NoError(t, err) + _, err = io.Copy(part, &buff) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", urlStr, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + resp := session.MakeRequest(t, req, http.StatusCreated) + + apiAttachment := new(api.Attachment) + DecodeJSON(t, resp, &apiAttachment) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID}) + assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ)) + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + func TestAPIEditIssueAttachment(t *testing.T) { defer tests.PrepareTestEnv(t)() diff --git a/tests/integration/api_issue_label_test.go b/tests/integration/api_issue_label_test.go index d2d8af102b..a29c75727f 100644 --- a/tests/integration/api_issue_label_test.go +++ b/tests/integration/api_issue_label_test.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" "testing" + "time" auth_model "code.gitea.io/gitea/models/auth" issues_model "code.gitea.io/gitea/models/issues" @@ -15,6 +16,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" "github.com/stretchr/testify/assert" ) @@ -111,6 +113,49 @@ func TestAPIAddIssueLabels(t *testing.T) { unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: 2}) } +func TestAPIAddIssueLabelsAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels?token=%s", + owner.Name, repo.Name, issueBefore.Index, token) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []int64{1}, + }) + MakeRequest(t, req, http.StatusOK) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdatedDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []int64{2}, + Updated: &updatedAt, + }) + MakeRequest(t, req, http.StatusOK) + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + func TestAPIReplaceIssueLabels(t *testing.T) { assert.NoError(t, unittest.LoadFixtures()) diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index 5f4c1e6a47..8e81bb3cb6 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -213,6 +213,157 @@ func TestAPIEditIssue(t *testing.T) { assert.Equal(t, title, issueAfter.Title) } +func TestAPIEditIssueAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 13}) + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + assert.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // User2 is not owner, but can update the 'public' issue with auto date + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) + + body := "new content!" + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(apiIssue.Updated) + assert.LessOrEqual(t, updatedSince, time.Minute) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // User1 is admin, and so can update the issue without auto date + session := loginUser(t, "user1") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) + + body := "new content, with updated time" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusCreated) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + // dates are converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + assert.Equal(t, updatedAt.In(utcTZ), apiIssue.Updated.In(utcTZ)) + + issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID}) + assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) + + t.Run("WithoutPermission", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // User2 is not owner nor admin, and so can't update the issue without auto date + session := loginUser(t, "user2") + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) + + body := "new content, with updated time" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + Updated: &updatedAt, + }) + resp := MakeRequest(t, req, http.StatusForbidden) + var apiError api.APIError + DecodeJSON(t, resp, &apiError) + + assert.Equal(t, "user needs to have admin or owner right", apiError.Message) + }) +} + +func TestAPIEditIssueMilestoneAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}) + + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}) + assert.NoError(t, issueBefore.LoadAttributes(db.DefaultContext)) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d?token=%s", owner.Name, repoBefore.Name, issueBefore.Index, token) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + milestone := int64(1) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + }) + MakeRequest(t, req, http.StatusCreated) + + // the execution of the API call supposedly lasted less than one minute + milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + updatedSince := time.Since(milestoneAfter.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithPostUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Note: the updated_unix field of the test Milestones is set to NULL + // Hence, any date is higher than the Milestone's updated date + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + milestone := int64(2) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + Updated: &updatedAt, + }) + MakeRequest(t, req, http.StatusCreated) + + // the milestone date should be set to 'updatedAt' + // dates are converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + assert.Equal(t, updatedAt.In(utcTZ), milestoneAfter.UpdatedUnix.AsTime().In(utcTZ)) + }) + + t.Run("WithPastUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Note: This Milestone's updated_unix has been set to Now() by the first subtest + milestone := int64(1) + milestoneBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + Updated: &updatedAt, + }) + MakeRequest(t, req, http.StatusCreated) + + // the milestone date should not change + // dates are converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone}) + assert.Equal(t, milestoneAfter.UpdatedUnix.AsTime().In(utcTZ), milestoneBefore.UpdatedUnix.AsTime().In(utcTZ)) + }) +} + func TestAPISearchIssues(t *testing.T) { defer tests.PrepareTestEnv(t)()