[FEAT] allow setting the update date on issues and comments

This field adds the possibility to set the update date when modifying
an issue through the API.

A 'NoAutoDate' in-memory field is added in the Issue struct.
If the update_at field is set, NoAutoDate is set to true and the
Issue's UpdatedUnix field is filled.

That information is passed down to the functions that actually updates
the database, which have been modified to not auto update dates if
requested.

A guard is added to the 'EditIssue' API call, to checks that the
udpate_at date is between the issue's creation date and the current
date (to avoid 'malicious' changes). It also limits the new feature
to project's owners and admins.

(cherry picked from commit c524d33402)

Add a SetIssueUpdateDate() function in services/issue.go

That function is used by some API calls to set the NoAutoDate and
UpdatedUnix fields of an Issue if an updated_at date is provided.

(cherry picked from commit f061caa655)

Add an updated_at field to the API calls related to Issue's Labels.

The update date is applied to the issue's comment created to inform
about the modification of the issue's labels.

(cherry picked from commit ea36cf80f5)

Add an updated_at field to the API call for issue's attachment creation

The update date is applied to the issue's comment created to inform
about the modification of the issue's content, and is set as the
asset creation date.

(cherry picked from commit 96150971ca)

Checking Issue changes, with and without providing an updated_at date

Those unit tests are added:

- TestAPIEditIssueWithAutoDate
- TestAPIEditIssueWithNoAutoDate

- TestAPIAddIssueLabelsWithAutoDate
- TestAPIAddIssueLabelsWithNoAutoDate

- TestAPICreateIssueAttachmentWithAutoDate
- TestAPICreateIssueAttachmentWithNoAutoDate

(cherry picked from commit 4926a5d7a2)

Add an updated_at field to the API call for issue's comment creation

The update date is used as the comment creation date, and is applied to
the issue as the update creation date.

(cherry picked from commit 76c8faecdc)

Add an updated_at field to the API call for issue's comment edition

The update date is used as the comment update date, and is applied to
the issue as an update date.

(cherry picked from commit cf787ad7fd)

Add an updated_at field to the API call for comment's attachment creation

The update date is applied to the comment, and is set as the asset
creation date.

(cherry picked from commit 1e4ff424d3)

Checking Comment changes, with and without providing an updated_at date

Those unit tests are added:

- TestAPICreateCommentWithAutoDate
- TestAPICreateCommentWithNoAutoDate

- TestAPIEditCommentWithAutoDate
- TestAPIEditCommentWithNoAutoDate

- TestAPICreateCommentAttachmentWithAutoDate
- TestAPICreateCommentAttachmentWithNoAutoDate

(cherry picked from commit da932152f1)

Pettier code to set the update time of comments

Now uses sess.AllCols().NoAutoToime().SetExpr("updated_unix", ...)

XORM is smart enough to compose one single SQL UPDATE which all
columns + updated_unix.

(cherry picked from commit 1f6a42808d)

Issue edition: Keep the max of the milestone and issue update dates.

When editing an issue via the API, an updated_at date can be provided.
If the EditIssue call changes the issue's milestone, the milestone's
update date is to be changed accordingly, but only with a greater
value.

This ensures that a milestone's update date is the max of all issue's
update dates.

(cherry picked from commit 8f22ea182e)

Rewrite the 'AutoDate' tests using subtests

Also add a test to check the permissions to set a date, and a test
to check update dates on milestones.

The tests related to 'AutoDate' are:
- TestAPIEditIssueAutoDate
- TestAPIAddIssueLabelsAutoDate
- TestAPIEditIssueMilestoneAutoDate
- TestAPICreateIssueAttachmentAutoDate
- TestAPICreateCommentAutoDate
- TestAPIEditCommentWithDate
- TestAPICreateCommentAttachmentAutoDate

(cherry picked from commit 961fd13c55)
(cherry picked from commit d52f4eea44)
(cherry picked from commit 3540ea2a43)

Conflicts:
	services/issue/issue.go
	https://codeberg.org/forgejo/forgejo/pulls/1415
(cherry picked from commit 56720ade00)

Conflicts:
	routers/api/v1/repo/issue_label.go
	https://codeberg.org/forgejo/forgejo/pulls/1462
(cherry picked from commit 47c78927d6)
(cherry picked from commit 2030f3b965)
(cherry picked from commit f02aeb7698)

Conflicts:
	routers/api/v1/repo/issue_attachment.go
	routers/api/v1/repo/issue_comment_attachment.go
	https://codeberg.org/forgejo/forgejo/pulls/1575
(cherry picked from commit d072525b35)
(cherry picked from commit 8424d0ab3d)
(cherry picked from commit 5cc62caec7)
(cherry picked from commit d6300d5dcd)

[FEAT] allow setting the update date on issues and comments (squash) apply the 'update_at' value to the cross-ref comments (#1676)

[this is a follow-up to PR #764]

When a comment of issue A referencing issue B is added with a forced 'updated_at' date, that date has to be applied to the comment created in issue B.

-----

Comment:

While trying my 'RoundUp migration script', I found that this case was forgotten in PR #764 - my apologies...

I'll try to write a functional test, base on models/issues/issue_xref_test.go

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/1676
Co-authored-by: fluzz <fluzz@freedroid.org>
Co-committed-by: fluzz <fluzz@freedroid.org>
(cherry picked from commit ac4f727f63)
This commit is contained in:
Earl Warren 2023-10-09 19:07:47 +02:00
parent 64fcb25de3
commit 5110476ee9
No known key found for this signature in database
GPG key ID: 0579CB2928A78A00
26 changed files with 907 additions and 37 deletions

View file

@ -817,6 +817,14 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
IsForcePush: opts.IsForcePush, IsForcePush: opts.IsForcePush,
Invalidated: opts.Invalidated, 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 { if _, err = e.Insert(comment); err != nil {
return nil, err return nil, err
} }
@ -1095,12 +1103,21 @@ func UpdateComment(ctx context.Context, c *Comment, doer *user_model.User) error
return err return err
} }
defer committer.Close() 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 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 return err
} }
if err := c.AddCrossReferences(ctx, doer, true); err != nil { if err := c.AddCrossReferences(ctx, doer, true); err != nil {

View file

@ -126,6 +126,7 @@ type Issue struct {
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
ClosedUnix timeutil.TimeStamp `xorm:"INDEX"` ClosedUnix timeutil.TimeStamp `xorm:"INDEX"`
NoAutoTime bool `xorm:"-"`
Attachments []*repo_model.Attachment `xorm:"-"` Attachments []*repo_model.Attachment `xorm:"-"`
Comments CommentList `xorm:"-"` Comments CommentList `xorm:"-"`

View file

@ -27,7 +27,12 @@ import (
// UpdateIssueCols updates cols of issue // UpdateIssueCols updates cols of issue
func UpdateIssueCols(ctx context.Context, issue *Issue, cols ...string) error { 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 err
} }
return nil return nil
@ -71,7 +76,11 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use
} }
if issue.IsClosed { if issue.IsClosed {
issue.ClosedUnix = timeutil.TimeStampNow() if issue.NoAutoTime {
issue.ClosedUnix = issue.UpdatedUnix
} else {
issue.ClosedUnix = timeutil.TimeStampNow()
}
} else { } else {
issue.ClosedUnix = 0 issue.ClosedUnix = 0
} }
@ -92,8 +101,14 @@ func doChangeIssueStatus(ctx context.Context, issue *Issue, doer *user_model.Use
// Update issue count of milestone // Update issue count of milestone
if issue.MilestoneID > 0 { if issue.MilestoneID > 0 {
if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { if issue.NoAutoTime {
return nil, err 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) return fmt.Errorf("UpdateIssueCols: %w", err)
} }
historyDate := timeutil.TimeStampNow()
if issue.NoAutoTime {
historyDate = issue.UpdatedUnix
}
if err = SaveIssueContentHistory(ctx, doer.ID, issue.ID, 0, 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) 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 return nil, false, err
} }
if _, err := db.GetEngine(ctx).ID(issue.ID).Cols( sess := db.GetEngine(ctx).ID(issue.ID)
"name", "content", "milestone_id", "priority", cols := []string{"name", "content", "milestone_id", "priority", "deadline_unix", "is_locked"}
"deadline_unix", "updated_unix", "is_locked"). if issue.NoAutoTime {
Update(issue); err != nil { cols = append(cols, "updated_unix")
sess.NoAutoTime()
}
if _, err := sess.Cols(cols...).Update(issue); err != nil {
return nil, false, err return nil, false, err
} }
@ -498,7 +520,7 @@ func UpdateIssueDeadline(ctx context.Context, issue *Issue, deadlineUnix timeuti
defer committer.Close() defer committer.Close()
// Update the deadline // 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 return err
} }

View file

@ -46,11 +46,15 @@ func neuterCrossReferences(ctx context.Context, issueID, commentID int64) error
for i, c := range active { for i, c := range active {
ids[i] = c.ID ids[i] = c.ID
} }
return neuterCrossReferencesIds(ctx, ids) return neuterCrossReferencesIds(ctx, nil, ids)
} }
func neuterCrossReferencesIds(ctx context.Context, ids []int64) error { func neuterCrossReferencesIds(stdCtx context.Context, ctx *crossReferencesContext, ids []int64) error {
_, err := db.GetEngine(ctx).In("id", ids).Cols("`ref_action`").Update(&Comment{RefAction: references.XRefActionNeutered}) 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 return err
} }
@ -100,7 +104,7 @@ func (issue *Issue) createCrossReferences(stdCtx context.Context, ctx *crossRefe
} }
} }
if len(ids) > 0 { if len(ids) > 0 {
if err = neuterCrossReferencesIds(stdCtx, ids); err != nil { if err = neuterCrossReferencesIds(stdCtx, ctx, ids); err != nil {
return err return err
} }
} }
@ -110,6 +114,10 @@ func (issue *Issue) createCrossReferences(stdCtx context.Context, ctx *crossRefe
if ctx.OrigComment != nil { if ctx.OrigComment != nil {
refCommentID = ctx.OrigComment.ID refCommentID = ctx.OrigComment.ID
} }
if ctx.OrigIssue.NoAutoTime {
xref.Issue.NoAutoTime = true
xref.Issue.UpdatedUnix = ctx.OrigIssue.UpdatedUnix
}
opts := &CreateCommentOptions{ opts := &CreateCommentOptions{
Type: ctx.Type, Type: ctx.Type,
Doer: ctx.Doer, Doer: ctx.Doer,

View file

@ -187,10 +187,9 @@ func updateMilestone(ctx context.Context, m *Milestone) error {
return UpdateMilestoneCounters(ctx, m.ID) return UpdateMilestoneCounters(ctx, m.ID)
} }
// UpdateMilestoneCounters calculates NumIssues, NumClosesIssues and Completeness func updateMilestoneCounters(ctx context.Context, id int64, noAutoTime bool, updatedUnix timeutil.TimeStamp) error {
func UpdateMilestoneCounters(ctx context.Context, id int64) error {
e := db.GetEngine(ctx) e := db.GetEngine(ctx)
_, err := e.ID(id). sess := e.ID(id).
SetExpr("num_issues", builder.Select("count(*)").From("issue").Where( SetExpr("num_issues", builder.Select("count(*)").From("issue").Where(
builder.Eq{"milestone_id": id}, builder.Eq{"milestone_id": id},
)). )).
@ -199,8 +198,11 @@ func UpdateMilestoneCounters(ctx context.Context, id int64) error {
"milestone_id": id, "milestone_id": id,
"is_closed": true, "is_closed": true,
}, },
)). ))
Update(&Milestone{}) if noAutoTime {
sess.SetExpr("updated_unix", updatedUnix).NoAutoTime()
}
_, err := sess.Update(&Milestone{})
if err != nil { if err != nil {
return err return err
} }
@ -210,6 +212,16 @@ func UpdateMilestoneCounters(ctx context.Context, id int64) error {
return err 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. // 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 { func ChangeMilestoneStatusByRepoIDAndID(ctx context.Context, repoID, milestoneID int64, isClosed bool) error {
ctx, committer, err := db.TxContext(ctx) ctx, committer, err := db.TxContext(ctx)

View file

@ -28,6 +28,7 @@ type Attachment struct {
Name string Name string
DownloadCount int64 `xorm:"DEFAULT 0"` DownloadCount int64 `xorm:"DEFAULT 0"`
Size int64 `xorm:"DEFAULT 0"` Size int64 `xorm:"DEFAULT 0"`
NoAutoTime bool `xorm:"-"`
CreatedUnix timeutil.TimeStamp `xorm:"created"` CreatedUnix timeutil.TimeStamp `xorm:"created"`
CustomDownloadURL string `xorm:"-"` CustomDownloadURL string `xorm:"-"`
} }

View file

@ -110,6 +110,8 @@ type EditIssueOption struct {
// swagger:strfmt date-time // swagger:strfmt date-time
Deadline *time.Time `json:"due_date"` Deadline *time.Time `json:"due_date"`
RemoveDeadline *bool `json:"unset_due_date"` RemoveDeadline *bool `json:"unset_due_date"`
// swagger:strfmt date-time
Updated *time.Time `json:"updated_at"`
} }
// EditDeadlineOption options for creating a deadline // EditDeadlineOption options for creating a deadline

View file

@ -28,12 +28,16 @@ type Comment struct {
type CreateIssueCommentOption struct { type CreateIssueCommentOption struct {
// required:true // required:true
Body string `json:"body" binding:"Required"` Body string `json:"body" binding:"Required"`
// swagger:strfmt date-time
Updated *time.Time `json:"updated_at"`
} }
// EditIssueCommentOption options for editing a comment // EditIssueCommentOption options for editing a comment
type EditIssueCommentOption struct { type EditIssueCommentOption struct {
// required: true // required: true
Body string `json:"body" binding:"Required"` 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 // TimelineComment represents a timeline comment (comment of any type) on a commit or issue

View file

@ -4,6 +4,10 @@
package structs package structs
import (
"time"
)
// Label a label to an issue or a pr // Label a label to an issue or a pr
// swagger:model // swagger:model
type Label struct { type Label struct {
@ -45,10 +49,18 @@ type EditLabelOption struct {
IsArchived *bool `json:"is_archived"` 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 // IssueLabelsOption a collection of labels
type IssueLabelsOption struct { type IssueLabelsOption struct {
// list of label IDs // list of label IDs
Labels []int64 `json:"labels"` Labels []int64 `json:"labels"`
// swagger:strfmt date-time
Updated *time.Time `json:"updated_at"`
} }
// LabelTemplate info of a Label template // LabelTemplate info of a Label template

View file

@ -1298,8 +1298,8 @@ func Routes() *web.Route {
m.Combo("").Get(repo.ListIssueLabels). m.Combo("").Get(repo.ListIssueLabels).
Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels). Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels).
Put(reqToken(), bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels). Put(reqToken(), bind(api.IssueLabelsOption{}), repo.ReplaceIssueLabels).
Delete(reqToken(), repo.ClearIssueLabels) Delete(reqToken(), bind(api.DeleteLabelsOption{}), repo.ClearIssueLabels)
m.Delete("/{id}", reqToken(), repo.DeleteIssueLabel) m.Delete("/{id}", reqToken(), bind(api.DeleteLabelsOption{}), repo.DeleteIssueLabel)
}) })
m.Group("/times", func() { m.Group("/times", func() {
m.Combo(""). m.Combo("").

View file

@ -780,6 +780,12 @@ func EditIssue(ctx *context.APIContext) {
return return
} }
err = issue_service.SetIssueUpdateDate(ctx, issue, form.Updated, ctx.Doer)
if err != nil {
ctx.Error(http.StatusForbidden, "SetIssueUpdateDate", err)
return
}
oldTitle := issue.Title oldTitle := issue.Title
if len(form.Title) > 0 { if len(form.Title) > 0 {
issue.Title = form.Title issue.Title = form.Title

View file

@ -5,6 +5,7 @@ package repo
import ( import (
"net/http" "net/http"
"time"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
@ -141,6 +142,11 @@ func CreateIssueAttachment(ctx *context.APIContext) {
// description: name of the attachment // description: name of the attachment
// type: string // type: string
// required: false // 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 // - name: attachment
// in: formData // in: formData
// description: attachment to upload // description: attachment to upload
@ -165,6 +171,20 @@ func CreateIssueAttachment(ctx *context.APIContext) {
return 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 // Get uploaded file from request
file, header, err := ctx.Req.FormFile("attachment") file, header, err := ctx.Req.FormFile("attachment")
if err != nil { 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{ attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{
Name: filename, Name: filename,
UploaderID: ctx.Doer.ID, UploaderID: ctx.Doer.ID,
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
IssueID: issue.ID, IssueID: issue.ID,
NoAutoTime: issue.NoAutoTime,
CreatedUnix: issue.UpdatedUnix,
}) })
if err != nil { if err != nil {
ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) ctx.Error(http.StatusInternalServerError, "UploadAttachment", err)

View file

@ -372,6 +372,12 @@ func CreateIssueComment(ctx *context.APIContext) {
return 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) comment, err := issue_service.CreateIssueComment(ctx, ctx.Doer, ctx.Repo.Repository, issue, form.Body, nil)
if err != nil { if err != nil {
ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err) ctx.Error(http.StatusInternalServerError, "CreateIssueComment", err)
@ -565,6 +571,17 @@ func editIssueComment(ctx *context.APIContext, form api.EditIssueCommentOption)
return 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 oldContent := comment.Content
comment.Content = form.Body comment.Content = form.Body
if err := issue_service.UpdateComment(ctx, comment, ctx.Doer, oldContent); err != nil { if err := issue_service.UpdateComment(ctx, comment, ctx.Doer, oldContent); err != nil {

View file

@ -5,6 +5,7 @@ package repo
import ( import (
"net/http" "net/http"
"time"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
@ -144,6 +145,11 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) {
// description: name of the attachment // description: name of the attachment
// type: string // type: string
// required: false // 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 // - name: attachment
// in: formData // in: formData
// description: attachment to upload // description: attachment to upload
@ -169,6 +175,25 @@ func CreateIssueCommentAttachment(ctx *context.APIContext) {
return 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 // Get uploaded file from request
file, header, err := ctx.Req.FormFile("attachment") file, header, err := ctx.Req.FormFile("attachment")
if err != nil { 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{ attachment, err := attachment.UploadAttachment(ctx, file, setting.Attachment.AllowedTypes, header.Size, &repo_model.Attachment{
Name: filename, Name: filename,
UploaderID: ctx.Doer.ID, UploaderID: ctx.Doer.ID,
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
IssueID: comment.IssueID, IssueID: comment.IssueID,
CommentID: comment.ID, CommentID: comment.ID,
NoAutoTime: comment.Issue.NoAutoTime,
CreatedUnix: comment.Issue.UpdatedUnix,
}) })
if err != nil { if err != nil {
ctx.Error(http.StatusInternalServerError, "UploadAttachment", err) ctx.Error(http.StatusInternalServerError, "UploadAttachment", err)

View file

@ -151,6 +151,10 @@ func DeleteIssueLabel(ctx *context.APIContext) {
// type: integer // type: integer
// format: int64 // format: int64
// required: true // required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/DeleteLabelsOption"
// responses: // responses:
// "204": // "204":
// "$ref": "#/responses/empty" // "$ref": "#/responses/empty"
@ -160,6 +164,7 @@ func DeleteIssueLabel(ctx *context.APIContext) {
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
// "422": // "422":
// "$ref": "#/responses/validationError" // "$ref": "#/responses/validationError"
form := web.GetForm(ctx).(*api.DeleteLabelsOption)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
if err != nil { if err != nil {
@ -176,6 +181,11 @@ func DeleteIssueLabel(ctx *context.APIContext) {
return 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")) label, err := issues_model.GetLabelByID(ctx, ctx.ParamsInt64(":id"))
if err != nil { if err != nil {
if issues_model.IsErrLabelNotExist(err) { if issues_model.IsErrLabelNotExist(err) {
@ -275,6 +285,10 @@ func ClearIssueLabels(ctx *context.APIContext) {
// type: integer // type: integer
// format: int64 // format: int64
// required: true // required: true
// - name: body
// in: body
// schema:
// "$ref": "#/definitions/DeleteLabelsOption"
// responses: // responses:
// "204": // "204":
// "$ref": "#/responses/empty" // "$ref": "#/responses/empty"
@ -282,6 +296,7 @@ func ClearIssueLabels(ctx *context.APIContext) {
// "$ref": "#/responses/forbidden" // "$ref": "#/responses/forbidden"
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
form := web.GetForm(ctx).(*api.DeleteLabelsOption)
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
if err != nil { if err != nil {
@ -298,6 +313,11 @@ func ClearIssueLabels(ctx *context.APIContext) {
return 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 { if err := issue_service.ClearLabels(ctx, issue, ctx.Doer); err != nil {
ctx.Error(http.StatusInternalServerError, "ClearLabels", err) ctx.Error(http.StatusInternalServerError, "ClearLabels", err)
return return
@ -328,5 +348,11 @@ func prepareForReplaceOrAdd(ctx *context.APIContext, form api.IssueLabelsOption)
return nil, nil, nil 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 return issue, labels, err
} }

View file

@ -47,6 +47,9 @@ type swaggerParameterBodies struct {
// in:body // in:body
IssueLabelsOption api.IssueLabelsOption IssueLabelsOption api.IssueLabelsOption
// in:body
DeleteLabelsOption api.DeleteLabelsOption
// in:body // in:body
CreateKeyOption api.CreateKeyOption CreateKeyOption api.CreateKeyOption

View file

@ -32,7 +32,12 @@ func NewAttachment(ctx context.Context, attach *repo_model.Attachment, file io.R
} }
attach.Size = size 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 return attach, err

View file

@ -89,7 +89,11 @@ func UpdateComment(ctx context.Context, c *issues_model.Comment, doer *user_mode
} }
if needsContentHistory { 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 { if err != nil {
return err return err
} }

View file

@ -6,6 +6,7 @@ package issue
import ( import (
"context" "context"
"fmt" "fmt"
"time"
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@ -17,6 +18,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/timeutil"
notify_service "code.gitea.io/gitea/services/notify" 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() 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
}

View file

@ -13,6 +13,32 @@ import (
notify_service "code.gitea.io/gitea/services/notify" 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 { 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. // Only check if milestone exists if we don't remove it.
if issue.MilestoneID > 0 { if issue.MilestoneID > 0 {
@ -30,13 +56,13 @@ func changeMilestoneAssign(ctx context.Context, doer *user_model.User, issue *is
} }
if oldMilestoneID > 0 { if oldMilestoneID > 0 {
if err := issues_model.UpdateMilestoneCounters(ctx, oldMilestoneID); err != nil { if err := updateMilestoneCounters(ctx, issue, oldMilestoneID); err != nil {
return err return err
} }
} }
if issue.MilestoneID > 0 { 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 return err
} }
} }

View file

@ -6485,6 +6485,13 @@
"name": "name", "name": "name",
"in": "query" "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", "type": "file",
"description": "attachment to upload", "description": "attachment to upload",
@ -7104,6 +7111,13 @@
"name": "name", "name": "name",
"in": "query" "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", "type": "file",
"description": "attachment to upload", "description": "attachment to upload",
@ -8079,6 +8093,13 @@
"name": "index", "name": "index",
"in": "path", "in": "path",
"required": true "required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/DeleteLabelsOption"
}
} }
], ],
"responses": { "responses": {
@ -8134,6 +8155,13 @@
"name": "id", "name": "id",
"in": "path", "in": "path",
"required": true "required": true
},
{
"name": "body",
"in": "body",
"schema": {
"$ref": "#/definitions/DeleteLabelsOption"
}
} }
], ],
"responses": { "responses": {
@ -17826,6 +17854,11 @@
"body": { "body": {
"type": "string", "type": "string",
"x-go-name": "Body" "x-go-name": "Body"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-go-name": "Updated"
} }
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
@ -18578,6 +18611,18 @@
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "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": { "DeployKey": {
"description": "DeployKey a deploy key", "description": "DeployKey a deploy key",
"type": "object", "type": "object",
@ -18831,6 +18876,11 @@
"body": { "body": {
"type": "string", "type": "string",
"x-go-name": "Body" "x-go-name": "Body"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-go-name": "Updated"
} }
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
@ -18880,6 +18930,11 @@
"unset_due_date": { "unset_due_date": {
"type": "boolean", "type": "boolean",
"x-go-name": "RemoveDeadline" "x-go-name": "RemoveDeadline"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-go-name": "Updated"
} }
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"
@ -20294,6 +20349,11 @@
"format": "int64" "format": "int64"
}, },
"x-go-name": "Labels" "x-go-name": "Labels"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-go-name": "Updated"
} }
}, },
"x-go-package": "code.gitea.io/gitea/modules/structs" "x-go-package": "code.gitea.io/gitea/modules/structs"

View file

@ -11,6 +11,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"testing" "testing"
"time"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "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}) 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) { func TestAPIEditCommentAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()

View file

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"testing" "testing"
"time"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
@ -15,6 +16,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/references"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/services/convert" "code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/tests" "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}) 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 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?token=%s",
repoOwner.Name, repo.Name, issue.Index, token)
commentBody := "mention #2"
req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{
Body: commentBody,
})
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?token=%s",
repoOwner.Name, repo.Name, createdComment.ID, token)
newCommentBody := "no mention"
req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{
Body: newCommentBody,
})
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?token=%s",
repoOwner.Name, repo.Name, issue.Index, token)
commentBody := "re-mention #2"
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 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?token=%s",
repoOwner.Name, repo.Name, createdComment.ID, token)
newCommentBody := "no mention"
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)
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) { func TestAPIGetComment(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
@ -161,6 +313,60 @@ func TestAPIEditComment(t *testing.T) {
unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody}) 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) { func TestAPIDeleteComment(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()

View file

@ -11,6 +11,7 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"testing" "testing"
"time"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues" 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}) 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) { func TestAPIEditIssueAttachment(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()

View file

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
"time"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
@ -15,6 +16,7 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs" api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "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}) 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) { func TestAPIReplaceIssueLabels(t *testing.T) {
assert.NoError(t, unittest.LoadFixtures()) assert.NoError(t, unittest.LoadFixtures())

View file

@ -213,6 +213,157 @@ func TestAPIEditIssue(t *testing.T) {
assert.Equal(t, title, issueAfter.Title) 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) { func TestAPISearchIssues(t *testing.T) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()