diff --git a/models/issues/comment.go b/models/issues/comment.go index c63fcab894..d9acca7e48 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -823,6 +823,14 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, IsForcePush: opts.IsForcePush, Invalidated: opts.Invalidated, } + if opts.Issue.NoAutoTime { + // Preload the comment with the Issue containing the forced update + // date. This is needed to propagate those data in AddCrossReferences() + comment.Issue = opts.Issue + comment.CreatedUnix = opts.Issue.UpdatedUnix + comment.UpdatedUnix = opts.Issue.UpdatedUnix + e.NoAutoTime() + } if _, err = e.Insert(comment); err != nil { return nil, err } @@ -1105,12 +1113,21 @@ func UpdateComment(ctx context.Context, 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 { + if err := c.LoadIssue(ctx); err != nil { return err } - if err := c.LoadIssue(ctx); 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 value + // 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.AddCrossReferences(ctx, doer, true); err != nil { diff --git a/models/issues/issue.go b/models/issues/issue.go index 90aad10bb9..b3b1744969 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -126,6 +126,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 b258dc882c..78f4657c44 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(ctx context.Context, issue *Issue, doer *user_model.User 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(ctx context.Context, issue *Issue, doer *user_model.User) 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(ctx context.Context, issue *Issue, deadlineUnix timeuti 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 77ef53a013..9b1a447471 100644 --- a/models/issues/issue_xref.go +++ b/models/issues/issue_xref.go @@ -46,11 +46,15 @@ func neuterCrossReferences(ctx context.Context, issueID, commentID int64) error for i, c := range active { ids[i] = c.ID } - return neuterCrossReferencesIds(ctx, ids) + return neuterCrossReferencesIds(ctx, nil, ids) } -func neuterCrossReferencesIds(ctx context.Context, ids []int64) error { - _, err := db.GetEngine(ctx).In("id", ids).Cols("`ref_action`").Update(&Comment{RefAction: references.XRefActionNeutered}) +func neuterCrossReferencesIds(stdCtx context.Context, ctx *crossReferencesContext, ids []int64) error { + sess := db.GetEngine(stdCtx).In("id", ids).Cols("`ref_action`") + if ctx != nil && ctx.OrigIssue.NoAutoTime { + sess.SetExpr("updated_unix", ctx.OrigIssue.UpdatedUnix).NoAutoTime() + } + _, err := sess.Update(&Comment{RefAction: references.XRefActionNeutered}) return err } @@ -100,7 +104,7 @@ func (issue *Issue) createCrossReferences(stdCtx context.Context, ctx *crossRefe } } if len(ids) > 0 { - if err = neuterCrossReferencesIds(stdCtx, ids); err != nil { + if err = neuterCrossReferencesIds(stdCtx, ctx, ids); err != nil { return err } } @@ -110,6 +114,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 f663d42fe9..15987f2225 100644 --- a/models/issues/milestone.go +++ b/models/issues/milestone.go @@ -187,10 +187,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}, )). @@ -199,8 +198,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 } @@ -210,6 +212,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(ctx context.Context, repoID, milestoneID int64, isClosed bool) error { ctx, committer, err := db.TxContext(ctx) diff --git a/models/repo/attachment.go b/models/repo/attachment.go index 1a588398c1..64df6b166e 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 34eae69329..331dbc1f8e 100644 --- a/modules/structs/issue.go +++ b/modules/structs/issue.go @@ -111,6 +111,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 cb1803f7c6..e244a0e4f9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1337,8 +1337,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 0f76a4b4ff..ed7aee295e 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -802,6 +802,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 11d19b21ff..eaaaeaa9f9 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 @@ -165,6 +171,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 { @@ -179,10 +199,12 @@ func CreateIssueAttachment(ctx *context.APIContext) { } attachment, err := attachment.UploadAttachment(ctx, 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 4db2c68a79..a589354730 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -399,6 +399,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) @@ -607,6 +613,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 21e2f4dabd..4f4b88750e 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 @@ -169,6 +175,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 { @@ -183,11 +208,13 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) { } attachment, err := attachment.UploadAttachment(ctx, 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 c2f530956e..008ff5c699 100644 --- a/routers/api/v1/repo/issue_label.go +++ b/routers/api/v1/repo/issue_label.go @@ -151,6 +151,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" @@ -160,6 +164,7 @@ func DeleteIssueLabel(ctx *context.APIContext) { // "$ref": "#/responses/notFound" // "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 { @@ -176,6 +181,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) { @@ -275,6 +285,10 @@ func ClearIssueLabels(ctx *context.APIContext) { // type: integer // format: int64 // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/DeleteLabelsOption" // responses: // "204": // "$ref": "#/responses/empty" @@ -282,6 +296,7 @@ func ClearIssueLabels(ctx *context.APIContext) { // "$ref": "#/responses/forbidden" // "404": // "$ref": "#/responses/notFound" + form := web.GetForm(ctx).(*api.DeleteLabelsOption) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) if err != nil { @@ -298,6 +313,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(ctx, issue, ctx.Doer); err != nil { ctx.Error(http.StatusInternalServerError, "ClearLabels", err) return @@ -328,5 +348,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 6f7859df62..b5efbe916d 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 967332fd98..1bcd460e3c 100644 --- a/services/attachment/attachment.go +++ b/services/attachment/attachment.go @@ -32,7 +32,12 @@ func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.R } 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 8d8c575c14..8de085026e 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 b1f418c32e..b577fa189c 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" @@ -17,6 +18,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/timeutil" notify_service "code.gitea.io/gitea/services/notify" ) @@ -290,3 +292,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 ff645744a7..31490c7b03 100644 --- a/services/issue/milestone.go +++ b/services/issue/milestone.go @@ -13,6 +13,32 @@ import ( notify_service "code.gitea.io/gitea/services/notify" ) +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 403f241d72..1b56e84766 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6531,6 +6531,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", @@ -7150,6 +7157,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", @@ -8125,6 +8139,13 @@ "name": "index", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/DeleteLabelsOption" + } } ], "responses": { @@ -8180,6 +8201,13 @@ "name": "id", "in": "path", "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/DeleteLabelsOption" + } } ], "responses": { @@ -17924,6 +17952,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" @@ -18684,6 +18717,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", @@ -18941,6 +18986,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" @@ -18990,6 +19040,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" @@ -20404,6 +20459,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 e5e62a86b7..b6f3d3bc81 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" @@ -121,6 +122,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", + repoOwner.Name, repo.Name, comment.ID) + + 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).AddTokenAuth(token) + 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).AddTokenAuth(token) + 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 a9c5228a16..221a6ba5ce 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" @@ -15,6 +16,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/references" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/tests" @@ -110,6 +112,156 @@ 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", + repoOwner.Name, repo.Name, issue.Index) + 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, + }).AddTokenAuth(token) + 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, + }).AddTokenAuth(token) + 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 TestAPICommentXRefAutoDate(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) + 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) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // Create a comment mentioning issue #2 and check that a xref comment was added + // in issue #2 + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", + repoOwner.Name, repo.Name, issue.Index) + + commentBody := "mention #2" + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{ + Body: commentBody, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var createdComment api.Comment + DecodeJSON(t, resp, &createdComment) + + ref := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: createdComment.ID}) + assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type) + assert.Equal(t, references.XRefActionNone, ref.RefAction) + // the execution of the API call supposedly lasted less than one minute + updatedSince := time.Since(ref.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + + // Remove the mention to issue #2 and check that the xref was neutered + urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", + repoOwner.Name, repo.Name, createdComment.ID) + + newCommentBody := "no mention" + req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{ + Body: newCommentBody, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: updatedComment.ID}) + assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type) + assert.Equal(t, references.XRefActionNeutered, ref.RefAction) + // the execution of the API call supposedly lasted less than one minute + updatedSince = time.Since(ref.UpdatedUnix.AsTime()) + assert.LessOrEqual(t, updatedSince, time.Minute) + }) + + t.Run("WithUpdateDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + // dates will be converted into the same tz, in order to compare them + utcTZ, _ := time.LoadLocation("UTC") + + // Create a comment mentioning issue #2 and check that a xref comment was added + // in issue #2 + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", + repoOwner.Name, repo.Name, issue.Index) + + commentBody := "re-mention #2" + updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second) + req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{ + Body: commentBody, + Updated: &updatedAt, + }).AddTokenAuth(token) + resp := MakeRequest(t, req, http.StatusCreated) + var createdComment api.Comment + DecodeJSON(t, resp, &createdComment) + + ref := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: createdComment.ID}) + assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type) + assert.Equal(t, references.XRefActionNone, ref.RefAction) + assert.Equal(t, updatedAt.In(utcTZ), ref.UpdatedUnix.AsTimeInLocation(utcTZ)) + + // Remove the mention to issue #2 and check that the xref was neutered + urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d", + repoOwner.Name, repo.Name, createdComment.ID) + + newCommentBody := "no mention" + updatedAt = time.Now().Add(-time.Hour).Truncate(time.Second) + req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{ + Body: newCommentBody, + Updated: &updatedAt, + }).AddTokenAuth(token) + resp = MakeRequest(t, req, http.StatusOK) + var updatedComment api.Comment + DecodeJSON(t, resp, &updatedComment) + + ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: updatedComment.ID}) + assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type) + assert.Equal(t, references.XRefActionNeutered, ref.RefAction) + assert.Equal(t, updatedAt.In(utcTZ), ref.UpdatedUnix.AsTimeInLocation(utcTZ)) + }) +} + func TestAPIGetComment(t *testing.T) { defer tests.PrepareTestEnv(t)() @@ -212,6 +364,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", + repoOwner.Name, repo.Name, comment.ID) + 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, + }).AddTokenAuth(token) + 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, + }).AddTokenAuth(token) + 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 4b57d387d8..375fe9ced8 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" @@ -97,6 +98,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", + repoOwner.Name, repo.Name, issue.Index) + + 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).AddTokenAuth(token) + 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).AddTokenAuth(token) + 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 35c0718263..905e79902b 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" ) @@ -114,6 +116,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", + owner.Name, repo.Name, issueBefore.Index) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{ + Labels: []int64{1}, + }).AddTokenAuth(token) + 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, + }).AddTokenAuth(token) + 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 f025806868..f7035f8fd3 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", owner.Name, repoBefore.Name, issueBefore.Index) + + body := "new content!" + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Body: &body, + }).AddTokenAuth(token) + 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", owner.Name, repoBefore.Name, issueBefore.Index) + + 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, + }).AddTokenAuth(token) + 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", owner.Name, repoBefore.Name, issueBefore.Index) + + 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, + }).AddTokenAuth(token) + 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", owner.Name, repoBefore.Name, issueBefore.Index) + + t.Run("WithAutoDate", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + milestone := int64(1) + req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{ + Milestone: &milestone, + }).AddTokenAuth(token) + 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, + }).AddTokenAuth(token) + 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, + }).AddTokenAuth(token) + 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)()