Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions models/issues/close_reason.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package issues

import (
"fmt"
)

type IssueCloseReason int64

const (
IssueCloseReasonNone IssueCloseReason = iota
IssueCloseReasonCompleted
IssueCloseReasonCompletedByCommit
IssueCloseReasonCompletedByPull
IssueCloseReasonAnswered
IssueCloseReasonDuplicate
IssueCloseReasonNotPlanned
)

func (r IssueCloseReason) String() string {
switch r {
case IssueCloseReasonCompleted:
return "completed"
case IssueCloseReasonCompletedByCommit:
return "completed_by_commit"
case IssueCloseReasonCompletedByPull:
return "completed_by_pull"
case IssueCloseReasonAnswered:
return "answered"
case IssueCloseReasonDuplicate:
return "duplicate"
case IssueCloseReasonNotPlanned:
return "not_planned"
default:
return ""
}
}

func (r IssueCloseReason) IsValid() bool {
return r >= IssueCloseReasonNone && r <= IssueCloseReasonNotPlanned
}

func ParseIssueCloseReason(reason string) (IssueCloseReason, error) {
switch reason {
case "":
return IssueCloseReasonNone, nil
case "completed":
return IssueCloseReasonCompleted, nil
case "completed_by_commit":
return IssueCloseReasonCompletedByCommit, nil
case "completed_by_pull":
return IssueCloseReasonCompletedByPull, nil
case "answered":
return IssueCloseReasonAnswered, nil
case "duplicate":
return IssueCloseReasonDuplicate, nil
case "not_planned":
return IssueCloseReasonNotPlanned, nil
default:
return IssueCloseReasonNone, fmt.Errorf("unknown close reason %q", reason)
}
}
84 changes: 84 additions & 0 deletions models/issues/close_reason_display.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package issues

import "code.gitea.io/gitea/modules/json"

type closeReasonParam struct {
IssueIndex int64 `json:"issue_index"`
CommentID int64 `json:"comment_id"`
CommitHash string `json:"commit_hash"`
PullIndex int64 `json:"pull_index"`
}

func parseCloseReasonParam(param string) closeReasonParam {
if param == "" {
return closeReasonParam{}
}
var p closeReasonParam
_ = json.Unmarshal([]byte(param), &p)
return p
}

func normalizeCloseReason(isClosed bool, reason IssueCloseReason) string {
if isClosed && reason == IssueCloseReasonNone {
return IssueCloseReasonCompleted.String()
}
return reason.String()
}

func (issue *Issue) CloseReasonForDisplay() string {
return normalizeCloseReason(issue.IsClosed, issue.CloseReason)
}

func (issue *Issue) CloseReasonDuplicateIssueIndex() int64 {
return parseCloseReasonParam(issue.CloseReasonParam).IssueIndex
}

func (issue *Issue) CloseReasonAnsweredCommentID() int64 {
return parseCloseReasonParam(issue.CloseReasonParam).CommentID
}

func (issue *Issue) CloseReasonCommitHash() string {
return parseCloseReasonParam(issue.CloseReasonParam).CommitHash
}

func (issue *Issue) CloseReasonPullIndex() int64 {
return parseCloseReasonParam(issue.CloseReasonParam).PullIndex
}

func (c *Comment) CloseReasonForDisplay() string {
if c.CommentMetaData == nil {
return ""
}
return normalizeCloseReason(true, c.CommentMetaData.CloseReason)
}

func (c *Comment) CloseReasonDuplicateIssueIndex() int64 {
if c.CommentMetaData == nil {
return 0
}
return parseCloseReasonParam(c.CommentMetaData.CloseReasonParam).IssueIndex
}

func (c *Comment) CloseReasonAnsweredCommentID() int64 {
if c.CommentMetaData == nil {
return 0
}
return parseCloseReasonParam(c.CommentMetaData.CloseReasonParam).CommentID
}

func (c *Comment) CloseReasonCommitHash() string {
if c.CommentMetaData == nil {
return ""
}
return parseCloseReasonParam(c.CommentMetaData.CloseReasonParam).CommitHash
}

func (c *Comment) CloseReasonPullIndex() int64 {
if c.CommentMetaData == nil {
return 0
}
return parseCloseReasonParam(c.CommentMetaData.CloseReasonParam).PullIndex
}
21 changes: 17 additions & 4 deletions models/issues/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ const (
CommentTypeUnpin // 37 unpin Issue/PullRequest

CommentTypeChangeTimeEstimate // 38 Change time estimate

CommentTypeCloseWithReason // 39 Close an issue with a structured close reason
)

var commentStrings = []string{
Expand Down Expand Up @@ -158,6 +160,7 @@ var commentStrings = []string{
"pin",
"unpin",
"change_time_estimate",
"close_with_reason",
}

func (t CommentType) String() string {
Expand Down Expand Up @@ -191,7 +194,7 @@ func (t CommentType) HasAttachmentSupport() bool {

func (t CommentType) HasMailReplySupport() bool {
switch t {
case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview, CommentTypeReopen, CommentTypeClose, CommentTypeMergePull, CommentTypeAssignees:
case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview, CommentTypeReopen, CommentTypeClose, CommentTypeCloseWithReason, CommentTypeMergePull, CommentTypeAssignees:
return true
}
return false
Expand Down Expand Up @@ -240,9 +243,11 @@ const SpecialDoerNameCodeOwners SpecialDoerNameType = "CODEOWNERS"

// CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database
type CommentMetaData struct {
ProjectColumnID int64 `json:"project_column_id,omitempty"`
ProjectColumnTitle string `json:"project_column_title,omitempty"`
ProjectTitle string `json:"project_title,omitempty"`
ProjectColumnID int64 `json:"project_column_id,omitempty"`
ProjectColumnTitle string `json:"project_column_title,omitempty"`
ProjectTitle string `json:"project_title,omitempty"`
CloseReason IssueCloseReason `json:"close_reason,omitempty"`
CloseReasonParam string `json:"close_reason_param,omitempty"`

SpecialDoerName SpecialDoerNameType `json:"special_doer_name,omitempty"` // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests
}
Expand Down Expand Up @@ -823,6 +828,12 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
SpecialDoerName: opts.SpecialDoerName,
}
}
if opts.CloseReason != IssueCloseReasonNone {
commentMetaData = &CommentMetaData{
CloseReason: opts.CloseReason,
CloseReasonParam: opts.CloseReasonParam,
}
}

comment := &Comment{
Type: opts.Type,
Expand Down Expand Up @@ -1020,6 +1031,8 @@ type CreateCommentOptions struct {
IsForcePush bool
Invalidated bool
SpecialDoerName SpecialDoerNameType // e.g. "CODEOWNERS" for CODEOWNERS-triggered review requests
CloseReason IssueCloseReason // for CommentTypeCloseWithReason
CloseReasonParam string // JSON-serialized param for the close reason
}

// GetCommentByID returns the comment by given ID.
Expand Down
2 changes: 1 addition & 1 deletion models/issues/dependency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestCreateIssueDependency(t *testing.T) {
assert.False(t, left)

// Close #2 and check again
_, err = issues_model.CloseIssue(t.Context(), issue2, user1)
_, err = issues_model.CloseIssue(t.Context(), issue2, user1, issues_model.IssueCloseOptions{})
assert.NoError(t, err)

issue2Closed, err := issues_model.GetIssueByID(t.Context(), 2)
Expand Down
3 changes: 3 additions & 0 deletions models/issues/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ type Issue struct {

// Time estimate
TimeEstimate int64 `xorm:"NOT NULL DEFAULT 0"`

CloseReason IssueCloseReason `xorm:"INDEX DEFAULT 0"`
CloseReasonParam string `xorm:"TEXT"`
}

var (
Expand Down
52 changes: 39 additions & 13 deletions models/issues/issue_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ func UpdateIssueCols(ctx context.Context, issue *Issue, cols ...string) error {
return err
}

// IssueCloseOptions carries the close-reason payload for SetIssueAsClosed / CloseIssue.
// CloseReason and CloseReasonParam follow the same schema as Issue.CloseReason /
// Issue.CloseReasonParam (see services/issue/close_reason.go for the full set of
// constants). IsMergePull is a legacy flag preserved for the PR-merge path; it will
// be removed once step 7 migrates that path to use CloseReasonCompletedByPull.
type IssueCloseOptions struct {
CloseReason IssueCloseReason
CloseReasonParam string
IsMergePull bool // when true and CloseReason is empty, use CommentTypeMergePull
}

// ErrIssueIsClosed is used when close a closed issue
type ErrIssueIsClosed struct {
ID int64
Expand All @@ -48,7 +59,7 @@ func (err ErrIssueIsClosed) Error() string {
return fmt.Sprintf("%s [id: %d, repo_id: %d, index: %d] is already closed", util.Iif(err.IsPull, "Pull Request", "Issue"), err.ID, err.RepoID, err.Index)
}

func SetIssueAsClosed(ctx context.Context, issue *Issue, doer *user_model.User, isMergePull bool) (*Comment, error) {
func SetIssueAsClosed(ctx context.Context, issue *Issue, doer *user_model.User, opts IssueCloseOptions) (*Comment, error) {
if issue.IsClosed {
return nil, ErrIssueIsClosed{
ID: issue.ID,
Expand All @@ -73,16 +84,27 @@ func SetIssueAsClosed(ctx context.Context, issue *Issue, doer *user_model.User,

issue.IsClosed = true
issue.ClosedUnix = timeutil.TimeStampNow()
issue.CloseReason = opts.CloseReason
issue.CloseReasonParam = opts.CloseReasonParam

if cnt, err := db.GetEngine(ctx).ID(issue.ID).Cols("is_closed", "closed_unix").
if cnt, err := db.GetEngine(ctx).ID(issue.ID).Cols("is_closed", "closed_unix", "close_reason", "close_reason_param").
Where("is_closed = ?", false).
Update(issue); err != nil {
return nil, err
} else if cnt != 1 {
return nil, ErrIssueAlreadyChanged
}

return updateIssueNumbers(ctx, issue, doer, util.Iif(isMergePull, CommentTypeMergePull, CommentTypeClose))
var cmtType CommentType
switch {
case opts.CloseReason != IssueCloseReasonNone:
cmtType = CommentTypeCloseWithReason
case opts.IsMergePull:
cmtType = CommentTypeMergePull
default:
cmtType = CommentTypeClose
}
return updateIssueNumbers(ctx, issue, doer, cmtType, opts.CloseReason, opts.CloseReasonParam)
}

// ErrIssueIsOpen is used when reopen an opened issue
Expand Down Expand Up @@ -115,19 +137,21 @@ func setIssueAsReopen(ctx context.Context, issue *Issue, doer *user_model.User)

issue.IsClosed = false
issue.ClosedUnix = 0
issue.CloseReason = IssueCloseReasonNone
issue.CloseReasonParam = ""

if cnt, err := db.GetEngine(ctx).ID(issue.ID).Cols("is_closed", "closed_unix").
if cnt, err := db.GetEngine(ctx).ID(issue.ID).Cols("is_closed", "closed_unix", "close_reason", "close_reason_param").
Where("is_closed = ?", true).
Update(issue); err != nil {
return nil, err
} else if cnt != 1 {
return nil, ErrIssueAlreadyChanged
}

return updateIssueNumbers(ctx, issue, doer, CommentTypeReopen)
return updateIssueNumbers(ctx, issue, doer, CommentTypeReopen, IssueCloseReasonNone, "")
}

func updateIssueNumbers(ctx context.Context, issue *Issue, doer *user_model.User, cmtType CommentType) (*Comment, error) {
func updateIssueNumbers(ctx context.Context, issue *Issue, doer *user_model.User, cmtType CommentType, closeReason IssueCloseReason, closeReasonParam string) (*Comment, error) {
// Update issue count of labels
if err := issue.LoadLabels(ctx); err != nil {
return nil, err
Expand All @@ -147,7 +171,7 @@ func updateIssueNumbers(ctx context.Context, issue *Issue, doer *user_model.User

// update repository's issue closed number
switch cmtType {
case CommentTypeClose, CommentTypeMergePull:
case CommentTypeClose, CommentTypeMergePull, CommentTypeCloseWithReason:
// only increase closed count
if err := IncrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false); err != nil {
return nil, err
Expand All @@ -162,15 +186,17 @@ func updateIssueNumbers(ctx context.Context, issue *Issue, doer *user_model.User
}

return CreateComment(ctx, &CreateCommentOptions{
Type: cmtType,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
Type: cmtType,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
CloseReason: closeReason,
CloseReasonParam: closeReasonParam,
})
}

// CloseIssue changes issue status to closed.
func CloseIssue(ctx context.Context, issue *Issue, doer *user_model.User) (*Comment, error) {
func CloseIssue(ctx context.Context, issue *Issue, doer *user_model.User, opts IssueCloseOptions) (*Comment, error) {
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
Expand All @@ -179,7 +205,7 @@ func CloseIssue(ctx context.Context, issue *Issue, doer *user_model.User) (*Comm
}

return db.WithTx2(ctx, func(ctx context.Context) (*Comment, error) {
return SetIssueAsClosed(ctx, issue, doer, false)
return SetIssueAsClosed(ctx, issue, doer, opts)
})
}

Expand Down
2 changes: 1 addition & 1 deletion models/issues/issue_xref_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func TestXRef_ResolveCrossReferences(t *testing.T) {
i1 := testCreateIssue(t, 1, 2, "title1", "content1", false)
i2 := testCreateIssue(t, 1, 2, "title2", "content2", false)
i3 := testCreateIssue(t, 1, 2, "title3", "content3", false)
_, err := issues_model.CloseIssue(t.Context(), i3, d)
_, err := issues_model.CloseIssue(t.Context(), i3, d, issues_model.IssueCloseOptions{})
assert.NoError(t, err)

pr := testCreatePR(t, 1, 2, "titlepr", fmt.Sprintf("closes #%d", i1.Index))
Expand Down
1 change: 1 addition & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ func prepareMigrationTasks() []*migration {
newMigration(328, "Add TokenPermissions column to ActionRunJob", v1_26.AddTokenPermissionsToActionRunJob),
newMigration(329, "Add unique constraint for user badge", v1_26.AddUniqueIndexForUserBadge),
newMigration(330, "Add name column to webhook", v1_26.AddNameToWebhook),
newMigration(331, "Add close reason columns to issue", v1_26.AddCloseReasonColumnsToIssue),
}
return preparedMigrations
}
Expand Down
16 changes: 16 additions & 0 deletions models/migrations/v1_26/v331.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package v1_26

import "xorm.io/xorm"

func AddCloseReasonColumnsToIssue(x *xorm.Engine) error {
type Issue struct {
CloseReason int64 `xorm:"INDEX DEFAULT 0"`
CloseReasonParam string `xorm:"TEXT"`
}

_, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(Issue))
return err
}
Loading