From 1b5b62264ca93947a17261eda6a0bbb6b9e5500b Mon Sep 17 00:00:00 2001 From: Kalibh Halford Date: Tue, 24 Sep 2024 09:09:31 +0100 Subject: [PATCH] Moving Chatops to another repo --- .github/workflows/cloud_chatops.yaml | 120 ------------ cloud_chatops/.gitignore | 3 - cloud_chatops/.pylintrc | 12 -- cloud_chatops/Dockerfile | 5 - cloud_chatops/README.md | 27 --- cloud_chatops/docker-compose.yaml | 11 -- cloud_chatops/pytest.ini | 5 - cloud_chatops/requirements.txt | 9 - cloud_chatops/src/__init__.py | 0 cloud_chatops/src/custom_exceptions.py | 29 --- cloud_chatops/src/enum_states.py | 9 - cloud_chatops/src/features/__init__.py | 0 cloud_chatops/src/features/commands.py | 186 ------------------- cloud_chatops/src/features/pr_reminder.py | 215 ---------------------- cloud_chatops/src/get_github_prs.py | 121 ------------ cloud_chatops/src/main.py | 86 --------- cloud_chatops/src/pr_dataclass.py | 18 -- cloud_chatops/src/read_data.py | 94 ---------- cloud_chatops/tests/__init__.py | 0 cloud_chatops/tests/test_read_data.py | 50 ----- cloud_chatops/version.txt | 1 - 21 files changed, 1001 deletions(-) delete mode 100644 .github/workflows/cloud_chatops.yaml delete mode 100644 cloud_chatops/.gitignore delete mode 100644 cloud_chatops/.pylintrc delete mode 100644 cloud_chatops/Dockerfile delete mode 100644 cloud_chatops/README.md delete mode 100644 cloud_chatops/docker-compose.yaml delete mode 100644 cloud_chatops/pytest.ini delete mode 100644 cloud_chatops/requirements.txt delete mode 100644 cloud_chatops/src/__init__.py delete mode 100644 cloud_chatops/src/custom_exceptions.py delete mode 100644 cloud_chatops/src/enum_states.py delete mode 100644 cloud_chatops/src/features/__init__.py delete mode 100644 cloud_chatops/src/features/commands.py delete mode 100644 cloud_chatops/src/features/pr_reminder.py delete mode 100644 cloud_chatops/src/get_github_prs.py delete mode 100644 cloud_chatops/src/main.py delete mode 100644 cloud_chatops/src/pr_dataclass.py delete mode 100644 cloud_chatops/src/read_data.py delete mode 100644 cloud_chatops/tests/__init__.py delete mode 100644 cloud_chatops/tests/test_read_data.py delete mode 100644 cloud_chatops/version.txt diff --git a/.github/workflows/cloud_chatops.yaml b/.github/workflows/cloud_chatops.yaml deleted file mode 100644 index 24989f46..00000000 --- a/.github/workflows/cloud_chatops.yaml +++ /dev/null @@ -1,120 +0,0 @@ -name: Cloud Chatops - -on: - push: - branches: - - master - pull_request: - paths: - - ".github/workflows/cloud_chatops.yaml" - - "cloud_chatops/**" - -jobs: - test_and_lint: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: ['ubuntu-20.04','ubuntu-22.04'] - python-version: [ "3.12", "3.x" ] - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: "pip" - - - name: Install dependencies - run: | - cd cloud_chatops - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Analyse with pylint - run: | - cd cloud_chatops - pylint . --recursive=true --rcfile=.pylintrc - - - name: Run tests - run: | - cd cloud_chatops - python3 -m pytest tests - - - name: Run tests and collect coverage - run: | - cd cloud_chatops - python3 -m pytest tests --cov-report xml:coverage.xml --cov - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - token: ${{secrets.CODECOV_TOKEN}} - files: cloud_chatops/coverage.xml - - push_dev_image_harbor: - runs-on: ubuntu-latest - needs: test_and_lint - steps: - - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Harbor - uses: docker/login-action@v3 - with: - registry: harbor.stfc.ac.uk - username: ${{ secrets.STAGING_HARBOR_USERNAME }} - password: ${{ secrets.STAGING_HARBOR_TOKEN }} - - - name: Set commit SHA for later - id: commit_sha - run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - - - name: Build and push to staging project - uses: docker/build-push-action@v6 - with: - cache-from: type=gha - cache-to: type=gha,mode=max - push: true - context: "{{defaultContext}}:cloud_chatops" - tags: "harbor.stfc.ac.uk/stfc-cloud-staging/cloud-chatops:${{ steps.commit_sha.outputs.sha_short }}" - - push_prod_image_harbor: - runs-on: ubuntu-latest - needs: test_and_lint - if: github.ref == 'refs/heads/main' - steps: - - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Harbor - uses: docker/login-action@v3 - with: - registry: harbor.stfc.ac.uk - username: ${{ secrets.HARBOR_USERNAME }} - password: ${{ secrets.HARBOR_TOKEN }} - - - name: Get release tag for later - id: release_tag - run: echo "version=$(cat cloud_chatops/version.txt)" >> $GITHUB_OUTPUT - - - name: Check if release file has updated - uses: dorny/paths-filter@v2 - id: release_updated - with: - filters: | - version: - - 'cloud_chatops/version.txt' - - - name: Build and push on version change - uses: docker/build-push-action@v6 - if: steps.release_updated.outputs.version == 'true' - with: - cache-from: type=gha - cache-to: type=gha,mode=max - push: true - context: "{{defaultContext}}:cloud_chatops" - tags: "harbor.stfc.ac.uk/stfc-cloud/cloud-chatops:${{ steps.release_tag.outputs.version }}" diff --git a/cloud_chatops/.gitignore b/cloud_chatops/.gitignore deleted file mode 100644 index 0ccb889c..00000000 --- a/cloud_chatops/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.idea/ -__pycache__/ -tests/__pycache__/ diff --git a/cloud_chatops/.pylintrc b/cloud_chatops/.pylintrc deleted file mode 100644 index 327ee415..00000000 --- a/cloud_chatops/.pylintrc +++ /dev/null @@ -1,12 +0,0 @@ -[FORMAT] -# Black will enforce 88 chars on Python code -# this will enforce 120 chars on docs / comments -max-line-length=120 - -# Disable various warnings: -# R0801 - Duplicate Code - Ignored to be fixed in future PRs - -disable=R0801 - -[MASTER] -init-hook='import sys; sys.path.append("src")' \ No newline at end of file diff --git a/cloud_chatops/Dockerfile b/cloud_chatops/Dockerfile deleted file mode 100644 index ff9c5416..00000000 --- a/cloud_chatops/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM python:3 -WORKDIR /usr/src/app -COPY . . -RUN pip3 install --no-cache-dir --requirement requirements.txt -CMD ["python", "src/main.py"] \ No newline at end of file diff --git a/cloud_chatops/README.md b/cloud_chatops/README.md deleted file mode 100644 index ce14c232..00000000 --- a/cloud_chatops/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# Cloud Slack Workspace App -## About -Using Slack's Bolt for Python library here I have developed a Slack Application currently running in the Cloud Slack Workspace.
-This application is designed to help promote the closing of GitHub pull requests either by getting them approved and merged or closed when they go stale.
-In principle, the app will notify authors about their pull requests until they are closed.
-### Deployment -The entry point for is in [main.py](src/main.py) which will run the application.
-The required files (below) need to be in the [cloud_chatops](.) directory.
-### Functionality -As of current, the application gets all open pull requests from any Cloud owned repository and will send a message to our pull-request channel about each pull request notifying the author.
-The app runs on an asynchronous loop scheduling each reminder to be sent out on days of our catch-ups (Monday, Wednesday and Friday).
-On Mondays the application will mention users with **@** as to notify them directly. However, on the other 2 days authors will not be mentioned as to not spam people.
-### Requirements: -The following files need to be present in the working directory of the application.
-- **repos.csv**: A list of repositories owned by `stfc` (e.g. `repo1,repo2,repo3`).
-- **user_map.json**: A dictionary of GitHub usernames to Slack Member IDs (e.g. `{"khalford":"ABC123"}`).
-- **secrets.json**: A dictionary of token names to token values (e.g. `{"SLACK_APP_TOKEN":"123ABC"}`).
-- **maintainer.txt**: A text file containing the maintainer users Slack Member ID. - -For `secrets.json` the following token structure can be used:
-```json -{ - "SLACK_BOT_TOKEN": "ABC123", - "SLACK_APP_TOKEN": "CDE456", - "GITHUB_TOKEN": "FGH789", -} -``` diff --git a/cloud_chatops/docker-compose.yaml b/cloud_chatops/docker-compose.yaml deleted file mode 100644 index 6d4223f5..00000000 --- a/cloud_chatops/docker-compose.yaml +++ /dev/null @@ -1,11 +0,0 @@ -services: - cloud_chatops: - image: harbor.stfc.ac.uk/stfc-cloud/cloud-chatops - volumes: - - $HOME/cloud_chatops_secrets/:/usr/src/app/cloud_chatops_secrets/ - watchtower: - image: containrrr/watchtower - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - $HOME/.docker/config.json:/config.json - command: --interval 60 diff --git a/cloud_chatops/pytest.ini b/cloud_chatops/pytest.ini deleted file mode 100644 index 3b937249..00000000 --- a/cloud_chatops/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -pythonpath = src -testpaths = tests -python_files = *.py -python_functions = test_* diff --git a/cloud_chatops/requirements.txt b/cloud_chatops/requirements.txt deleted file mode 100644 index b51826df..00000000 --- a/cloud_chatops/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -slack-sdk -schedule -slack-bolt -aiohttp -requests -pytest -pytest-cov -pylint -black \ No newline at end of file diff --git a/cloud_chatops/src/__init__.py b/cloud_chatops/src/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cloud_chatops/src/custom_exceptions.py b/cloud_chatops/src/custom_exceptions.py deleted file mode 100644 index b70ad511..00000000 --- a/cloud_chatops/src/custom_exceptions.py +++ /dev/null @@ -1,29 +0,0 @@ -"""This module contains custom exceptions to handle errors for the Application.""" - - -class RepoNotFound(LookupError): - """Error: The requested repository does not exist on GitHub.""" - - -class UnknownHTTPError(RuntimeError): - """Error: The received HTTP response is unexpected.""" - - -class RepositoriesNotGiven(RuntimeError): - """Error: repos.csv does not contain any repositories.""" - - -class TokensNotGiven(RuntimeError): - """Error: Token values are either empty or not given.""" - - -class UserMapNotGiven(RuntimeError): - """Error: User map is empty.""" - - -class BadGitHubToken(RuntimeError): - """Error: GitHub REST Api token is invalid.""" - - -class ChannelNotFound(LookupError): - """Error: The channel was not found.""" diff --git a/cloud_chatops/src/enum_states.py b/cloud_chatops/src/enum_states.py deleted file mode 100644 index 0d051168..00000000 --- a/cloud_chatops/src/enum_states.py +++ /dev/null @@ -1,9 +0,0 @@ -# pylint: disable=C0114 -from enum import Enum, auto - - -class PRsFoundState(Enum): - """This Enum provides states for whether any PRs were found.""" - - PRS_FOUND = auto() - NONE_FOUND = auto() diff --git a/cloud_chatops/src/features/__init__.py b/cloud_chatops/src/features/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cloud_chatops/src/features/commands.py b/cloud_chatops/src/features/commands.py deleted file mode 100644 index 753cf912..00000000 --- a/cloud_chatops/src/features/commands.py +++ /dev/null @@ -1,186 +0,0 @@ -"""This module handles the posting of messages to Slack using the Slack SDK WebClient class.""" - -from typing import List -from datetime import datetime, timedelta -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from enum_states import PRsFoundState -from read_data import get_token, get_user_map, get_repos -from get_github_prs import GetGitHubPRs -from pr_dataclass import PrData - -# If the PR author is not in the Slack ID mapping -# then we set the user to mention as David Fairbrother -# as the team lead to deal with this PR. -DEFAULT_AUTHOR = "U01JG0LKU3W" -DEFAULT_CHANNEL = "dev-chatops" - - -class PostToDMs: - """ - This class handles the Slack posting. - """ - - def __init__(self): - super().__init__() - self.repos = get_repos() - self.client = WebClient(token=get_token("SLACK_BOT_TOKEN")) - self.slack_ids = get_user_map() - self.prs = GetGitHubPRs(get_repos(), "stfc").run() - self.channel = DEFAULT_CHANNEL - self.thread_ts = None - - def run(self, channel: str, post_all: bool) -> None: - """ - This method calls the functions to post the reminder message and further PR messages. - :param channel: The users channel ID to post to. - :param post_all: To post all PRs found or only ones authored by the user. - """ - self.channel = channel - self.post_reminder_message() - self.post_thread_messages(self.prs, post_all) - - def post_reminder_message(self) -> WebClient.chat_postMessage: - """ - This method posts the main reminder message to start the thread if PR notifications. - :return: The reminder message return object - """ - reminder = self.client.chat_postMessage( - channel=self.channel, - text="Here are the outstanding PRs as of today:", - ) - self.thread_ts = reminder.data["ts"] - self.channel = reminder.data["channel"] - return reminder - - def post_thread_messages(self, prs: List[PrData], post_all: bool) -> None: - """ - This method iterates through each PR and calls the post method for them. - :param post_all: To post all prs or user only prs. - :param prs: A list of PRs from GitHub - """ - prs_posted = PRsFoundState.NONE_FOUND - for pr in prs: - checked_pr = self.check_pr(pr) - prs_posted = self.filter_thread_message(checked_pr, post_all) - - if prs_posted == PRsFoundState.NONE_FOUND: - self.send_no_prs() - - def filter_thread_message(self, info: PrData, post_all: bool) -> PRsFoundState: - """ - This method filters which pull requests to send to the thread dependent on the value of personal_thread. - If personal_thread holds a value, only PRs authored by that user will be sent to the thread. - Else, all the PRs will be sent. - :param post_all: To post all PRs or user specific PRs - :param info: The PR info to send in a message. - :return: Returns an Enum state. - """ - pr_author = info.user - slack_member = self.channel - if post_all: - self.send_thread(info) - return PRsFoundState.PRS_FOUND - if not post_all and pr_author == slack_member: - self.send_thread(info) - return PRsFoundState.PRS_FOUND - return PRsFoundState.NONE_FOUND - - def send_no_prs(self) -> None: - """ - This method sends a message to the user that they have no PRs open. - This method is only called if no other PRs have been mentioned. - :param reminder: The thread message to send under. - """ - self.client.chat_postMessage( - text="There are no outstanding pull requests open.", - channel=self.channel, - thread_ts=self.thread_ts, - unfurl_links=False, - ) - - def check_pr(self, info: PrData) -> PrData: - """ - This method validates certain information in the PR data such as who authored the PR and if it's old or not. - :param info: The information to validate. - :return: The validated information. - """ - if info.user not in self.slack_ids: - info.user = DEFAULT_AUTHOR - else: - info.user = self._github_to_slack_username(info.user) - opened_date = datetime.fromisoformat(info.created_at).replace(tzinfo=None) - datetime_now = datetime.now().replace(tzinfo=None) - time_cutoff = datetime_now - timedelta(days=30 * 6) - if opened_date < time_cutoff: - info.old = True - del info.created_at - return info - - def send_thread(self, pr: PrData) -> None: - """ - This method sends the thread message and prepares the reactions. - :param pr: PR dataclass - """ - message = self.construct_message(pr.pr_title, pr.user, pr.url, pr.old) - response = self.client.chat_postMessage( - text=message, - channel=self.channel, - thread_ts=self.thread_ts, - unfurl_links=False, - ) - assert response["ok"] - pr.thread_ts = response.data["ts"] - self.send_thread_react(pr) - - def send_thread_react(self, pr: PrData) -> None: - """ - This method sends reactions to the PR message if necessary. - """ - react_with = [] - if pr.old: - react_with.append("alarm_clock") - if pr.draft: - react_with.append("scroll") - for react in react_with: - react_response = self.client.reactions_add( - channel=self.channel, name=react, timestamp=pr.thread_ts - ) - assert react_response["ok"] - - def construct_message(self, pr_title: str, user: str, url: str, old: bool) -> str: - """ - This method constructs the PR message depending on if the PR is old and if the message should mention or not. - :param pr_title: The title of the PR. - :param user: The author of the PR. - :param url: The URL of the PR. - :param old: If the PR is older than 6 months. - :return: - """ - message = [] - if old: - message.append("*This PR is older than 6 months. Consider closing it:*") - message.append(f"Pull Request: <{url}|{pr_title}>") - name = self._get_real_name(user) - message.append(f"Author: {name}") - return "\n".join(message) - - def _get_real_name(self, username: str) -> str: - """ - This method uses the Slack client method to get the real name of a user and returns it. - :param username: The user ID to look for - :return: Returns the real name or if not found the name originally parsed in - """ - try: - name = self.client.users_profile_get(user=username)["profile"]["real_name"] - except SlackApiError: - name = username - return name - - def _github_to_slack_username(self, user: str) -> str: - """ - This method checks if we have a Slack id for the GitHub user and returns it. - :param user: GitHub username to check for - :return: Slack ID or GitHub username - """ - return self.slack_ids[user] if user in self.slack_ids else user diff --git a/cloud_chatops/src/features/pr_reminder.py b/cloud_chatops/src/features/pr_reminder.py deleted file mode 100644 index 99b36c58..00000000 --- a/cloud_chatops/src/features/pr_reminder.py +++ /dev/null @@ -1,215 +0,0 @@ -"""This module handles the posting of messages to Slack using the Slack SDK WebClient class.""" - -from typing import List -from datetime import datetime, timedelta -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from read_data import get_token, get_user_map, get_repos -from get_github_prs import GetGitHubPRs -from pr_dataclass import PrData -from custom_exceptions import ChannelNotFound - -DEFAULT_CHANNEL = "C06U37Y02R4" # STFC-cloud: dev-chatops -# If the PR author is not in the Slack ID mapping -# then we set the user to mention as David Fairbrother -# as the team lead to deal with this PR. -DEFAULT_AUTHOR = "U01JG0LKU3W" - - -class PostPRsToSlack: - # pylint: disable=R0903 - # Disabling this as there only needs to be one entry point. - """ - This class handles the Slack posting. - """ - - def __init__(self, mention=False): - self.channel = DEFAULT_CHANNEL - self.thread_ts = "" - self.mention = mention - self.slack_ids = get_user_map() - self.message_builder = PRMessageBuilder(self.mention) - self.client = WebClient(token=get_token("SLACK_BOT_TOKEN")) - self.prs = GetGitHubPRs(get_repos(), "stfc").run() - - def run(self, channel=None) -> None: - """ - This method sets class attributes then cals the reminder and thread post methods. - :param channel: Changes the channel to post the messages to. - """ - if channel: - self._set_channel_id(channel) - - self._post_reminder_message() - self._post_thread_messages(self.prs) - - def _post_reminder_message(self) -> None: - """ - This method posts the main reminder message to start the thread if PR notifications. - """ - response = self.client.chat_postMessage( - channel=self.channel, - text="Here are the outstanding PRs as of today:", - ) - self.thread_ts = response.data["ts"] - - def _post_thread_messages(self, prs: List[PrData]) -> None: - """ - This method iterates through each PR and calls the post method for them. - :param prs: A list of PRs from GitHub - """ - prs_found = False - for pr in prs: - prs_found = True - response = self._send_thread(pr) - self._send_thread_react(pr, response.data["ts"]) - - if not prs_found: - self._send_no_prs_found() - - def _send_thread(self, pr_data: PrData) -> WebClient.chat_postMessage: - """ - This method sends the message and returns the response. - :param pr_data: The PR data as a dataclass - :return: The message response. - """ - message = self.message_builder.make_message(pr_data) - response = self.client.chat_postMessage( - text=message, - channel=self.channel, - thread_ts=self.thread_ts, - unfurl_links=False, - ) - assert response["ok"] - return response - - def _send_thread_react(self, pr_data: PrData, message_ts: str) -> None: - """ - This method sends reactions to the PR message if necessary. - :param pr_data: The PR info - :param message_ts: The timestamp of the message to react to - """ - reactions = { - "old": "alarm_clock", - "draft": "scroll", - } - for react, react_id in reactions.items(): - if getattr(pr_data, react): - react_response = self.client.reactions_add( - channel=self.channel, name=react_id, timestamp=message_ts - ) - assert react_response["ok"] - - def _send_no_prs_found(self) -> None: - """ - This method sends a message to the chat that there are no PRs. - """ - self.client.chat_postMessage( - text="There are no outstanding pull requests open.", - channel=self.channel, - thread_ts=self.thread_ts, - unfurl_links=False, - ) - - def _set_channel_id(self, channel_name: str) -> None: - """ - This method will get the channel id from the channel name and set the attrribute to the class. - This is necessary as the chat.postMessage method takes name or id but reactions.add only takes id. - This method also acts as a channel verif - :param channel_name: The channel name to lookup - :return: - """ - channels = self.client.conversations_list(types="private_channel")["channels"] - channel_obj = next( - (channel for channel in channels if channel["name"] == channel_name), None - ) - if channel_obj: - self.channel = channel_obj["id"] - else: - raise ChannelNotFound( - f"The channel {channel_name} could not be found. Check the bot is a member of the channel.\n" - f' You can use "/invite @Cloud ChatOps" to invite the app to your channel.' - ) - - -class PRMessageBuilder: - """This class handles constructing the PR messages to be sent.""" - - # pylint: disable=R0903 - # Disabling this as there only needs to be one entry point. - def __init__(self, mention): - self.client = WebClient(token=get_token("SLACK_BOT_TOKEN")) - self.slack_ids = get_user_map() - self.mention = mention - - def make_message(self, pr_data: PrData) -> str: - """ - This method checks the pr data and makes a string message from it. - :param pr_data: The PR info - :return: The message to post - """ - checked_info = self._check_pr_info(pr_data) - return self._construct_string(checked_info) - - def _construct_string(self, pr_data: PrData) -> str: - """ - This method constructs the PR message depending on if the PR is old and if the message should mention or not. - :param pr_data: The data class containing the info about the PR. - :return: The message as a single string. - """ - message = [] - if pr_data.old: - message.append("*This PR is older than 6 months. Consider closing it:*") - message.append(f"Pull Request: <{pr_data.url}|{pr_data.pr_title}>") - if self.mention and not pr_data.draft: - message.append(f"Author: <@{pr_data.user}>") - else: - name = self._get_real_name(pr_data.user) - message.append(f"Author: {name}") - return "\n".join(message) - - def _get_real_name(self, username: str) -> str: - """ - This method uses the Slack client method to get the real name of a user and returns it. - :param username: The user ID to look for - :return: Returns the real name or if not found the name originally parsed in - """ - try: - name = self.client.users_profile_get(user=username)["profile"]["real_name"] - except SlackApiError: - name = username - return name - - def _github_to_slack_username(self, user: str) -> str: - """ - This method checks if we have a Slack id for the GitHub user and returns it. - :param user: GitHub username to check for - :return: Slack ID or GitHub username - """ - if user not in self.slack_ids: - user = DEFAULT_AUTHOR - else: - user = self.slack_ids[user] - return user - - @staticmethod - def _check_pr_age(time_created: str) -> bool: - """ - This method checks if the PR is older than 6 months. - :param time_created: The date the PR was created. - :return: PR older or not. - """ - opened_date = datetime.fromisoformat(time_created).replace(tzinfo=None) - datetime_now = datetime.now().replace(tzinfo=None) - time_cutoff = datetime_now - timedelta(days=30 * 6) - return opened_date < time_cutoff - - def _check_pr_info(self, info: PrData) -> PrData: - """ - This method validates certain information in the PR data such as who authored the PR and if it's old or not. - :param info: The information to validate. - :return: The validated information. - """ - info.user = self._github_to_slack_username(info.user) - info.old = self._check_pr_age(info.created_at) - return info diff --git a/cloud_chatops/src/get_github_prs.py b/cloud_chatops/src/get_github_prs.py deleted file mode 100644 index 5faeb162..00000000 --- a/cloud_chatops/src/get_github_prs.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -This module handles the HTTP requests and formatting to the GitHub REST Api. -It will get all open pull requests in provided repositories. -""" - -from typing import List, Dict -import requests -from read_data import get_token -from pr_dataclass import PrData -from custom_exceptions import ( - RepoNotFound, - UnknownHTTPError, - BadGitHubToken, -) - - -class GetGitHubPRs: - # pylint: disable=R0903 - # Disabling this as in the future there is likely to be more public functions. - """ - This class handles getting the open PRs from the GitHub Rest API. - """ - - def __init__(self, repos: List[str], owner: str): - """ - This method initialises the class with the following attributes. - :param repos: A list of repositories to get pull requests for. - :param repos: The owner of the above repositories. - """ - self.repos = repos - self.owner = owner - self._http_handler = HTTPHandler() - - def run(self) -> List[PrData]: - """ - This method is the entry point to the class. - It runs the HTTP request methods and returns the responses. - :return: The responses from the HTTP requests. - """ - responses = self._request_all_repos_http() - return self._parse_pr_to_dataclass(responses) - - def _request_all_repos_http(self) -> List[Dict]: - """ - This method starts a request for each repository and returns a list of those PRs. - :return: A dictionary of repos and their PRs. - """ - responses = [] - for repo in self.repos: - url = f"https://api.github.com/repos/{self.owner}/{repo}/pulls" - responses += self._http_handler.make_request(url) - return responses - - @staticmethod - def _parse_pr_to_dataclass(responses: List[Dict]) -> List[PrData]: - """ - This module converts the responses from the HTTP request into Dataclasses to be more easily handled. - :param responses: List of responses made from HTTP requests - :return: Responses in dataclasses - """ - responses_dataclasses = [] - for pr in responses: - responses_dataclasses.append( - PrData( - pr_title=f"{pr['title']} #{pr['number']}", - user=pr["user"]["login"], - url=pr["html_url"], - created_at=pr["created_at"], - draft=pr["draft"], - ) - ) - return responses_dataclasses - - -class HTTPHandler: - """ - This class makes the HTTP requests to the GitHub REST API. - """ - - def make_request(self, url: str) -> List[Dict]: - """ - This method gets the HTTP response from the URL given and returns the response as a list. - :param url: The URL to make the HTTP request to. - :return: List of PRs. - """ - response = self.get_http_response(url) - return [response] if isinstance(response, dict) else response - - def get_http_response(self, url: str) -> List[Dict]: - """ - This method sends a HTTP request to the GitHub Rest API endpoint and returns all open PRs from that repository. - :param url: The URL to make the request to - :return: The response in JSON form - """ - headers = {"Authorization": "token " + get_token("GITHUB_TOKEN")} - response = requests.get(url, headers=headers, timeout=60) - self._validate_response(response) - return response.json() - - @staticmethod - def _validate_response(response: requests.get) -> None: - """ - This method checks the status code of the HTTP response and handles exceptions accordingly. - :param response: The response to check. - """ - match response.status_code: - case 200: - pass - case 401: - raise BadGitHubToken( - "Your GitHub api token is invalid. Check that it hasn't expired." - ) - case 404: - raise RepoNotFound( - f'The repository at the url "{response.url}" could not be found.' - ) - - case _: - raise UnknownHTTPError( - f"The HTTP response code is unknown and cannot be handled. Response: {response.status_code}" - ) diff --git a/cloud_chatops/src/main.py b/cloud_chatops/src/main.py deleted file mode 100644 index b447153b..00000000 --- a/cloud_chatops/src/main.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -This module starts the Slack Bolt application Asynchronously running the event loop. -Using Socket mode, the application listens for events from the Slack API client. -""" - -import logging -import asyncio -from slack_bolt.app.async_app import AsyncApp -from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler -import schedule -from features.pr_reminder import PostPRsToSlack -from features.commands import PostToDMs -from read_data import get_token, validate_required_files - - -logging.basicConfig(level=logging.DEBUG) -app = AsyncApp(token=get_token("SLACK_BOT_TOKEN")) - - -@app.event("message") -async def handle_message_events(body, logger): - """This method handles message events and logs them.""" - logger.info(body) - - -@app.command("/prs") -async def remind_prs(ack, respond, command): - """ - This function calls the messaging method to notify a user about their open PRs or all open PRs if asked. - :param command: The return object from Slack API. - :param ack: Slacks acknowledgement command. - :param respond: Slacks respond command to respond to the command in chat. - """ - await ack() - channel = command["user_id"] - if command["text"] == "mine": - await respond("Gathering the PRs...") - PostToDMs().run(channel, False) - elif command["text"] == "all": - await respond("Gathering the PRs...") - PostToDMs().run(channel, True) - else: - await respond("Please provide the correct argument: 'mine' or 'all'.") - return - - await respond("Check out your DMs.") - - -async def schedule_jobs() -> None: - """ - This function schedules tasks for the async loop to run. - """ - - def run_pr(channel, mention=False) -> None: - """ - This is a placeholder function for the schedule to accept. - """ - PostPRsToSlack(mention=mention).run(channel=channel) - - schedule.every().monday.at("09:00").do( - run_pr, mention=True, channel="pull-requests" - ) - - while True: - schedule.run_pending() - await asyncio.sleep(10) - - -async def run_app() -> None: - """ - This function is the main entry point for the application. First, it validates the required files. - Then it starts the async loop and runs the scheduler. - """ - validate_required_files() - asyncio.ensure_future(schedule_jobs()) - handler = AsyncSocketModeHandler(app, get_token("SLACK_APP_TOKEN")) - await handler.start_async() - - -def main(): - """This method is the entry point if using this package.""" - asyncio.run(run_app()) - - -if __name__ == "__main__": - asyncio.run(run_app()) diff --git a/cloud_chatops/src/pr_dataclass.py b/cloud_chatops/src/pr_dataclass.py deleted file mode 100644 index 29f6981d..00000000 --- a/cloud_chatops/src/pr_dataclass.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -This module declares the dataclass used to store PR information. -This is preferred over dictionaries as dataclasses make code more readable. -""" - -from dataclasses import dataclass - - -@dataclass -class PrData: - """Class holding information about a single pull request.""" - - pr_title: str - user: str - url: str - created_at: str - draft: bool - old: bool = False diff --git a/cloud_chatops/src/read_data.py b/cloud_chatops/src/read_data.py deleted file mode 100644 index aff2f06c..00000000 --- a/cloud_chatops/src/read_data.py +++ /dev/null @@ -1,94 +0,0 @@ -"""This module handles reading data from files such as secrets and user maps.""" - -from typing import List, Dict -import sys -import os -import json -from custom_exceptions import ( - RepositoriesNotGiven, - UserMapNotGiven, - TokensNotGiven, -) - -PATH = "/usr/src/app/cloud_chatops_secrets/" -try: - if sys.argv[1] == "local": - PATH = f"{os.environ['HOME']}/cloud_chatops_secrets/" -except IndexError: - pass -except KeyError: - print( - "Are you trying to run locally? Couldn't find HOME in your environment variables." - ) - sys.exit() - - -def validate_required_files() -> None: - """ - This function checks that all required files have data in them before the application runs. - """ - repos = get_repos() - if not repos: - raise RepositoriesNotGiven("repos.csv does not contain any repositories.") - - tokens = ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "GITHUB_TOKEN", "INFLUX_TOKEN"] - for token in tokens: - temp = get_token(token) - if not temp: - raise TokensNotGiven( - f"Token {token} does not have a value in secrets.json." - ) - user_map = get_user_map() - if not user_map: - raise UserMapNotGiven("user_map.json is empty.") - for item, value in user_map.items(): - if not value: - raise UserMapNotGiven(f"User {item} does not have a Slack ID assigned.") - - -def get_token(secret: str) -> str: - """ - This function will read from the secrets file and return a specified secret. - :param secret: The secret to find - :return: A secret as string - """ - with open(PATH + "secrets.json", "r", encoding="utf-8") as file: - data = file.read() - secrets = json.loads(data) - return secrets[secret] - - -def get_repos() -> List[str]: - """ - This function reads the repo csv file and returns a list of repositories - :return: List of repositories as strings - """ - with open(PATH + "repos.csv", "r", encoding="utf-8") as file: - data = file.read() - repos = data.split(",") - if not repos[-1]: - repos = repos[:-1] - return repos - - -def get_user_map() -> Dict: - """ - This function gets the GitHub to Slack username mapping from the map file. - :return: Dictionary of username mapping - """ - with open(PATH + "user_map.json", "r", encoding="utf-8") as file: - data = file.read() - user_map = json.loads(data) - return user_map - - -def get_maintainer() -> str: - """ - This function will get the maintainer user's Slack ID from the text file. - :return: Slack Member ID - """ - with open(PATH + "maintainer.txt", "r", encoding="utf-8") as file: - data = file.read() - if not data: - return "U05RBU0RF4J" # Default Maintainer: Kalibh Halford - return data diff --git a/cloud_chatops/tests/__init__.py b/cloud_chatops/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/cloud_chatops/tests/test_read_data.py b/cloud_chatops/tests/test_read_data.py deleted file mode 100644 index 72d5084e..00000000 --- a/cloud_chatops/tests/test_read_data.py +++ /dev/null @@ -1,50 +0,0 @@ -"""This test file covers all tests for the read_data module.""" - -from unittest.mock import patch, mock_open -from read_data import get_token, get_repos, get_user_map, get_maintainer - - -def test_get_token(): - """This test checks that a value is returned when the function is called with a specific token.""" - with patch( - "builtins.open", mock_open(read_data='{"mock_token_1": "mock_value_1"}') - ): - res = get_token("mock_token_1") - assert res == "mock_value_1" - - -def test_get_user_map(): - """This test ensures that the JSON file is read and converted to a dictionary correctly.""" - with patch("builtins.open", mock_open(read_data='{"mock_user_1": "mock_id_1"}')): - res = get_user_map() - assert res == {"mock_user_1": "mock_id_1"} - - -def test_get_repos(): - """This test checks that a list is returned if a string list of repos is read with no comma at the end.""" - with patch("builtins.open", mock_open(read_data="repo1,repo2")): - res = get_repos() - assert res == ["repo1", "repo2"] - - -def test_get_repos_trailing_separator(): - """ - This test checks that a list of repos is returned correctly if there is a trailing comma at the end of the list. - """ - with patch("builtins.open", mock_open(read_data="repo1,repo2,")): - res = get_repos() - assert res == ["repo1", "repo2"] - - -def test_get_maintainer(): - """This test checks that the user's name is returned.""" - with patch("builtins.open", mock_open(read_data="mock_person")): - res = get_maintainer() - assert res == "mock_person" - - -def test_get_maintainer_no_value(): - """This test checks that the defualt user ID is returned if maintainer.txt is empty.""" - with patch("builtins.open", mock_open(read_data="")): - res = get_maintainer() - assert res == "U05RBU0RF4J" # Default Maintainer: Kalibh Halford diff --git a/cloud_chatops/version.txt b/cloud_chatops/version.txt deleted file mode 100644 index afaf360d..00000000 --- a/cloud_chatops/version.txt +++ /dev/null @@ -1 +0,0 @@ -1.0.0 \ No newline at end of file