From 62646d927f0f783b622b73bd1d295f9715e6abe7 Mon Sep 17 00:00:00 2001 From: AbhayTopno <143319154+AbhayTopno@users.noreply.github.com> Date: Mon, 3 Feb 2025 09:06:40 +0530 Subject: [PATCH] Addition of Contribute Section in Home of Slack NestBot (#593) * Addition of Contribute Section in Home of Slack NestBot * Update contribute.py * Update of apps/slack/commands/contribute * Minor changes * Update of code after the review * Update backend/apps/slack/commands/contribute.py Co-authored-by: Nitin Awari <99824048+nitinawari@users.noreply.github.com> * Update urls.py * Update contribute.py * Update code --------- Co-authored-by: Nitin Awari <99824048+nitinawari@users.noreply.github.com> Co-authored-by: Arkadii Yakovets Co-authored-by: Arkadii Yakovets <2201626+arkid15r@users.noreply.github.com> --- backend/apps/slack/actions/home.py | 15 +- backend/apps/slack/blocks.py | 10 + backend/apps/slack/commands/contribute.py | 86 ++------ .../apps/slack/common/handlers/contribute.py | 87 ++++++++ backend/apps/slack/common/presentation.py | 2 +- backend/apps/slack/constants.py | 4 + .../tests/slack/commands/contribute_test.py | 199 +++++------------- 7 files changed, 190 insertions(+), 213 deletions(-) create mode 100644 backend/apps/slack/common/handlers/contribute.py diff --git a/backend/apps/slack/actions/home.py b/backend/apps/slack/actions/home.py index 2816d0f06..6c0e120e3 100644 --- a/backend/apps/slack/actions/home.py +++ b/backend/apps/slack/actions/home.py @@ -6,7 +6,7 @@ from apps.slack.apps import SlackConfig from apps.slack.blocks import get_header, markdown -from apps.slack.common.handlers import chapters, committees, projects +from apps.slack.common.handlers import chapters, committees, contribute, projects from apps.slack.common.presentation import EntityPresentation from apps.slack.constants import ( VIEW_CHAPTERS_ACTION, @@ -15,6 +15,9 @@ VIEW_COMMITTEES_ACTION, VIEW_COMMITTEES_ACTION_NEXT, VIEW_COMMITTEES_ACTION_PREV, + VIEW_CONTRIBUTE_ACTION, + VIEW_CONTRIBUTE_ACTION_NEXT, + VIEW_CONTRIBUTE_ACTION_PREV, VIEW_PROJECTS_ACTION, VIEW_PROJECTS_ACTION_NEXT, VIEW_PROJECTS_ACTION_PREV, @@ -67,6 +70,13 @@ def handle_home_actions(ack, body, client): VIEW_PROJECTS_ACTION_NEXT, }: blocks = projects.get_blocks(page=page, limit=10, presentation=home_presentation) + + case action if action in { + VIEW_CONTRIBUTE_ACTION, + VIEW_CONTRIBUTE_ACTION_PREV, + VIEW_CONTRIBUTE_ACTION_NEXT, + }: + blocks = contribute.get_blocks(page=page, limit=10, presentation=home_presentation) case _: blocks = [markdown("Invalid action, please try again.")] @@ -93,6 +103,9 @@ def handle_home_actions(ack, body, client): VIEW_COMMITTEES_ACTION_NEXT, VIEW_COMMITTEES_ACTION_PREV, VIEW_COMMITTEES_ACTION, + VIEW_CONTRIBUTE_ACTION_NEXT, + VIEW_CONTRIBUTE_ACTION_PREV, + VIEW_CONTRIBUTE_ACTION, VIEW_PROJECTS_ACTION_NEXT, VIEW_PROJECTS_ACTION_PREV, VIEW_PROJECTS_ACTION, diff --git a/backend/apps/slack/blocks.py b/backend/apps/slack/blocks.py index feb00bc2a..e00b7fc6c 100644 --- a/backend/apps/slack/blocks.py +++ b/backend/apps/slack/blocks.py @@ -50,6 +50,16 @@ def get_header(): "value": "view_committees", "action_id": "view_committees_action", }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Contribute", + "emoji": True, + }, + "value": "view_contribute", + "action_id": "view_contribute_action", + }, ], }, ] diff --git a/backend/apps/slack/commands/contribute.py b/backend/apps/slack/commands/contribute.py index 0a52e7038..4124bedfd 100644 --- a/backend/apps/slack/commands/contribute.py +++ b/backend/apps/slack/commands/contribute.py @@ -1,89 +1,47 @@ """Slack bot contribute command.""" from django.conf import settings -from django.utils.text import Truncator from apps.common.constants import NL -from apps.common.utils import get_absolute_url from apps.slack.apps import SlackConfig from apps.slack.blocks import markdown -from apps.slack.common.constants import COMMAND_START -from apps.slack.constants import FEEDBACK_CHANNEL_MESSAGE -from apps.slack.utils import escape +from apps.slack.common.constants import COMMAND_HELP, COMMAND_START +from apps.slack.common.handlers.contribute import get_blocks +from apps.slack.common.presentation import EntityPresentation COMMAND = "/contribute" -SUMMARY_TRUNCATION_LIMIT = 255 -TITLE_TRUNCATION_LIMIT = 80 def contribute_handler(ack, command, client): """Slack /contribute command handler.""" - from apps.github.models.issue import Issue - from apps.owasp.api.search.issue import get_issues - from apps.slack.common.contribute import CONTRIBUTE_GENERAL_INFORMATION_BLOCKS - ack() if not settings.SLACK_COMMANDS_ENABLED: return command_text = command["text"].strip() - if command_text in COMMAND_START: + + if command_text in COMMAND_HELP: blocks = [ - *CONTRIBUTE_GENERAL_INFORMATION_BLOCKS, - markdown(f"{FEEDBACK_CHANNEL_MESSAGE}"), + markdown( + f"*Available Commands for Contributing:*{NL}" + f"•`/contribute` - View all available issues.{NL}" + f"•`/contribute ` - Search for contribution opportunities.{NL}" + ), ] else: - search_query_escaped = escape(command_text) - blocks = [ - markdown(f"*No results found for `{COMMAND} {search_query_escaped}`*{NL}"), - ] - - attributes = [ - "idx_project_name", - "idx_summary", - "idx_title", - "idx_url", - ] - if issues := get_issues( - command_text, - attributes=attributes, - distinct=not command_text, + search_query = "" if command_text in COMMAND_START else command_text + blocks = get_blocks( + search_query=search_query, limit=10, - )["hits"]: - blocks = [ - markdown( - ( - f"{NL}*Here are top 10 most relevant issues " - f"that I found based on *{NL} `{COMMAND} {search_query_escaped}`:{NL}" - ) - if search_query_escaped - else (f"{NL}*Here are top 10 most recent issues:*{NL}") - ), - ] - - for idx, issue in enumerate(issues): - title_truncated = Truncator(escape(issue["idx_title"])).chars( - TITLE_TRUNCATION_LIMIT, truncate="..." - ) - summary_truncated = Truncator(issue["idx_summary"]).chars( - SUMMARY_TRUNCATION_LIMIT, truncate="..." - ) - blocks.append( - markdown( - f"{NL}*{idx + 1}.* <{issue['idx_url']}|*{title_truncated}*>{NL}" - f"{escape(issue['idx_project_name'])}{NL}" - f"{escape(summary_truncated)}{NL}" - ), - ) - - blocks.append( - markdown( - f"⚠️ *Extended search over {Issue.open_issues_count()} open issues " - f"is available at <{get_absolute_url('projects/contribute')}" - f"?q={command_text}|{settings.SITE_NAME}>*\n" - f"{FEEDBACK_CHANNEL_MESSAGE}" - ), - ) + presentation=EntityPresentation( + include_feedback=True, + include_metadata=True, + include_pagination=False, + include_timestamps=True, + name_truncation=80, + summary_truncation=300, + ), + ) conversation = client.conversations_open(users=command["user_id"]) client.chat_postMessage(channel=conversation["channel"]["id"], blocks=blocks) diff --git a/backend/apps/slack/common/handlers/contribute.py b/backend/apps/slack/common/handlers/contribute.py new file mode 100644 index 000000000..30d655e04 --- /dev/null +++ b/backend/apps/slack/common/handlers/contribute.py @@ -0,0 +1,87 @@ +"""Handler for OWASP Contribute Slack functionality.""" + +from __future__ import annotations + +from django.utils.text import Truncator + +from apps.common.constants import NL +from apps.slack.blocks import get_pagination_buttons, markdown +from apps.slack.common.constants import TRUNCATION_INDICATOR +from apps.slack.common.presentation import EntityPresentation +from apps.slack.constants import FEEDBACK_CHANNEL_MESSAGE +from apps.slack.utils import escape + + +def get_blocks( + page=1, search_query: str = "", limit: int = 10, presentation: EntityPresentation | None = None +): + """Get contribute blocks.""" + from apps.github.models.issue import Issue + from apps.owasp.api.search.issue import get_issues + + presentation = presentation or EntityPresentation() + search_query_escaped = escape(search_query) + + attributes = [ + "idx_project_name", + "idx_project_url", + "idx_summary", + "idx_title", + "idx_url", + ] + + offset = (page - 1) * limit + contribute_data = get_issues(search_query, attributes=attributes, limit=limit, page=page) + issues = contribute_data["hits"] + total_pages = contribute_data["nbPages"] + + if not issues: + return [ + markdown( + f"*No issues found for `{search_query_escaped}`*{NL}" + if search_query + else "*No issues found*{NL}" + ) + ] + + blocks = [] + for idx, issue in enumerate(issues): + title = Truncator(escape(issue["idx_title"])).chars( + presentation.name_truncation, truncate=TRUNCATION_INDICATOR + ) + project_name = escape(issue["idx_project_name"]) + project_url = escape(issue["idx_project_url"]) + summary = Truncator(escape(issue["idx_summary"])).chars( + presentation.summary_truncation, truncate=TRUNCATION_INDICATOR + ) + + blocks.append( + markdown( + f"{offset + idx + 1}. <{issue['idx_url']}|*{title}*>{NL}" + f"<{project_url}|{project_name}>{NL}" + f"{summary}{NL}" + ) + ) + + if presentation.include_feedback: + blocks.append( + markdown( + f"Extended search over {Issue.open_issues_count()} OWASP issues" + f"{FEEDBACK_CHANNEL_MESSAGE}" + ) + ) + if presentation.include_pagination and ( + pagination_block := get_pagination_buttons( + "contribute", + page, + total_pages - 1, + ) + ): + blocks.append( + { + "type": "actions", + "elements": pagination_block, + } + ) + + return blocks diff --git a/backend/apps/slack/common/presentation.py b/backend/apps/slack/common/presentation.py index 7a5b4b56d..67bfbe95e 100644 --- a/backend/apps/slack/common/presentation.py +++ b/backend/apps/slack/common/presentation.py @@ -1,4 +1,4 @@ -"""default entities presentation for chapters, committees, project,.""" +"""default entities presentation for OWASP entities (project, events, etc).""" from dataclasses import dataclass diff --git a/backend/apps/slack/constants.py b/backend/apps/slack/constants.py index b4981b010..92e90db1e 100644 --- a/backend/apps/slack/constants.py +++ b/backend/apps/slack/constants.py @@ -33,6 +33,10 @@ VIEW_CHAPTERS_ACTION_NEXT = "view_chapters_action_next" VIEW_CHAPTERS_ACTION_PREV = "view_chapters_action_prev" +VIEW_CONTRIBUTE_ACTION = "view_contribute_action" +VIEW_CONTRIBUTE_ACTION_NEXT = "view_contribute_action_next" +VIEW_CONTRIBUTE_ACTION_PREV = "view_contribute_action_prev" + FEEDBACK_CHANNEL_MESSAGE = ( f"💬 You can share feedback on your {NEST_BOT_NAME} experience " diff --git a/backend/tests/slack/commands/contribute_test.py b/backend/tests/slack/commands/contribute_test.py index 1b381aa4b..5fe499cea 100644 --- a/backend/tests/slack/commands/contribute_test.py +++ b/backend/tests/slack/commands/contribute_test.py @@ -2,178 +2,83 @@ import pytest from django.conf import settings -from django.utils.text import Truncator -from apps.slack.commands.contribute import ( - COMMAND_START, - SUMMARY_TRUNCATION_LIMIT, - TITLE_TRUNCATION_LIMIT, - contribute_handler, -) -from apps.slack.constants import FEEDBACK_CHANNEL_MESSAGE +from apps.slack.commands.contribute import contribute_handler + + +@pytest.fixture(autouse=True) +def mock_get_absolute_url(): + with patch("apps.common.utils.get_absolute_url") as mock: + mock.return_value = "http://example.com" + yield mock class TestContributeHandler: @pytest.fixture() - def mock_slack_command(self): + def mock_command(self): return { - "text": "python", + "text": "", "user_id": "U123456", } @pytest.fixture() - def mock_slack_client(self): + def mock_client(self): client = MagicMock() client.conversations_open.return_value = {"channel": {"id": "C123456"}} return client - @pytest.fixture() - def mock_issue(self): - return { - "idx_project_name": "Test Project", - "idx_summary": "Test Summary", - "idx_title": "Test Title", - "idx_url": "http://example.com/issue/1", - } - - @pytest.mark.parametrize( - ("command_enabled", "has_results", "expected_message"), - [ - (True, True, "Here are top 10 most relevant issues"), - (True, False, "No results found for"), - (False, True, None), - ], - ) - @patch("apps.owasp.api.search.issue.get_issues") - @patch("apps.github.models.issue.Issue.open_issues_count") - def test_handler_results( - self, - mock_open_issues_count, - mock_get_issues, - command_enabled, - has_results, - expected_message, - mock_slack_client, - mock_slack_command, - mock_issue, - ): - settings.SLACK_COMMANDS_ENABLED = command_enabled - mock_get_issues.return_value = {"hits": [mock_issue] if has_results else []} - mock_open_issues_count.return_value = 42 - - contribute_handler(ack=MagicMock(), command=mock_slack_command, client=mock_slack_client) - - if command_enabled: - blocks = mock_slack_client.chat_postMessage.call_args[1]["blocks"] - assert any(expected_message in str(block) for block in blocks) - if has_results: - assert any(mock_issue["idx_title"] in str(block) for block in blocks) - else: - mock_slack_client.conversations_open.assert_not_called() - mock_slack_client.chat_postMessage.assert_not_called() - - @pytest.mark.parametrize( - ("title_length", "summary_length", "should_truncate"), - [ - (TITLE_TRUNCATION_LIMIT + 10, SUMMARY_TRUNCATION_LIMIT + 10, True), - (TITLE_TRUNCATION_LIMIT - 10, SUMMARY_TRUNCATION_LIMIT - 10, False), - ], - ) - @patch("apps.owasp.api.search.issue.get_issues") - @patch("apps.github.models.issue.Issue.open_issues_count") - def test_handler_text_truncation( - self, - mock_open_issues_count, - mock_get_issues, - title_length, - summary_length, - should_truncate, - mock_slack_client, - mock_slack_command, - ): - long_title = "A" * title_length - long_summary = "B" * summary_length - - mock_issue = { - "idx_project_name": "Test Project", - "idx_summary": long_summary, - "idx_title": long_title, - "idx_url": "http://example.com/issue/1", - } - mock_get_issues.return_value = {"hits": [mock_issue]} - mock_open_issues_count.return_value = 42 - settings.SLACK_COMMANDS_ENABLED = True - - contribute_handler(ack=MagicMock(), command=mock_slack_command, client=mock_slack_client) + @pytest.fixture(autouse=True) + def mock_get_contributions(self): + with patch("apps.owasp.api.search.issue.get_issues") as mock: + mock.return_value = {"hits": [], "nbPages": 1} + yield mock - blocks = mock_slack_client.chat_postMessage.call_args[1]["blocks"] - truncated_title = Truncator(long_title).chars(TITLE_TRUNCATION_LIMIT, truncate="...") - truncated_summary = Truncator(long_summary).chars(SUMMARY_TRUNCATION_LIMIT, truncate="...") - - if should_truncate: - assert any(truncated_title in str(block) for block in blocks) - assert any(truncated_summary in str(block) for block in blocks) - assert not any(long_title in str(block) for block in blocks) - assert not any(long_summary in str(block) for block in blocks) - else: - assert any(long_title in str(block) for block in blocks) - assert any(long_summary in str(block) for block in blocks) + @pytest.fixture(autouse=True) + def mock_issue_active_contribute_count(self): + with patch( + "apps.github.models.issue.Issue.open_issues_count", new_callable=MagicMock + ) as mock: + mock.return_value = 10 # Example value + yield mock @pytest.mark.parametrize( - ("search_text", "expected_search", "expected_escaped", "distinct_value"), + ("commands_enabled", "command_text", "expected_calls"), [ - ("", "", "", True), - ("test<>&", "test<>&", "test<>&", False), - ("normal search", "normal search", "normal search", False), + (True, "", 1), + (True, "search term", 1), + (True, "-h", 1), + (False, "", 0), ], ) - @patch("apps.owasp.api.search.issue.get_issues") - @patch("apps.github.models.issue.Issue.open_issues_count") - def test_handler_search_queries( - self, - mock_open_issues_count, - mock_get_issues, - search_text, - expected_search, - expected_escaped, - distinct_value, - mock_slack_client, + def test_contribute_handler( + self, mock_client, mock_command, commands_enabled, command_text, expected_calls ): - command = {"text": search_text, "user_id": "U123456"} - mock_get_issues.return_value = {"hits": []} - mock_open_issues_count.return_value = 42 - settings.SLACK_COMMANDS_ENABLED = True + settings.SLACK_COMMANDS_ENABLED = commands_enabled + mock_command["text"] = command_text - contribute_handler(ack=MagicMock(), command=command, client=mock_slack_client) + contribute_handler(ack=MagicMock(), command=mock_command, client=mock_client) - mock_get_issues.assert_called_with( - expected_search, - attributes=["idx_project_name", "idx_summary", "idx_title", "idx_url"], - distinct=distinct_value, - limit=10, - ) + assert mock_client.chat_postMessage.call_count == expected_calls - blocks = mock_slack_client.chat_postMessage.call_args[1]["blocks"] - assert any(expected_escaped in str(block) for block in blocks) - - @pytest.mark.parametrize("command_text", sorted(COMMAND_START)) - @patch("apps.owasp.api.search.issue.get_issues") - @patch("apps.github.models.issue.Issue.open_issues_count") - def test_handler_start_commands( - self, - mock_open_issues_count, - mock_get_issues, - command_text, - mock_slack_client, + def test_contribute_handler_with_results( + self, mock_get_contributions, mock_client, mock_command ): - command = {"text": command_text, "user_id": "U123456"} - settings.SLACK_COMMANDS_ENABLED = True - mock_get_issues.return_value = {"hits": []} - mock_open_issues_count.return_value = 42 settings.SLACK_COMMANDS_ENABLED = True + mock_get_contributions.return_value = { + "hits": [ + { + "idx_title": "Test Contribution", + "idx_project_name": "Test Project", + "idx_project_url": "http://example.com/project", + "idx_summary": "Test Summary", + "idx_url": "http://example.com/contribution", + } + ], + "nbPages": 1, + } - contribute_handler(ack=MagicMock(), command=command, client=mock_slack_client) + contribute_handler(ack=MagicMock(), command=mock_command, client=mock_client) - blocks = mock_slack_client.chat_postMessage.call_args[1]["blocks"] - assert len(blocks) > 0 - assert FEEDBACK_CHANNEL_MESSAGE.strip() in str(blocks) + blocks = mock_client.chat_postMessage.call_args[1]["blocks"] + assert any("Test Contribution" in str(block) for block in blocks) + assert any("Test Project" in str(block) for block in blocks)