Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
143 changes: 143 additions & 0 deletions models/issues/close_reason.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package issues

import (
"database/sql/driver"
"encoding/json"

Check failure on line 8 in models/issues/close_reason.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

import 'encoding/json' is not allowed from list 'main': use gitea's modules/json instead of encoding/json (depguard)

Check failure on line 8 in models/issues/close_reason.go

View workflow job for this annotation

GitHub Actions / lint-backend

import 'encoding/json' is not allowed from list 'main': use gitea's modules/json instead of encoding/json (depguard)

Check failure on line 8 in models/issues/close_reason.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

import 'encoding/json' is not allowed from list 'main': use gitea's modules/json instead of encoding/json (depguard)
"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)
}
}

func parseIssueCloseReasonNumber(reason int64) (IssueCloseReason, error) {
r := IssueCloseReason(reason)
if !r.IsValid() {
return IssueCloseReasonNone, fmt.Errorf("unknown close reason %d", reason)
}
return r, nil
}

func (r IssueCloseReason) Value() (driver.Value, error) {
if !r.IsValid() {
return nil, fmt.Errorf("unknown close reason %d", r)
}
return r.String(), nil
}

func (r *IssueCloseReason) Scan(src any) error {
if src == nil {
*r = IssueCloseReasonNone
return nil
}

switch v := src.(type) {
case string:
parsed, err := ParseIssueCloseReason(v)
if err != nil {
return err
}
*r = parsed
return nil
case []byte:
return r.Scan(string(v))
case int64:
parsed, err := parseIssueCloseReasonNumber(v)
if err != nil {
return err
}
*r = parsed
return nil
case int:
return r.Scan(int64(v))
default:
return fmt.Errorf("unsupported close reason type %T", src)
}
}

func (r IssueCloseReason) MarshalJSON() ([]byte, error) {
return json.Marshal(r.String())
}

func (r *IssueCloseReason) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
*r = IssueCloseReasonNone
return nil
}

var s string
if err := json.Unmarshal(data, &s); err == nil {
parsed, parseErr := ParseIssueCloseReason(s)
if parseErr != nil {
return parseErr
}
*r = parsed
return nil
}

var n int64
if err := json.Unmarshal(data, &n); err != nil {
return err
}
parsed, err := parseIssueCloseReasonNumber(n)
if err != nil {
return err
}
*r = parsed
return nil
}
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 "encoding/json"

Check failure on line 6 in models/issues/close_reason_display.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

import 'encoding/json' is not allowed from list 'main': use gitea's modules/json instead of encoding/json (depguard)

Check failure on line 6 in models/issues/close_reason_display.go

View workflow job for this annotation

GitHub Actions / lint-backend

import 'encoding/json' is not allowed from list 'main': use gitea's modules/json instead of encoding/json (depguard)

Check failure on line 6 in models/issues/close_reason_display.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

import 'encoding/json' is not allowed from list 'main': use gitea's modules/json instead of encoding/json (depguard)

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 (comment *Comment) CloseReasonForDisplay() string {
if comment.CommentMetaData == nil {
return ""
}
return normalizeCloseReason(true, comment.CommentMetaData.CloseReason)
}

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

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

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

func (comment *Comment) CloseReasonPullIndex() int64 {
if comment.CommentMetaData == nil {
return 0
}
return parseCloseReasonParam(comment.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 @@
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 @@
"pin",
"unpin",
"change_time_estimate",
"close_with_reason",
}

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

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 @@

// 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 @@ -458,7 +463,7 @@
}

// APIURL formats a API-string to the issue-comment
func (c *Comment) APIURL(ctx context.Context) string {

Check failure on line 466 in models/issues/comment.go

View workflow job for this annotation

GitHub Actions / lint-go-gogit

ST1016: methods on the same type should have the same receiver name (seen 43x "c", 5x "comment") (staticcheck)

Check failure on line 466 in models/issues/comment.go

View workflow job for this annotation

GitHub Actions / lint-backend

ST1016: methods on the same type should have the same receiver name (seen 43x "c", 5x "comment") (staticcheck)

Check failure on line 466 in models/issues/comment.go

View workflow job for this annotation

GitHub Actions / lint-go-windows

ST1016: methods on the same type should have the same receiver name (seen 43x "c", 5x "comment") (staticcheck)
err := c.LoadIssue(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("LoadIssue(%d): %v", c.IssueID, err)
Expand Down Expand Up @@ -823,6 +828,12 @@
SpecialDoerName: opts.SpecialDoerName,
}
}
if opts.CloseReason != IssueCloseReasonNone {
commentMetaData = &CommentMetaData{
CloseReason: opts.CloseReason,
CloseReasonParam: opts.CloseReasonParam,
}
Comment thread
a1012112796 marked this conversation as resolved.
}

comment := &Comment{
Type: opts.Type,
Expand Down Expand Up @@ -1020,6 +1031,8 @@
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 NOT NULL DEFAULT ''"`
}

var (
Expand Down
Loading
Loading