diff --git a/modules/actions/workflows.go b/modules/actions/workflows.go index a538b6e290..fa248fa834 100644 --- a/modules/actions/workflows.go +++ b/modules/actions/workflows.go @@ -362,7 +362,7 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool // Actions with the same name: // opened, edited, closed, reopened, assigned, unassigned, milestoned, demilestoned // Actions need to be converted: - // label_updated -> labeled + // label_updated -> labeled (when adding) or unlabeled (when removing) // label_cleared -> unlabeled // Unsupported activity types: // deleted, transferred, pinned, unpinned, locked, unlocked @@ -370,7 +370,12 @@ func matchIssuesEvent(issuePayload *api.IssuePayload, evt *jobparser.Event) bool action := issuePayload.Action switch action { case api.HookIssueLabelUpdated: - action = "labeled" + // Check if any labels were removed to determine if this should be "labeled" or "unlabeled" + if len(issuePayload.RemovedLabels) > 0 { + action = "unlabeled" + } else { + action = "labeled" + } case api.HookIssueLabelCleared: action = "unlabeled" } diff --git a/modules/actions/workflows_test.go b/modules/actions/workflows_test.go index c8e1e553fe..4062cdd6c2 100644 --- a/modules/actions/workflows_test.go +++ b/modules/actions/workflows_test.go @@ -136,3 +136,98 @@ func TestDetectMatched(t *testing.T) { }) } } + +func TestMatchIssuesEvent(t *testing.T) { + testCases := []struct { + desc string + payload *api.IssuePayload + yamlOn string + expected bool + eventType string + }{ + { + desc: "Label deletion should trigger unlabeled event", + payload: &api.IssuePayload{ + Action: api.HookIssueLabelUpdated, + Issue: &api.Issue{ + Labels: []*api.Label{}, + }, + RemovedLabels: []*api.Label{ + {ID: 123, Name: "deleted-label"}, + }, + }, + yamlOn: "on:\n issues:\n types: [unlabeled]", + expected: true, + eventType: "unlabeled", + }, + { + desc: "Label deletion with existing labels should trigger unlabeled event", + payload: &api.IssuePayload{ + Action: api.HookIssueLabelUpdated, + Issue: &api.Issue{ + Labels: []*api.Label{ + {ID: 456, Name: "existing-label"}, + }, + }, + RemovedLabels: []*api.Label{ + {ID: 123, Name: "deleted-label"}, + }, + }, + yamlOn: "on:\n issues:\n types: [unlabeled]", + expected: true, + eventType: "unlabeled", + }, + { + desc: "Label addition should trigger labeled event", + payload: &api.IssuePayload{ + Action: api.HookIssueLabelUpdated, + Issue: &api.Issue{ + Labels: []*api.Label{ + {ID: 123, Name: "new-label"}, + }, + }, + RemovedLabels: []*api.Label{}, // Empty array, no labels removed + }, + yamlOn: "on:\n issues:\n types: [labeled]", + expected: true, + eventType: "labeled", + }, + { + desc: "Label clear should trigger unlabeled event", + payload: &api.IssuePayload{ + Action: api.HookIssueLabelCleared, + Issue: &api.Issue{ + Labels: []*api.Label{}, + }, + }, + yamlOn: "on:\n issues:\n types: [unlabeled]", + expected: true, + eventType: "unlabeled", + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + evts, err := GetEventsFromContent([]byte(tc.yamlOn)) + assert.NoError(t, err) + assert.Len(t, evts, 1) + + // Test if the event matches as expected + assert.Equal(t, tc.expected, matchIssuesEvent(tc.payload, evts[0])) + + // For extra validation, use a direct call to test the actual mapping + action := tc.payload.Action + switch action { + case api.HookIssueLabelUpdated: + if len(tc.payload.RemovedLabels) > 0 { + action = "unlabeled" + } else { + action = "labeled" + } + case api.HookIssueLabelCleared: + action = "unlabeled" + } + assert.Equal(t, tc.eventType, string(action), "Event type should match expected") + }) + } +} diff --git a/modules/structs/hook.go b/modules/structs/hook.go index aaa9fbc9d3..890a99c8fe 100644 --- a/modules/structs/hook.go +++ b/modules/structs/hook.go @@ -310,13 +310,14 @@ const ( // IssuePayload represents the payload information that is sent along with an issue event. type IssuePayload struct { - Action HookIssueAction `json:"action"` - Index int64 `json:"number"` - Changes *ChangesPayload `json:"changes,omitempty"` - Issue *Issue `json:"issue"` - Repository *Repository `json:"repository"` - Sender *User `json:"sender"` - CommitID string `json:"commit_id"` + Action HookIssueAction `json:"action"` + Index int64 `json:"number"` + Changes *ChangesPayload `json:"changes,omitempty"` + RemovedLabels []*Label `json:"removed_labels"` + Issue *Issue `json:"issue"` + Repository *Repository `json:"repository"` + Sender *User `json:"sender"` + CommitID string `json:"commit_id"` } // JSONPayload encodes the IssuePayload to JSON, with an indentation of two spaces.