From ab1979bcfb0dd5522931a3425e14a1eda4332872 Mon Sep 17 00:00:00 2001 From: okxint <130782884+okxint@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:29:43 +0530 Subject: [PATCH] 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 --- routers/api/v1/repo/issue.go | 65 +++++++++----------- routers/api/v1/repo/milestone.go | 9 +-- routers/api/v1/repo/pull.go | 26 ++------ routers/common/deadline.go | 7 +-- templates/swagger/v1-openapi3.generated.json | 3 + templates/swagger/v1-swagger.generated.json | 3 + tests/integration/api_issue_test.go | 2 + tests/integration/api_pull_test.go | 13 +++- 8 files changed, 58 insertions(+), 70 deletions(-) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index c3d9dfac1d8..a8a6bb1ddf2 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -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 diff --git a/routers/api/v1/repo/milestone.go b/routers/api/v1/repo/milestone.go index 2b19d43b889..41a7361e3ca 100644 --- a/routers/api/v1/repo/milestone.go +++ b/routers/api/v1/repo/milestone.go @@ -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 { diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go index 0063287f77f..4cda175700d 100644 --- a/routers/api/v1/repo/pull.go +++ b/routers/api/v1/repo/pull.go @@ -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 diff --git a/routers/common/deadline.go b/routers/common/deadline.go index b8a1449a687..43b30462835 100644 --- a/routers/common/deadline.go +++ b/routers/common/deadline.go @@ -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()) } diff --git a/templates/swagger/v1-openapi3.generated.json b/templates/swagger/v1-openapi3.generated.json index b92a5a134c6..46fcfccaeab 100644 --- a/templates/swagger/v1-openapi3.generated.json +++ b/templates/swagger/v1-openapi3.generated.json @@ -29212,6 +29212,9 @@ "201": { "$ref": "#/components/responses/PullRequest" }, + "400": { + "$ref": "#/components/responses/error" + }, "403": { "$ref": "#/components/responses/forbidden" }, diff --git a/templates/swagger/v1-swagger.generated.json b/templates/swagger/v1-swagger.generated.json index 7ef18edf193..7ffc1ebd418 100644 --- a/templates/swagger/v1-swagger.generated.json +++ b/templates/swagger/v1-swagger.generated.json @@ -16853,6 +16853,9 @@ "201": { "$ref": "#/responses/PullRequest" }, + "400": { + "$ref": "#/responses/error" + }, "403": { "$ref": "#/responses/forbidden" }, diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go index b77775d1730..69c5c412cfe 100644 --- a/tests/integration/api_issue_test.go +++ b/tests/integration/api_issue_test.go @@ -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, diff --git a/tests/integration/api_pull_test.go b/tests/integration/api_pull_test.go index e9ccbe45831..aa4aed97895 100644 --- a/tests/integration/api_pull_test.go +++ b/tests/integration/api_pull_test.go @@ -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) })