Skip to content
Open
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
4 changes: 4 additions & 0 deletions .github/actions/labeler-action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ inputs:
changed_files:
description: "comma separated list of files"
required: false
comment_author:
description: "GitHub login of the comment author; used as the implicit target of bare /assign and /unassign."
required: false

runs:
using: "docker"
Expand All @@ -31,3 +34,4 @@ runs:
- "${{ inputs.issue_number }}"
- "${{ inputs.comment_body }}"
- "${{ inputs.changed_files }}"
- "${{ inputs.comment_author }}"
37 changes: 36 additions & 1 deletion utilities/labeler/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,38 @@ Apply labels based on changed file paths:
match: "label-pattern" # Supports wildcards like "triage/*"
```

### Assign / Unassign
Add or remove issue/PR assignees from a slash command. The command arguments are
treated as GitHub logins (a leading `@` is optional). With no arguments, the comment
author is used, so a bare `/assign` self-assigns — this requires the `comment_author`
input (CLI: the optional trailing `comment_author` argument) to be set.
```yaml
- name: assign
kind: match
spec:
command: "/assign"
actions:
- kind: assign

- name: unassign
kind: match
spec:
command: "/unassign"
actions:
- kind: unassign
```

Example comments:
```
/assign # assigns the comment author
/assign @alice bob # assigns alice and bob
/unassign @alice # removes alice
```

Users who are already assigned (or, for `/unassign`, not assigned) are skipped. Users
who cannot be assigned in the repository are skipped with a log message instead of
failing the run.

## Testing

Run tests:
Expand All @@ -75,9 +107,12 @@ go test -v

### CLI
```bash
./labeler <labels_url> <owner> <repo> <issue_number> <comment_body> <changed_files>
./labeler <labels_url> <owner> <repo> <issue_number> <comment_body> <changed_files> [comment_author]
```

`comment_author` is optional and only needed for bare `/assign` / `/unassign`. In a
workflow, pass `${{ github.event.comment.user.login }}`.

### GitHub Actions Workflow
The included workflow automatically runs the labeler on issue comments.

Expand Down
196 changes: 196 additions & 0 deletions utilities/labeler/assign_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package main

import (
"context"
"errors"
"testing"
)

func createTestConfigWithAssignRules() *LabelsYAML {
return &LabelsYAML{
AutoCreate: false,
DefinitionRequired: true,
Debug: true,
Ruleset: []Rule{
{
Name: "assign",
Kind: "match",
Spec: RuleSpec{Command: "/assign"},
Actions: []Action{
{Kind: "assign"},
},
},
{
Name: "unassign",
Kind: "match",
Spec: RuleSpec{Command: "/unassign"},
Actions: []Action{
{Kind: "unassign"},
},
},
},
}
}

func newAssignRequest(body, author string) *LabelRequest {
return &LabelRequest{
Owner: "test-owner",
Repo: "test-repo",
IssueNumber: 1,
CommentBody: body,
CommentAuthor: author,
}
}

func TestLabeler_Assign_ExplicitUsers(t *testing.T) {
client := NewMockGitHubClient()
labeler := NewLabeler(client, createTestConfigWithAssignRules())

if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/assign @alice bob", "carol")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}

want := []string{"alice", "bob"}
if !slicesEqual(client.AddedAssignees[1], want) {
t.Errorf("expected assignees %v to be added, got %v", want, client.AddedAssignees[1])
}
if len(client.RemovedAssignees[1]) != 0 {
t.Errorf("expected no assignees to be removed, got %v", client.RemovedAssignees[1])
}
}

func TestLabeler_Assign_NoArgsAssignsCommentAuthor(t *testing.T) {
client := NewMockGitHubClient()
labeler := NewLabeler(client, createTestConfigWithAssignRules())

if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/assign", "carol")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}

if !slicesEqual(client.AddedAssignees[1], []string{"carol"}) {
t.Errorf("expected comment author to be assigned, got %v", client.AddedAssignees[1])
}
}

func TestLabeler_Assign_NoArgsNoAuthorIsNoop(t *testing.T) {
client := NewMockGitHubClient()
labeler := NewLabeler(client, createTestConfigWithAssignRules())

// Without a comment author there is nobody to self-assign; this must not error.
if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/assign", "")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}

if len(client.AddedAssignees[1]) != 0 {
t.Errorf("expected no assignees to be added, got %v", client.AddedAssignees[1])
}
}

func TestLabeler_Assign_SkipsAlreadyAssignedAndDuplicates(t *testing.T) {
client := NewMockGitHubClient()
client.Assignees[1] = []string{"alice"}
labeler := NewLabeler(client, createTestConfigWithAssignRules())

if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/assign @alice @bob @bob", "carol")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}

if !slicesEqual(client.AddedAssignees[1], []string{"bob"}) {
t.Errorf("expected only 'bob' to be added, got %v", client.AddedAssignees[1])
}
}

func TestLabeler_Assign_SkipsUnassignableUsers(t *testing.T) {
client := NewMockGitHubClient()
client.Assignable = []string{"alice"}
labeler := NewLabeler(client, createTestConfigWithAssignRules())

if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/assign @alice @stranger", "carol")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}

if !slicesEqual(client.AddedAssignees[1], []string{"alice"}) {
t.Errorf("expected only assignable user 'alice' to be added, got %v", client.AddedAssignees[1])
}
}

func TestLabeler_Assign_IsAssigneeErrorIsReported(t *testing.T) {
client := NewMockGitHubClient()
client.IsAssigneeError = errors.New("boom")
labeler := NewLabeler(client, createTestConfigWithAssignRules())

// ProcessRequest logs per-rule errors rather than returning them, so assert
// on the observable side effect: nothing is assigned on an API error.
if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/assign @alice", "carol")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}
if len(client.AddedAssignees[1]) != 0 {
t.Errorf("expected no assignees to be added on IsAssignee error, got %v", client.AddedAssignees[1])
}
}

func TestLabeler_Unassign_ExplicitUsers(t *testing.T) {
client := NewMockGitHubClient()
client.Assignees[1] = []string{"alice", "bob", "carol"}
labeler := NewLabeler(client, createTestConfigWithAssignRules())

if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/unassign @alice bob", "dave")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}

if !slicesEqual(client.RemovedAssignees[1], []string{"alice", "bob"}) {
t.Errorf("expected alice and bob to be removed, got %v", client.RemovedAssignees[1])
}
if !slicesEqual(client.Assignees[1], []string{"carol"}) {
t.Errorf("expected carol to remain assigned, got %v", client.Assignees[1])
}
}

func TestLabeler_Unassign_NoArgsRemovesCommentAuthor(t *testing.T) {
client := NewMockGitHubClient()
client.Assignees[1] = []string{"alice", "carol"}
labeler := NewLabeler(client, createTestConfigWithAssignRules())

if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/unassign", "carol")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}

if !slicesEqual(client.RemovedAssignees[1], []string{"carol"}) {
t.Errorf("expected comment author to be unassigned, got %v", client.RemovedAssignees[1])
}
}

func TestLabeler_Unassign_SkipsUsersNotAssigned(t *testing.T) {
client := NewMockGitHubClient()
client.Assignees[1] = []string{"alice"}
labeler := NewLabeler(client, createTestConfigWithAssignRules())

if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/unassign @bob", "carol")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}

if len(client.RemovedAssignees[1]) != 0 {
t.Errorf("expected no removal request for a user who is not assigned, got %v", client.RemovedAssignees[1])
}
}

func TestLabeler_Assign_CommandMustBeOwnToken(t *testing.T) {
client := NewMockGitHubClient()
labeler := NewLabeler(client, createTestConfigWithAssignRules())

// "/assignee" must not trigger the "/assign" rule.
if err := labeler.ProcessRequest(context.Background(), newAssignRequest("/assignee @alice", "carol")); err != nil {
t.Fatalf("ProcessRequest failed: %v", err)
}
if len(client.AddedAssignees[1]) != 0 {
t.Errorf("expected '/assignee' not to match '/assign', got %v", client.AddedAssignees[1])
}
}

func TestNormalizeLogins(t *testing.T) {
got := normalizeLogins([]string{"@Alice", "bob", "", "@", "@alice", "Bob"})
want := []string{"Alice", "bob"}
if !slicesEqual(got, want) {
t.Errorf("normalizeLogins = %v, want %v", got, want)
}
}
Loading
Loading