fix(api): normalize all API due dates to end of day (#38677)

Due dates set through the API were stored inconsistently. The create
endpoints kept the raw timestamp, issue and pull request edits used end
of day in the client's offset, and only the deadline and milestone edit
endpoints normalized to end of day in the server's UI timezone like the
web UI does. All API due dates now go through
`ParseAPIDeadlineToEndOfDay`.

Editing a pull request with `unset_due_date: false` and no `due_date`
dereferenced a nil pointer and returned 500. It now shares the issue
edit logic and returns 400.

Related to https://github.com/go-gitea/gitea/issues/37620, which is
about the web sidebar and isn't fixed here.

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
okxint
2026-10-03 17:29:43 +05:30
committed by GitHub
parent ad38b60983
commit ab1979bcfb
8 changed files with 58 additions and 70 deletions
+30 -35
View File
@@ -22,7 +22,6 @@ import (
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/api/v1/utils"
@@ -568,25 +567,20 @@ func CreateIssue(ctx *context.APIContext) {
// "$ref": "#/responses/repoArchivedError"
form := web.GetForm[*api.CreateIssueOption](ctx)
var deadlineUnix timeutil.TimeStamp
if form.Deadline != nil && ctx.Repo.Permission.CanWrite(unit.TypeIssues) {
deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix())
}
issue := &issues_model.Issue{
RepoID: ctx.Repo.Repository.ID,
Repo: ctx.Repo.Repository,
Title: form.Title,
PosterID: ctx.Doer.ID,
Poster: ctx.Doer,
Content: form.Body,
Ref: form.Ref,
DeadlineUnix: deadlineUnix,
RepoID: ctx.Repo.Repository.ID,
Repo: ctx.Repo.Repository,
Title: form.Title,
PosterID: ctx.Doer.ID,
Poster: ctx.Doer,
Content: form.Body,
Ref: form.Ref,
}
assigneeIDs := make([]int64, 0)
var err error
if ctx.Repo.Permission.CanWrite(unit.TypeIssues) {
issue.DeadlineUnix = common.ParseAPIDeadlineToEndOfDay(form.Deadline)
issue.MilestoneID = form.Milestone
assigneeIDs, err = issues_model.MakeIDsFromAPIAssigneesToAdd(ctx, form.Assignee, form.Assignees)
if err != nil {
@@ -760,26 +754,8 @@ func EditIssue(ctx *context.APIContext) {
}
// Update or remove the deadline, only if set and allowed
if (form.Deadline != nil || form.RemoveDeadline != nil) && canWrite {
var deadlineUnix timeutil.TimeStamp
if form.RemoveDeadline == nil || !*form.RemoveDeadline {
if form.Deadline == nil {
ctx.APIError(http.StatusBadRequest, "The due_date cannot be empty")
return
}
if !form.Deadline.IsZero() {
deadline := time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(),
23, 59, 59, 0, form.Deadline.Location())
deadlineUnix = timeutil.TimeStamp(deadline.Unix())
}
}
if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
issue.DeadlineUnix = deadlineUnix
if canWrite && !editIssueDeadline(ctx, issue, form.Deadline, form.RemoveDeadline) {
return
}
// Add/delete assignees
@@ -911,6 +887,25 @@ func DeleteIssue(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent)
}
func editIssueDeadline(ctx *context.APIContext, issue *issues_model.Issue, deadline *time.Time, removeDeadline *bool) bool {
if deadline == nil && removeDeadline == nil {
return true
}
if removeDeadline != nil && *removeDeadline {
deadline = nil
} else if deadline == nil {
ctx.APIError(http.StatusBadRequest, "The due_date cannot be empty")
return false
}
deadlineUnix := common.ParseAPIDeadlineToEndOfDay(deadline)
if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return false
}
issue.DeadlineUnix = deadlineUnix
return true
}
// UpdateIssueDeadline updates an issue deadline
func UpdateIssueDeadline(ctx *context.APIContext) {
// swagger:operation POST /repos/{owner}/{repo}/issues/{index}/deadline issue issueEditIssueDeadline
@@ -964,7 +959,7 @@ func UpdateIssueDeadline(ctx *context.APIContext) {
return
}
deadlineUnix, _ := common.ParseAPIDeadlineToEndOfDay(form.Deadline)
deadlineUnix := common.ParseAPIDeadlineToEndOfDay(form.Deadline)
if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
+2 -7
View File
@@ -149,16 +149,11 @@ func CreateMilestone(ctx *context.APIContext) {
// "$ref": "#/responses/notFound"
form := web.GetForm[*api.CreateMilestoneOption](ctx)
var deadlineUnix int64
if form.Deadline != nil {
deadlineUnix = form.Deadline.Unix()
}
milestone := &issues_model.Milestone{
RepoID: ctx.Repo.Repository.ID,
Name: form.Title,
Content: form.Description,
DeadlineUnix: timeutil.TimeStamp(deadlineUnix),
DeadlineUnix: common.ParseAPIDeadlineToEndOfDay(form.Deadline),
}
if form.State == "closed" {
@@ -219,7 +214,7 @@ func EditMilestone(ctx *context.APIContext) {
if form.Description != nil {
milestone.Content = *form.Description
}
milestone.DeadlineUnix, _ = common.ParseAPIDeadlineToEndOfDay(form.Deadline)
milestone.DeadlineUnix = common.ParseAPIDeadlineToEndOfDay(form.Deadline)
oldIsClosed := milestone.IsClosed
if form.State != nil {
+5 -21
View File
@@ -10,7 +10,6 @@ import (
"net/http"
"strconv"
"strings"
"time"
git_model "gitea.dev/models/git"
issues_model "gitea.dev/models/issues"
@@ -26,7 +25,6 @@ import (
"gitea.dev/modules/optional"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
"gitea.dev/modules/web"
"gitea.dev/routers/api/v1/utils"
@@ -491,11 +489,6 @@ func CreatePullRequest(ctx *context.APIContext) {
milestoneID = milestone.ID
}
var deadlineUnix timeutil.TimeStamp
if form.Deadline != nil {
deadlineUnix = timeutil.TimeStamp(form.Deadline.Unix())
}
unitPullRequest, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypePullRequests)
if err != nil {
ctx.APIErrorInternal(err)
@@ -510,7 +503,7 @@ func CreatePullRequest(ctx *context.APIContext) {
MilestoneID: milestoneID,
IsPull: true,
Content: form.Body,
DeadlineUnix: deadlineUnix,
DeadlineUnix: common.ParseAPIDeadlineToEndOfDay(form.Deadline),
}
pr := &issues_model.PullRequest{
HeadRepoID: compareResult.HeadRepo.ID,
@@ -616,6 +609,8 @@ func EditPullRequest(ctx *context.APIContext) {
// responses:
// "201":
// "$ref": "#/responses/PullRequest"
// "400":
// "$ref": "#/responses/error"
// "403":
// "$ref": "#/responses/forbidden"
// "404":
@@ -690,19 +685,8 @@ func EditPullRequest(ctx *context.APIContext) {
}
// Update or remove deadline if set
if form.Deadline != nil || form.RemoveDeadline != nil {
var deadlineUnix timeutil.TimeStamp
if (form.RemoveDeadline == nil || !*form.RemoveDeadline) && !form.Deadline.IsZero() {
deadline := time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(),
23, 59, 59, 0, form.Deadline.Location())
deadlineUnix = timeutil.TimeStamp(deadline.Unix())
}
if err := issues_model.UpdateIssueDeadline(ctx, issue, deadlineUnix, ctx.Doer); err != nil {
ctx.APIErrorInternal(err)
return
}
issue.DeadlineUnix = deadlineUnix
if !editIssueDeadline(ctx, issue, form.Deadline, form.RemoveDeadline) {
return
}
// Add/delete assignees
+3 -4
View File
@@ -22,10 +22,9 @@ func ParseDeadlineDateToEndOfDay(date string) (timeutil.TimeStamp, error) {
return timeutil.TimeStamp(deadline.Unix()), nil
}
func ParseAPIDeadlineToEndOfDay(t *time.Time) (timeutil.TimeStamp, error) {
func ParseAPIDeadlineToEndOfDay(t *time.Time) timeutil.TimeStamp {
if t == nil || t.IsZero() || t.Unix() == 0 {
return 0, nil
return 0
}
deadline := time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, setting.DefaultUILocation)
return timeutil.TimeStamp(deadline.Unix()), nil
return timeutil.TimeStamp(time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, setting.DefaultUILocation).Unix())
}
+3
View File
@@ -29212,6 +29212,9 @@
"201": {
"$ref": "#/components/responses/PullRequest"
},
"400": {
"$ref": "#/components/responses/error"
},
"403": {
"$ref": "#/components/responses/forbidden"
},
+3
View File
@@ -16853,6 +16853,9 @@
"201": {
"$ref": "#/responses/PullRequest"
},
"400": {
"$ref": "#/responses/error"
},
"403": {
"$ref": "#/responses/forbidden"
},
+2
View File
@@ -126,11 +126,13 @@ func testAPICreateIssue(t *testing.T) {
Body: body,
Title: title,
Assignee: owner.Name,
Deadline: new(time.Date(2026, 5, 11, 12, 0, 0, 0, time.FixedZone("", -10*3600))),
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
apiIssue := DecodeJSON(t, resp, &api.Issue{})
assert.Equal(t, body, apiIssue.Body)
assert.Equal(t, title, apiIssue.Title)
assert.Equal(t, time.Date(2026, 5, 11, 23, 59, 59, 0, setting.DefaultUILocation).Unix(), apiIssue.Deadline.Unix())
unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{
RepoID: repoBefore.ID,
+10 -3
View File
@@ -449,13 +449,15 @@ func TestAPIEditPull(t *testing.T) {
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
title := "create a success pr"
req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), &api.CreatePullRequestOption{
Head: "develop",
Base: "master",
Title: title,
Head: "develop",
Base: "master",
Title: title,
Deadline: new(time.Date(2026, 5, 11, 12, 0, 0, 0, time.FixedZone("", -10*3600))),
}).AddTokenAuth(token)
resp := MakeRequest(t, req, http.StatusCreated)
apiPull := DecodeJSON(t, resp, &api.PullRequest{})
assert.Equal(t, "master", apiPull.Base.Name)
assert.Equal(t, time.Date(2026, 5, 11, 23, 59, 59, 0, setting.DefaultUILocation).Unix(), apiPull.Deadline.Unix())
newTitle := "edit a this pr"
newBody := "edited body"
@@ -480,6 +482,11 @@ func TestAPIEditPull(t *testing.T) {
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusNotFound)
req = NewRequestWithJSON(t, http.MethodPatch, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner10.Name, repo10.Name, pull.Index), &api.EditPullRequestOption{
RemoveDeadline: new(false),
}).AddTokenAuth(token)
MakeRequest(t, req, http.StatusBadRequest)
t.Run("PullContentVersion", func(t *testing.T) {
testAPIPullContentVersion(t, pull.ID)
})