Commit 5c9b667e authored by Florian Forster's avatar Florian Forster
Browse files

feat(issues): Allow resetting due date, epic, milestone, and weight from issues.

Changelog: Improvements
parent 4b28efc9
Loading
Loading
Loading
Loading
+61 −0
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ import (
	"fmt"
	"net/http"
	"reflect"
	"strings"
	"time"
)

@@ -484,6 +485,9 @@ func (s *IssuesService) CreateIssue(pid any, opt *CreateIssueOptions, options ..

// UpdateIssueOptions represents the available UpdateIssue() options.
//
// To reset the due date, epic, milestone, or weight of the issue, set the
// ResetDueDate, ResetEpic, ResetMilestone, or ResetWeight field to true.
//
// GitLab API docs: https://docs.gitlab.com/api/issues/#edit-an-issue
type UpdateIssueOptions struct {
	Title            *string       `url:"title,omitempty" json:"title,omitempty"`
@@ -501,6 +505,63 @@ type UpdateIssueOptions struct {
	Weight           *int          `url:"weight,omitempty" json:"weight,omitempty"`
	DiscussionLocked *bool         `url:"discussion_locked,omitempty" json:"discussion_locked,omitempty"`
	IssueType        *string       `url:"issue_type,omitempty" json:"issue_type,omitempty"`

	ResetDueDate     bool `url:"-" json:"-"`
	ResetEpicID      bool `url:"-" json:"-"`
	ResetMilestoneID bool `url:"-" json:"-"`
	ResetWeight      bool `url:"-" json:"-"`
}

// MarshalJSON implements custom JSON marshaling for UpdateIssueOptions.
// This is needed to support emitting a literal `null` when the field needs to be removed.
func (o UpdateIssueOptions) MarshalJSON() ([]byte, error) {
	data := map[string]any{}

	// Use reflection to copy all fields from o to data
	val := reflect.ValueOf(o)
	typ := val.Type()

	for i := range val.NumField() {
		field := val.Field(i)
		fieldName := typ.Field(i).Name

		if field.IsZero() {
			continue
		}

		name := fieldName

		if tag := typ.Field(i).Tag.Get("json"); tag != "" {
			tagFields := strings.Split(tag, ",")
			name = tagFields[0]
		}

		// Skip unexported fields.
		if name == "-" {
			continue
		}

		data[name] = field.Interface()
	}

	// Emit a literal `null` when the field needs to be removed
	if o.ResetDueDate {
		data["due_date"] = nil
	}

	if o.ResetEpicID {
		data["epic_id"] = nil
	}

	if o.ResetMilestoneID {
		data["milestone_id"] = nil
	}

	if o.ResetWeight {
		data["weight"] = nil
	}

	return json.Marshal(data)
}

// UpdateIssue updates an existing project issue. This function is also used
+87 −1
Original line number Diff line number Diff line
@@ -21,8 +21,10 @@ import (
	"net/http"
	"reflect"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestGetIssue(t *testing.T) {
@@ -643,7 +645,22 @@ func TestUpdateIssue(t *testing.T) {

	mux.HandleFunc("/api/v4/projects/1/issues/5", func(w http.ResponseWriter, r *http.Request) {
		testMethod(t, r, http.MethodPut)
		fmt.Fprint(w, `{"id":1, "title" : "Title of issue", "description": "This is description of an issue", "author" : {"id" : 1, "name": "snehal"}, "assignees":[{"id":1}]}`)
		testBodyJSON(t, r, map[string]any{
			"title":       "Title of issue",
			"description": "This is description of an issue",
		})
		mustWriteJSONResponse(t, w, map[string]any{
			"id":          1,
			"title":       "Title of issue",
			"description": "This is description of an issue",
			"author": map[string]any{
				"id":   1,
				"name": "snehal",
			},
			"assignees": []map[string]any{
				{"id": 1},
			},
		})
	})

	updateIssueOpt := &UpdateIssueOptions{
@@ -668,6 +685,75 @@ func TestUpdateIssue(t *testing.T) {
	}
}

func TestUpdateIssue_ResetFields(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		opts     UpdateIssueOptions
		wantBody map[string]any
	}{
		{
			name:     "set due date",
			opts:     UpdateIssueOptions{DueDate: Ptr(ISOTime(time.Date(2025, time.May, 12, 0, 0, 0, 0, time.UTC)))},
			wantBody: map[string]any{"due_date": "2025-05-12"},
		},
		{
			name:     "unset due date",
			opts:     UpdateIssueOptions{ResetDueDate: true},
			wantBody: map[string]any{"due_date": nil},
		},
		{
			name:     "set epic",
			opts:     UpdateIssueOptions{EpicID: Ptr(42)},
			wantBody: map[string]any{"epic_id": float64(42)},
		},
		{
			name:     "unset epic",
			opts:     UpdateIssueOptions{ResetEpicID: true},
			wantBody: map[string]any{"epic_id": nil},
		},
		{
			name:     "set milestone",
			opts:     UpdateIssueOptions{MilestoneID: Ptr(42)},
			wantBody: map[string]any{"milestone_id": float64(42)},
		},
		{
			name:     "unset milestone",
			opts:     UpdateIssueOptions{ResetMilestoneID: true},
			wantBody: map[string]any{"milestone_id": nil},
		},
		{
			name:     "set weight",
			opts:     UpdateIssueOptions{Weight: Ptr(42)},
			wantBody: map[string]any{"weight": float64(42)},
		},
		{
			name:     "unset weight",
			opts:     UpdateIssueOptions{ResetWeight: true},
			wantBody: map[string]any{"weight": nil},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			mux, client := setup(t)

			mux.HandleFunc("/api/v4/projects/1/issues/5", func(w http.ResponseWriter, r *http.Request) {
				testMethod(t, r, http.MethodPut)
				testBodyJSON(t, r, tt.wantBody)
				mustWriteJSONResponse(t, w, map[string]any{"id": 5})
			})

			issue, _, err := client.Issues.UpdateIssue(1, 5, &tt.opts)
			require.NoError(t, err)
			assert.Equal(t, 5, issue.ID)
		})
	}
}

func TestSubscribeToIssue(t *testing.T) {
	t.Parallel()
	mux, client := setup(t)