Skip to content
Merged
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
8 changes: 8 additions & 0 deletions config/bot_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@
"needs-attention": "e99695",
"urgent": "b60205",
"needs-clarification": "fbca04"
},
"assignees": {
"bug": "lead-developer",
"feature": "tech-lead",
"docs": "docs-owner",
"security": "security-owner",
"question": "lead-developer",
"urgent": "lead-developer"
}
},
"tone": {
Expand Down
51 changes: 51 additions & 0 deletions src/assigner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from github import GithubException
import logging

logger = logging.getLogger(__name__)

def assign_issue(issue, repo, config, labels):

if issue.assignees:
return

assignee_map = config.get("assignees", {})

collaborators = {
user.login
for user in repo.get_collaborators()
}

for label in labels:

username = assignee_map.get(label)

if not username:
continue

if username not in collaborators:

logger.warning(
"%s is not collaborator",
username
)

continue

try:

issue.add_to_assignees(username)

logger.info(
"Issue #%s assigned to %s",
issue.number,
username
)

return

except GithubException as e:

logger.error(
"Assignment error: %s",
str(e)
)
4 changes: 4 additions & 0 deletions src/labeler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import logging
from src.utils import translate_to_english, get_config_path, load_config

from src.assigner import assign_issue

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -42,6 +44,7 @@ def ensure_labels_exist(repo, rules):
def apply_labels(issue, repo, config_path=None):
"""Main function: detects and applies labels to the issue."""
rules = load_label_rules(config_path)
config = load_config()
ensure_labels_exist(repo, rules)

matched = detect_labels(issue.title, issue.body or "", rules)
Expand All @@ -56,5 +59,6 @@ def apply_labels(issue, repo, config_path=None):
if new_labels:
issue.add_to_labels(*new_labels)
logger.info("Issue #%s: labels added -> %s", issue.number, new_labels)
assign_issue(issue, repo, config,new_labels)
else:
logger.debug("Issue #%s: all detected labels already applied.", issue.number)
129 changes: 129 additions & 0 deletions tests/test_apply_labels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from unittest.mock import MagicMock, patch

from src.labeler import apply_labels


@patch("src.labeler.assign_issue")
@patch("src.labeler.ensure_labels_exist")
@patch("src.labeler.load_label_rules")
@patch("src.labeler.load_config")
@patch(
"src.labeler.translate_to_english",
side_effect=lambda x: x
)
def test_apply_labels_success(
mock_translate,
mock_load_config,
mock_load_rules,
mock_ensure_labels,
mock_assign_issue
):

mock_load_rules.return_value = {
"bug": ["error"]
}

mock_load_config.return_value = {
"assignees": {
"bug": "lead-dev"
},
"labeler": {
"label_colors": {
"bug": "ff0000"
}
}
}

issue = MagicMock()
issue.title = "Critical error"
issue.body = "Application error on startup"
issue.number = 1
issue.labels = []

repo = MagicMock()

apply_labels(issue, repo)

issue.add_to_labels.assert_called_once_with(
"bug"
)

mock_assign_issue.assert_called_once()


@patch("src.labeler.ensure_labels_exist")
@patch("src.labeler.load_label_rules")
@patch(
"src.labeler.translate_to_english",
side_effect=lambda x: x
)
def test_apply_labels_no_matches(
mock_translate,
mock_load_rules,
mock_ensure_labels
):

mock_load_rules.return_value = {
"bug": ["error"]
}

issue = MagicMock()
issue.title = "New feature"
issue.body = "Add dark mode"
issue.number = 2
issue.labels = []

repo = MagicMock()

apply_labels(issue, repo)

issue.add_to_labels.assert_not_called()


@patch("src.labeler.assign_issue")
@patch("src.labeler.ensure_labels_exist")
@patch("src.labeler.load_label_rules")
@patch("src.labeler.load_config")
@patch(
"src.labeler.translate_to_english",
side_effect=lambda x: x
)
def test_apply_labels_skip_existing(
mock_translate,
mock_load_config,
mock_load_rules,
mock_ensure_labels,
mock_assign_issue
):

mock_load_rules.return_value = {
"bug": ["error"]
}

mock_load_config.return_value = {
"assignees": {
"bug": "lead-dev"
},
"labeler": {
"label_colors": {
"bug": "ff0000"
}
}
}

existing_label = MagicMock()
existing_label.name = "bug"

issue = MagicMock()
issue.title = "Critical error"
issue.body = "Application error"
issue.number = 3
issue.labels = [existing_label]

repo = MagicMock()

apply_labels(issue, repo)

issue.add_to_labels.assert_not_called()

mock_assign_issue.assert_not_called()
122 changes: 122 additions & 0 deletions tests/test_assigner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
from unittest.mock import MagicMock

from src.assigner import assign_issue

def test_assign_issue_success():

issue = MagicMock()
issue.number = 1
issue.assignees = []

collaborator = MagicMock()
collaborator.login = "lead-dev"

repo = MagicMock()
repo.get_collaborators.return_value = [collaborator]

config = {
"assignees": {
"bug": "lead-dev"
}
}

labels = ["bug"]

assign_issue(
issue,
repo,
config,
labels
)

issue.add_to_assignees.assert_called_once_with(
"lead-dev"
)


def test_assign_issue_not_collaborator():

issue = MagicMock()
issue.number = 2
issue.assignees = []

collaborator = MagicMock()
collaborator.login = "another-user"

repo = MagicMock()
repo.get_collaborators.return_value = [collaborator]

config = {
"assignees": {
"bug": "lead-dev"
}
}

labels = ["bug"]

assign_issue(
issue,
repo,
config,
labels
)

issue.add_to_assignees.assert_not_called()


def test_assign_issue_no_matching_label():

issue = MagicMock()
issue.number = 3
issue.assignees = []

collaborator = MagicMock()
collaborator.login = "lead-dev"

repo = MagicMock()
repo.get_collaborators.return_value = [collaborator]

config = {
"assignees": {
"bug": "lead-dev"
}
}

labels = ["docs"]

assign_issue(
issue,
repo,
config,
labels
)

issue.add_to_assignees.assert_not_called()


def test_assign_issue_already_assigned():

existing_assignee = MagicMock()

issue = MagicMock()
issue.number = 4
issue.assignees = [existing_assignee]

repo = MagicMock()

config = {
"assignees": {
"bug": "lead-dev"
}
}

labels = ["bug"]

assign_issue(
issue,
repo,
config,
labels
)

issue.add_to_assignees.assert_not_called()