From 3fb53f3dde9e67da6fcec0bd763a4a0fa7351578 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 12 Jan 2024 17:06:08 -0500 Subject: [PATCH 01/45] eod commit --- .gitignore | 4 +++ app.py | 56 ++++------------------------- listeners/__init__.py | 6 ++++ listeners/events/__init__.py | 7 ++++ listeners/events/app_home_open.py | 27 ++++++++++++++ listeners/functions/__init__.py | 7 ++++ listeners/functions/create_issue.py | 23 ++++++++++++ manifest.json | 18 +++++++--- state/__init__.py | 31 ++++++++++++++++ 9 files changed, 125 insertions(+), 54 deletions(-) create mode 100644 listeners/__init__.py create mode 100644 listeners/events/__init__.py create mode 100644 listeners/events/app_home_open.py create mode 100644 listeners/functions/__init__.py create mode 100644 listeners/functions/create_issue.py create mode 100644 state/__init__.py diff --git a/.gitignore b/.gitignore index 82c3c55..35d3ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ tmp.txt logs/ *.db .pytype/ + +#tmp +requirements.txt +.slack diff --git a/app.py b/app.py index ea7d39e..eddf1ff 100644 --- a/app.py +++ b/app.py @@ -1,58 +1,16 @@ -import os import logging -from slack_bolt import Ack, App, BoltContext, Say, Complete, Fail +import os + +from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -from slack_sdk import WebClient + +from listeners import register_listeners app = App(token=os.environ.get("SLACK_BOT_TOKEN")) logging.basicConfig(level=logging.DEBUG) - -@app.function("sample_function") -def handle_sample_function_event(inputs: dict, say: Say, fail: Fail, logger: logging.Logger): - user_id = inputs["user_id"] - - try: - say( - channel=user_id, # sending a DM to this user - text="Click button to complete function!", - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": "Click button to complete function!"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Click me!"}, - "action_id": "sample_click", - }, - } - ], - ) - except Exception as e: - logger.exception(e) - fail(f"Failed to handle a function request (error: {e})") - - -@app.action("sample_click") -def handle_sample_click( - ack: Ack, body: dict, context: BoltContext, client: WebClient, complete: Complete, fail: Fail, logger: logging.Logger -): - ack() - - try: - # Since the button no longer works, we should remove it - client.chat_update( - channel=context.channel_id, - ts=body["message"]["ts"], - text="Congrats! You clicked the button", - ) - - # Signal that the function completed successfully - complete({"user_id": context.actor_user_id}) - except Exception as e: - logger.exception(e) - fail(f"Failed to handle a function request (error: {e})") - +# Register Listeners +register_listeners(app) if __name__ == "__main__": SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).start() diff --git a/listeners/__init__.py b/listeners/__init__.py new file mode 100644 index 0000000..e304ff7 --- /dev/null +++ b/listeners/__init__.py @@ -0,0 +1,6 @@ +from listeners import events, functions + + +def register_listeners(app): + functions.register(app) + events.register(app) diff --git a/listeners/events/__init__.py b/listeners/events/__init__.py new file mode 100644 index 0000000..df6d129 --- /dev/null +++ b/listeners/events/__init__.py @@ -0,0 +1,7 @@ +from slack_bolt import App + +from .app_home_open import app_home_open_callback + + +def register(app: App): + app.event("app_home_opened")(app_home_open_callback) diff --git a/listeners/events/app_home_open.py b/listeners/events/app_home_open.py new file mode 100644 index 0000000..094cec4 --- /dev/null +++ b/listeners/events/app_home_open.py @@ -0,0 +1,27 @@ +from logging import Logger + +from slack_sdk import WebClient + + +def app_home_open_callback(client: WebClient, event: dict, logger: Logger): + # ignore the app_home_opened event for anything but the Home tab + if event["tab"] != "home": + return + try: + client.views_publish( + user_id=event["user"], + view={ + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Welcome home, <@" + event["user"] + "> :house:*", + }, + }, + ], + }, + ) + except Exception as e: + logger.error(f"Error publishing home tab: {e}") diff --git a/listeners/functions/__init__.py b/listeners/functions/__init__.py new file mode 100644 index 0000000..df54840 --- /dev/null +++ b/listeners/functions/__init__.py @@ -0,0 +1,7 @@ +from slack_bolt import App + +from .create_issue import create_issue_callback + + +def register(app: App): + app.event("create_issue")(create_issue_callback) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py new file mode 100644 index 0000000..b820b51 --- /dev/null +++ b/listeners/functions/create_issue.py @@ -0,0 +1,23 @@ +import logging + +from slack_bolt import Complete, Fail, Say + + +def create_issue_callback(inputs: dict, say: Say, fail: Fail, complete: Complete, logger: logging.Logger): + user_id = inputs["user_id"] + + try: + say( + channel=user_id, # sending a DM to this user + text="Click button to complete function!", + blocks=[ + { + "type": "section", + "text": {"type": "mrkdwn", "text": "Click button to complete function!"}, + } + ], + ) + complete({"user_id": user_id}) + except Exception as e: + logger.exception(e) + fail(f"Failed to handle a function request (error: {e})") diff --git a/manifest.json b/manifest.json index 90d74c0..330744e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,23 +1,31 @@ { "display_information": { - "name": "BoltPy Automation Template" + "name": "BoltPy Jira Functions" }, "outgoing_domains": [], "settings": { "org_deploy_enabled": true, - "socket_mode_enabled": true + "socket_mode_enabled": true, + "event_subscriptions": { + "bot_events": [ + "app_home_opened" + ] + } }, "features": { "bot_user": { - "display_name": "BoltPy Automation Template" + "display_name": "BoltPy Jira Functions" }, "app_home": { - "messages_tab_enabled": true + "messages_tab_enabled": false, + "home_tab_enabled": true } }, "oauth_config": { "scopes": { - "bot": ["chat:write"] + "bot": [ + "chat:write" + ] } }, "functions": { diff --git a/state/__init__.py b/state/__init__.py new file mode 100644 index 0000000..b5b908a --- /dev/null +++ b/state/__init__.py @@ -0,0 +1,31 @@ +from typing import Dict + + +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + """ + Possible changes to the value of the `__init__` argument do not affect + the returned instance. + """ + if cls not in cls._instances: + instance = super().__call__(*args, **kwargs) + cls._instances[cls] = instance + return cls._instances[cls] + + +class State(metaclass=Singleton): + _users: Dict[str, str] = {} + + def create_user(self, username: str) -> None: + self._users[username] = None + + def update_user(self, username: str) -> None: + self._users[username] = None + + def read_user(self, username: str) -> str: + return self._users[username] + + def delete_user(self, username: str) -> None: + self._users.pop(username) From 6bb085ca64a4765f1c1f541da9f83b293ba8f829 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 15 Jan 2024 15:21:39 -0500 Subject: [PATCH 02/45] Introduce logic for PAT --- app.py | 2 +- controllers/__init__.py | 4 + controllers/app_home_builder.py | 76 +++++++++++++++++++ .../personal_access_token_table.py | 18 ++--- listeners/__init__.py | 3 +- listeners/actions/__init__.py | 8 ++ listeners/actions/clear_pat.py | 22 ++++++ listeners/actions/submit_pat.py | 24 ++++++ listeners/events/app_home_open.py | 25 +++--- 9 files changed, 156 insertions(+), 26 deletions(-) create mode 100644 controllers/__init__.py create mode 100644 controllers/app_home_builder.py rename state/__init__.py => controllers/personal_access_token_table.py (50%) create mode 100644 listeners/actions/__init__.py create mode 100644 listeners/actions/clear_pat.py create mode 100644 listeners/actions/submit_pat.py diff --git a/app.py b/app.py index eddf1ff..5a0def1 100644 --- a/app.py +++ b/app.py @@ -7,7 +7,7 @@ from listeners import register_listeners app = App(token=os.environ.get("SLACK_BOT_TOKEN")) -logging.basicConfig(level=logging.DEBUG) +logging.basicConfig(level=logging.INFO) # Register Listeners register_listeners(app) diff --git a/controllers/__init__.py b/controllers/__init__.py new file mode 100644 index 0000000..84c899c --- /dev/null +++ b/controllers/__init__.py @@ -0,0 +1,4 @@ +from .personal_access_token_table import PersonalAccessTokenTable +from .app_home_builder import AppHomeBuilder + +__all__ = ["PersonalAccessTokenTable", "AppHomeBuilder"] diff --git a/controllers/app_home_builder.py b/controllers/app_home_builder.py new file mode 100644 index 0000000..524c386 --- /dev/null +++ b/controllers/app_home_builder.py @@ -0,0 +1,76 @@ +from typing import TypedDict + + +context = "\ +(PATs) are a secure way to use scripts and integrate external applications with your Atlassian application. To use the\ +functions defined by this app you will need to add your own, click the add button to submit yours." + + +class AppHome(TypedDict): + type: str + blocks: list + + +class AppHomeBuilder: + def __init__(self): + self.view: AppHome = { + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": context, + }, + }, + {"type": "divider"}, + ], + } + + def add_pat_submit_button(self): + self.view["blocks"].append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Submit", + }, + "action_id": "submit_pat", + } + ], + } + ) + + def add_pat_input_field(self): + self.view["blocks"].append( + { + "type": "input", + "block_id": "user_jira_pat_input", + "element": { + "type": "plain_text_input", + "action_id": "user_jira_pat", + "placeholder": {"type": "plain_text", "text": "Enter your personal access token"}, + }, + "label": {"type": "plain_text", "text": "PAT"}, + } + ) + + def add_clear_pat_button(self): + self.view["blocks"].append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Clear PAT", + }, + "action_id": "clear_pat", + } + ], + } + ) diff --git a/state/__init__.py b/controllers/personal_access_token_table.py similarity index 50% rename from state/__init__.py rename to controllers/personal_access_token_table.py index b5b908a..0aa8a1b 100644 --- a/state/__init__.py +++ b/controllers/personal_access_token_table.py @@ -15,17 +15,17 @@ def __call__(cls, *args, **kwargs): return cls._instances[cls] -class State(metaclass=Singleton): +class PersonalAccessTokenTable(metaclass=Singleton): _users: Dict[str, str] = {} - def create_user(self, username: str) -> None: - self._users[username] = None + def create_user(self, user_id: str, personal_access_token: str) -> None: + self._users[user_id] = personal_access_token - def update_user(self, username: str) -> None: - self._users[username] = None + def read_user(self, user_id: str) -> str: + return self._users[user_id] - def read_user(self, username: str) -> str: - return self._users[username] + def delete_user(self, user_id: str) -> None: + self._users.pop(user_id, None) - def delete_user(self, username: str) -> None: - self._users.pop(username) + def __contains__(self, user_id: str) -> bool: + return user_id in self._users diff --git a/listeners/__init__.py b/listeners/__init__.py index e304ff7..6721cbc 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,6 +1,7 @@ -from listeners import events, functions +from listeners import events, functions, actions def register_listeners(app): functions.register(app) events.register(app) + actions.register(app) diff --git a/listeners/actions/__init__.py b/listeners/actions/__init__.py new file mode 100644 index 0000000..bc53cd4 --- /dev/null +++ b/listeners/actions/__init__.py @@ -0,0 +1,8 @@ +from slack_bolt import App +from .submit_pat import submit_pat_callback +from .clear_pat import clear_pat_callback + + +def register(app: App): + app.action("submit_pat")(submit_pat_callback) + app.action("clear_pat")(clear_pat_callback) diff --git a/listeners/actions/clear_pat.py b/listeners/actions/clear_pat.py new file mode 100644 index 0000000..8c9dc51 --- /dev/null +++ b/listeners/actions/clear_pat.py @@ -0,0 +1,22 @@ +from logging import Logger + +from slack_bolt import Ack +from slack_sdk import WebClient + +from controllers import PersonalAccessTokenTable, AppHomeBuilder + + +def clear_pat_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): + try: + ack() + user_id = body["user"]["id"] + + pat_table = PersonalAccessTokenTable() + pat_table.delete_user(user_id=user_id) + + home = AppHomeBuilder() + home.add_pat_input_field() + home.add_pat_submit_button() + client.views_publish(user_id=user_id, view=home.view) + except Exception as e: + logger.error(e) diff --git a/listeners/actions/submit_pat.py b/listeners/actions/submit_pat.py new file mode 100644 index 0000000..c442b82 --- /dev/null +++ b/listeners/actions/submit_pat.py @@ -0,0 +1,24 @@ +from logging import Logger + +from slack_bolt import Ack +from slack_sdk import WebClient + +from controllers import PersonalAccessTokenTable, AppHomeBuilder + + +def submit_pat_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): + try: + ack() + user_id = body["user"]["id"] + + home = AppHomeBuilder() + home.add_clear_pat_button() + client.views_publish(user_id=user_id, view=home.view) + + pat_table = PersonalAccessTokenTable() + pat_table.create_user( + user_id=user_id, + personal_access_token=body["view"]["state"]["values"]["user_jira_pat_input"]["user_jira_pat"]["value"], + ) + except Exception as e: + logger.error(e) diff --git a/listeners/events/app_home_open.py b/listeners/events/app_home_open.py index 094cec4..d425102 100644 --- a/listeners/events/app_home_open.py +++ b/listeners/events/app_home_open.py @@ -2,26 +2,21 @@ from slack_sdk import WebClient +from controllers import AppHomeBuilder, PersonalAccessTokenTable + def app_home_open_callback(client: WebClient, event: dict, logger: Logger): # ignore the app_home_opened event for anything but the Home tab if event["tab"] != "home": return try: - client.views_publish( - user_id=event["user"], - view={ - "type": "home", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Welcome home, <@" + event["user"] + "> :house:*", - }, - }, - ], - }, - ) + home = AppHomeBuilder() + pat_table = PersonalAccessTokenTable() + if event["user"] in pat_table: + home.add_clear_pat_button() + else: + home.add_pat_input_field() + home.add_pat_submit_button() + client.views_publish(user_id=event["user"], view=home.view) except Exception as e: logger.error(f"Error publishing home tab: {e}") From 97e64e3b2955a2ad8f2c76e37a59f87aefb57435 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 15 Jan 2024 17:20:26 -0500 Subject: [PATCH 03/45] Now actually make it work --- .sample.env | 1 + controllers/personal_access_token_table.py | 2 +- listeners/functions/__init__.py | 2 +- listeners/functions/create_issue.py | 58 +++++++++++++++++----- manifest.json | 41 +++++++++++---- 5 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 .sample.env diff --git a/.sample.env b/.sample.env new file mode 100644 index 0000000..9fe4cf9 --- /dev/null +++ b/.sample.env @@ -0,0 +1 @@ +JIRA_BASE_URL=http://localhost:8080 diff --git a/controllers/personal_access_token_table.py b/controllers/personal_access_token_table.py index 0aa8a1b..1be0ea3 100644 --- a/controllers/personal_access_token_table.py +++ b/controllers/personal_access_token_table.py @@ -21,7 +21,7 @@ class PersonalAccessTokenTable(metaclass=Singleton): def create_user(self, user_id: str, personal_access_token: str) -> None: self._users[user_id] = personal_access_token - def read_user(self, user_id: str) -> str: + def read_pat(self, user_id: str) -> str: return self._users[user_id] def delete_user(self, user_id: str) -> None: diff --git a/listeners/functions/__init__.py b/listeners/functions/__init__.py index df54840..3cc0637 100644 --- a/listeners/functions/__init__.py +++ b/listeners/functions/__init__.py @@ -4,4 +4,4 @@ def register(app: App): - app.event("create_issue")(create_issue_callback) + app.function("create_issue")(create_issue_callback) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index b820b51..483bb5b 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -1,23 +1,57 @@ import logging +import os -from slack_bolt import Complete, Fail, Say +from slack_bolt import Complete, Fail, Say, Ack +import requests +from controllers import PersonalAccessTokenTable +import json +JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") + + +# https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ +# https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issues/#api-rest-api-2-issue-post +def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger): + ack() + pat_table = PersonalAccessTokenTable() -def create_issue_callback(inputs: dict, say: Say, fail: Fail, complete: Complete, logger: logging.Logger): user_id = inputs["user_id"] + if user_id not in pat_table: + # TODO send a message to user on how to fix this + return fail(f"User {user_id} has not set up their PAT properly, visit the app home to do this") + try: - say( - channel=user_id, # sending a DM to this user - text="Click button to complete function!", - blocks=[ - { - "type": "section", - "text": {"type": "mrkdwn", "text": "Click button to complete function!"}, - } - ], + project: str = inputs["project"] + issue_type = inputs["issuetype"] + # assignee = inputs["assignee"] + summary = inputs["summary"] + description = inputs["description"] + + url = f"{JIRA_BASE_URL}/rest/api/2/issue" + + headers = { + "Authorization": f"Bearer {pat_table.read_pat(user_id)}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + payload = json.dumps( + { + "fields": { + "description": description, + "issuetype": {"id" if issue_type.isdigit() else "name": issue_type}, + "labels": ["bugfix", "blitz_test"], + "project": {"id" if project.isdigit() else "key": project}, + "summary": summary, + }, + } ) - complete({"user_id": user_id}) + + # response = requests.request("POST", url, data=payload, headers=headers) + + # logger.info(json.loads(response.text)) + complete(outputs={"issue_url": "https://media.giphy.com/media/NEvPzZ8bd1V4Y/giphy.gif"}) except Exception as e: logger.exception(e) fail(f"Failed to handle a function request (error: {e})") diff --git a/manifest.json b/manifest.json index 330744e..f2a8fbd 100644 --- a/manifest.json +++ b/manifest.json @@ -29,9 +29,9 @@ } }, "functions": { - "sample_function": { - "title": "Sample function", - "description": "Runs sample function", + "create_issue": { + "title": "Create an issue", + "description": "Create a JIRA Cloud issue", "input_parameters": { "user_id": { "type": "slack#/types/user_id", @@ -40,15 +40,38 @@ "is_required": true, "hint": "Select a user in the workspace", "name": "user_id" + }, + "project": { + "type": "string", + "title": "Project", + "description": "Project", + "is_required": true + }, + "issuetype": { + "type": "string", + "title": "Issue type", + "description": "Type of issue to create: Bug, Improvement, New Feature, or Epic.", + "is_required": true + }, + "summary": { + "type": "string", + "title": "Summary", + "description": "Summary of the bug or issue...", + "is_required": true + }, + "description": { + "type": "string", + "title": "Description", + "description": "Description of the bug or issue...", + "is_required": true } }, "output_parameters": { - "user_id": { - "type": "slack#/types/user_id", - "title": "User", - "description": "User to used the function", - "is_required": true, - "name": "user_id" + "issue_url": { + "type": "string", + "title": "Issue url", + "description": "Url of the issue that was created", + "is_required": true } } } From ba79ca802ef9ac7cad4fb1341d794a80e11da7fc Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 18 Jan 2024 16:38:57 -0500 Subject: [PATCH 04/45] update --- listeners/actions/submit_pat.py | 1 + listeners/functions/create_issue.py | 39 ++++++++++++++++++---------- tests/functions/__init__.py | 0 tests/functions/test_create_issue.py | 37 ++++++++++++++++++++++++++ tests/utils.py | 11 ++++++++ 5 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 tests/functions/__init__.py create mode 100644 tests/functions/test_create_issue.py create mode 100644 tests/utils.py diff --git a/listeners/actions/submit_pat.py b/listeners/actions/submit_pat.py index c442b82..ff75c8f 100644 --- a/listeners/actions/submit_pat.py +++ b/listeners/actions/submit_pat.py @@ -20,5 +20,6 @@ def submit_pat_callback(ack: Ack, client: WebClient, body: dict, logger: Logger) user_id=user_id, personal_access_token=body["view"]["state"]["values"]["user_jira_pat_input"]["user_jira_pat"]["value"], ) + except Exception as e: logger.error(e) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 483bb5b..42f70eb 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -1,26 +1,26 @@ import logging import os +import sys -from slack_bolt import Complete, Fail, Say, Ack +from slack_bolt import Complete, Fail, Ack import requests from controllers import PersonalAccessTokenTable import json -JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") - # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ # https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issues/#api-rest-api-2-issue-post -def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger): +def create_issue_callback( + ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger, pat_table=PersonalAccessTokenTable() +): ack() - pat_table = PersonalAccessTokenTable() + JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") user_id = inputs["user_id"] if user_id not in pat_table: # TODO send a message to user on how to fix this return fail(f"User {user_id} has not set up their PAT properly, visit the app home to do this") - try: project: str = inputs["project"] issue_type = inputs["issuetype"] @@ -28,12 +28,12 @@ def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete summary = inputs["summary"] description = inputs["description"] - url = f"{JIRA_BASE_URL}/rest/api/2/issue" + url = f"{JIRA_BASE_URL}rest/api/2/issue" headers = { "Authorization": f"Bearer {pat_table.read_pat(user_id)}", - "Accept": "application/json", - "Content-Type": "application/json", + "Accept": "application/json;charset=UTF-8", + "Content-Type": "application/json;charset=UTF-8", } payload = json.dumps( @@ -41,17 +41,30 @@ def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete "fields": { "description": description, "issuetype": {"id" if issue_type.isdigit() else "name": issue_type}, - "labels": ["bugfix", "blitz_test"], "project": {"id" if project.isdigit() else "key": project}, "summary": summary, }, } ) - # response = requests.request("POST", url, data=payload, headers=headers) + print(payload) + + response = requests.get( + url, + data=payload, + headers=headers, + ) + + logger.info(response) - # logger.info(json.loads(response.text)) - complete(outputs={"issue_url": "https://media.giphy.com/media/NEvPzZ8bd1V4Y/giphy.gif"}) + print(response.status_code) + for k, v in response.headers.items(): + print(f"{k}: {v}") + # logger.info(response.json()) + logger.info(response.text) + jason_data = json.loads(response.text) + logger.info(jason_data) + complete(outputs={"issue_url": jason_data["self"]}) except Exception as e: logger.exception(e) fail(f"Failed to handle a function request (error: {e})") diff --git a/tests/functions/__init__.py b/tests/functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py new file mode 100644 index 0000000..c8694d9 --- /dev/null +++ b/tests/functions/test_create_issue.py @@ -0,0 +1,37 @@ +import logging +import os +from unittest.mock import MagicMock + +from controllers import PersonalAccessTokenTable +from listeners.functions.create_issue import create_issue_callback + + +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestCreateIssue: + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + + def teardown_method(self): + restore_os_env(self.old_os_env) + + def test_create_issue(self, caplog): + mock_ack = MagicMock(retrun_value=None) + mock_fail = MagicMock() + mock_complete = MagicMock() + mock_logger = logging.getLogger() + mock_input = { + "user_id": "me", + "project": "HERMES", + "issuetype": "BUG", + "summary": "this is a test from python", + "description": "this is a test from python", + } + + os.environ["JIRA_BASE_URL"] = "https://jira-dev.tinyspeck.com/" + mock_pat_table = PersonalAccessTokenTable() + mock_pat_table.create_user("me", "1234") + create_issue_callback(mock_ack, mock_input, mock_fail, mock_complete, mock_logger, mock_pat_table) + + mock_complete.assert_called_once() diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..185e41b --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,11 @@ +import os + + +def remove_os_env_temporarily() -> dict: + old_env = os.environ.copy() + os.environ.clear() + return old_env + + +def restore_os_env(old_env: dict) -> None: + os.environ.update(old_env) From b637c1566b9bb66cf07c7186757b4c8623801409 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 26 Jan 2024 18:22:28 -0500 Subject: [PATCH 05/45] Make it work!!! --- controllers/personal_access_token_table.py | 33 ++++--------- listeners/functions/create_issue.py | 54 ++++++++-------------- tests/functions/test_create_issue.py | 49 ++++++++++++++------ 3 files changed, 64 insertions(+), 72 deletions(-) diff --git a/controllers/personal_access_token_table.py b/controllers/personal_access_token_table.py index 1be0ea3..33ff582 100644 --- a/controllers/personal_access_token_table.py +++ b/controllers/personal_access_token_table.py @@ -1,31 +1,14 @@ -from typing import Dict - - -class Singleton(type): - _instances = {} - - def __call__(cls, *args, **kwargs): - """ - Possible changes to the value of the `__init__` argument do not affect - the returned instance. - """ - if cls not in cls._instances: - instance = super().__call__(*args, **kwargs) - cls._instances[cls] = instance - return cls._instances[cls] - - -class PersonalAccessTokenTable(metaclass=Singleton): - _users: Dict[str, str] = {} +class PersonalAccessTokenTable(dict): + def __new__(cls): + if not hasattr(cls, "instance"): + cls.instance = super(PersonalAccessTokenTable, cls).__new__(cls) + return cls.instance def create_user(self, user_id: str, personal_access_token: str) -> None: - self._users[user_id] = personal_access_token + self[user_id] = personal_access_token def read_pat(self, user_id: str) -> str: - return self._users[user_id] + return self[user_id] def delete_user(self, user_id: str) -> None: - self._users.pop(user_id, None) - - def __contains__(self, user_id: str) -> bool: - return user_id in self._users + self.pop(user_id, None) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 42f70eb..0318bf7 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -1,6 +1,5 @@ import logging import os -import sys from slack_bolt import Complete, Fail, Ack import requests @@ -10,60 +9,47 @@ # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ # https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issues/#api-rest-api-2-issue-post -def create_issue_callback( - ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger, pat_table=PersonalAccessTokenTable() -): +def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger): ack() - JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") + pat_table = PersonalAccessTokenTable() user_id = inputs["user_id"] - if user_id not in pat_table: # TODO send a message to user on how to fix this return fail(f"User {user_id} has not set up their PAT properly, visit the app home to do this") + + JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") + + headers = { + "Authorization": f"Bearer {pat_table.read_pat(user_id)}", + "Accept": "application/json", + "Content-Type": "application/json", + } + + for name, value in os.environ.items(): + if name.startswith("HEADER_"): + headers[name.split("HEADER_")[1].replace("_", "-")] = value + try: project: str = inputs["project"] - issue_type = inputs["issuetype"] - # assignee = inputs["assignee"] - summary = inputs["summary"] - description = inputs["description"] - - url = f"{JIRA_BASE_URL}rest/api/2/issue" + issue_type: str = inputs["issuetype"] - headers = { - "Authorization": f"Bearer {pat_table.read_pat(user_id)}", - "Accept": "application/json;charset=UTF-8", - "Content-Type": "application/json;charset=UTF-8", - } + url = f"{JIRA_BASE_URL}/rest/api/latest/issue" payload = json.dumps( { "fields": { - "description": description, + "description": inputs["description"], "issuetype": {"id" if issue_type.isdigit() else "name": issue_type}, "project": {"id" if project.isdigit() else "key": project}, - "summary": summary, + "summary": inputs["summary"], }, } ) - print(payload) - - response = requests.get( - url, - data=payload, - headers=headers, - ) - - logger.info(response) + response = requests.post(url, data=payload, headers=headers) - print(response.status_code) - for k, v in response.headers.items(): - print(f"{k}: {v}") - # logger.info(response.json()) - logger.info(response.text) jason_data = json.loads(response.text) - logger.info(jason_data) complete(outputs={"issue_url": jason_data["self"]}) except Exception as e: logger.exception(e) diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index c8694d9..bac88a7 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -1,37 +1,60 @@ +import json import logging import os -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +import requests from controllers import PersonalAccessTokenTable from listeners.functions.create_issue import create_issue_callback +from tests.utils import remove_os_env_temporarily, restore_os_env -from tests.utils import remove_os_env_temporarily, restore_os_env +def mock_response(status=200, data: dict = None): + mock_resp = MagicMock() + mock_resp.status_code = status + if data: + mock_resp.json = MagicMock(return_value=data) + mock_resp.text = json.dumps(data) + return mock_resp class TestCreateIssue: def setup_method(self): self.old_os_env = remove_os_env_temporarily() + PersonalAccessTokenTable().clear() def teardown_method(self): + PersonalAccessTokenTable().clear() restore_os_env(self.old_os_env) - def test_create_issue(self, caplog): - mock_ack = MagicMock(retrun_value=None) + def test_create_issue(self): + mock_ack = MagicMock() mock_fail = MagicMock() mock_complete = MagicMock() - mock_logger = logging.getLogger() mock_input = { "user_id": "me", - "project": "HERMES", - "issuetype": "BUG", + "project": "PROJ", + "issuetype": "Bug", "summary": "this is a test from python", "description": "this is a test from python", } - - os.environ["JIRA_BASE_URL"] = "https://jira-dev.tinyspeck.com/" - mock_pat_table = PersonalAccessTokenTable() - mock_pat_table.create_user("me", "1234") - create_issue_callback(mock_ack, mock_input, mock_fail, mock_complete, mock_logger, mock_pat_table) - + PersonalAccessTokenTable().create_user("me", "my_pat_token") + + os.environ["JIRA_BASE_URL"] = "https://jira-dev/" + + with patch.object(requests, "post") as mock_requests: + mock_requests.return_value = mock_response( + status=201, + data={ + "id": "1234", + "key": "PROJ-1", + "self": "https://jira-dev/rest/api/2/issue/1234", + }, + ) + create_issue_callback(mock_ack, mock_input, mock_fail, mock_complete, logging.getLogger()) + mock_requests.assert_called_once() + + mock_ack.assert_called_once() mock_complete.assert_called_once() + assert mock_complete.call_args[1] == {"outputs": {"issue_url": "https://jira-dev/rest/api/2/issue/1234"}} From e27decbcb427465921e5055bb3e647e3075ffab9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 6 Feb 2024 15:12:52 -0500 Subject: [PATCH 06/45] Add this stuff in --- .sample.env | 1 + listeners/functions/create_issue.py | 1 + requirements.txt | 2 ++ 3 files changed, 4 insertions(+) diff --git a/.sample.env b/.sample.env index 9fe4cf9..087b545 100644 --- a/.sample.env +++ b/.sample.env @@ -1 +1,2 @@ JIRA_BASE_URL=http://localhost:8080 +HEADER_MY_PROXY_TOKEN=A1B2C3 diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 0318bf7..6f5a550 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -49,6 +49,7 @@ def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete response = requests.post(url, data=payload, headers=headers) + response.raise_for_status() jason_data = json.loads(response.text) complete(outputs={"issue_url": jason_data["self"]}) except Exception as e: diff --git a/requirements.txt b/requirements.txt index 8740337..ae1906e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ slack-cli-hooks +slack-bolt==1.19.0rc1 pytest flake8==7.0.0 black==23.12.1 +requests From 7ce84376a457c6ebe237ff168e98af42a1652fb3 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 7 Feb 2024 15:45:14 -0500 Subject: [PATCH 07/45] Update manifest.json --- manifest.json | 153 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 117 insertions(+), 36 deletions(-) diff --git a/manifest.json b/manifest.json index f2a8fbd..cf601d6 100644 --- a/manifest.json +++ b/manifest.json @@ -1,4 +1,7 @@ { + "_metadata": { + "major_version": 2 + }, "display_information": { "name": "BoltPy Jira Functions" }, @@ -33,47 +36,125 @@ "title": "Create an issue", "description": "Create a JIRA Cloud issue", "input_parameters": { - "user_id": { - "type": "slack#/types/user_id", - "title": "User", - "description": "Send this to who?", - "is_required": true, - "hint": "Select a user in the workspace", - "name": "user_id" - }, - "project": { - "type": "string", - "title": "Project", - "description": "Project", - "is_required": true - }, - "issuetype": { - "type": "string", - "title": "Issue type", - "description": "Type of issue to create: Bug, Improvement, New Feature, or Epic.", - "is_required": true - }, - "summary": { - "type": "string", - "title": "Summary", - "description": "Summary of the bug or issue...", - "is_required": true - }, - "description": { - "type": "string", - "title": "Description", - "description": "Description of the bug or issue...", - "is_required": true + "properties": { + "user_id": { + "type": "slack#/types/user_id", + "title": "Represents a user who interacted with a workflow at runtime.", + "description": "Send this to who?", + "is_required": true, + "hint": "Select a user in the workspace" + }, + "project": { + "type": "string", + "title": "Project", + "description": "Project", + "is_required": true + }, + "issuetype": { + "type": "string", + "title": "Issue type", + "description": "Type of issue to create: Bug, Improvement, New Feature, or Epic.", + "is_required": true + }, + "summary": { + "type": "string", + "title": "Summary", + "description": "Summary of the bug or issue...", + "is_required": true + }, + "description": { + "type": "string", + "title": "Description", + "description": "Description of the bug or issue...", + "is_required": true + } } }, "output_parameters": { - "issue_url": { - "type": "string", - "title": "Issue url", - "description": "Url of the issue that was created", - "is_required": true + "properties": { + "issue_url": { + "type": "string", + "title": "Issue url", + "description": "Url of the issue that was created", + "is_required": true + } } } } + }, + "workflows": { + "create_jira_issue": { + "title": "Create a Jira issue", + "description": "Create a Jira issue in dev", + "input_parameters": { + "properties": { + "interactivity": { + "type": "slack#/types/interactivity" + } + }, + "required": [ + "interactivity" + ] + }, + "steps": [ + { + "id": "0", + "function_id": "slack#/functions/open_form", + "inputs": { + "title": "Create a Jira Issue", + "interactivity": "{{inputs.interactivity}}", + "submit_label": "submit", + "fields": { + "elements": [ + { + "name": "project", + "title": "Project", + "type": "string" + }, + { + "name": "issuetype", + "title": "Issue type", + "type": "string", + "default": "Bug" + }, + { + "name": "description", + "title": "Description", + "hint": "Description of the bug or issue...", + "type": "string", + "long": true + }, + { + "name": "summary", + "title": "Summary", + "hint": "Summary of the bug or issue...", + "type": "string" + } + ], + "required": [] + } + } + }, + { + "id": "1", + "function_id": "#/functions/create_issue", + "inputs": { + "user_id": "{{steps.0.interactivity.interactor.id}}", + "project": "{{steps.0.fields.project}}", + "issuetype": "{{steps.0.fields.issuetype}}", + "summary": "{{steps.0.fields.summary}}", + "description": "{{steps.0.fields.description}}" + } + }, + { + "id": "2", + "function_id": "slack#/functions/send_dm", + "inputs": { + "user_id": "{{steps.0.interactivity.interactor.id}}", + "message": "{{steps.1.issue_url}}" + } + } + ] + } } } From af046a314597ddbd16033a1414da0f27c09be8ac Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 15 May 2024 13:28:01 -0400 Subject: [PATCH 08/45] OAuth works --- app.py | 71 +++++++++++++++++++++++++++++++++++++++-- manifest.json | 83 ++---------------------------------------------- requirements.txt | 1 + 3 files changed, 73 insertions(+), 82 deletions(-) diff --git a/app.py b/app.py index 5a0def1..3316e75 100644 --- a/app.py +++ b/app.py @@ -1,16 +1,83 @@ import logging import os +from flask import Flask, request, redirect +import requests +import urllib.parse from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler from listeners import register_listeners -app = App(token=os.environ.get("SLACK_BOT_TOKEN")) logging.basicConfig(level=logging.INFO) +app = App(token=os.environ.get("SLACK_BOT_TOKEN")) +flask_app = Flask(__name__) + +JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") +oauth_redirect_path = "/oauth/redirect" +jira_client_id = os.getenv("JIRA_CLIENT_ID") +jira_client_secret = os.getenv("JIRA_CLIENT_SECRET") +jira_code_verifier = os.getenv("CODE_VERIFIER") +jira_redirect_uri = os.getenv("APP_BASE_URL") + oauth_redirect_path +app_home_page_url = os.getenv("APP_HOME_PAGE_URL") + # Register Listeners register_listeners(app) +flask_app = Flask(__name__) + +params = { + "client_id": jira_client_id, + "redirect_uri": jira_redirect_uri, + "response_type": "code", + "scope": "WRITE", + "code_challenge": jira_code_verifier, + "code_challenge_method": "plain", +} +authorization_url = f"{JIRA_BASE_URL}/rest/oauth2/latest/authorize?{urllib.parse.urlencode(params)}" + + +class JiraInstallation: + def __init__(self, scope: str, access_token: str, token_type: str, expires_in: int, refresh_token: str): + self.scope = scope + self.access_token = access_token + self.token_type = token_type + self.expires_in = expires_in + self.refresh_token = refresh_token + + +print(f"Please go to {authorization_url} and authorize access.") + + +@flask_app.route(oauth_redirect_path, methods=["GET"]) +def oauth_redirect(): + headers = {"Content-Type": "application/x-www-form-urlencoded", "TSAuth-Token": os.getenv("HEADER_TSAuth_Token")} + resp = requests.post( + url=f"{JIRA_BASE_URL}/rest/oauth2/latest/token", + params={ + "grant_type": "authorization_code", + "client_id": jira_client_id, + "client_secret": jira_client_secret, + "code": request.args["code"], + "redirect_uri": jira_redirect_uri, + "code_verifier": jira_code_verifier, + }, + headers=headers, + ) + resp.raise_for_status() + json = resp.json() + jira_installation = JiraInstallation( + scope=json["scope"], + access_token=json["access_token"], + token_type=json["token_type"], + expires_in=json["expires_in"], + refresh_token=json["refresh_token"], + ) + print(jira_installation) + return redirect(app_home_page_url, code=302) + + if __name__ == "__main__": - SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).start() + SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).connect() + flask_app.run(port=3000) diff --git a/manifest.json b/manifest.json index cf601d6..2c7cefe 100644 --- a/manifest.json +++ b/manifest.json @@ -34,15 +34,13 @@ "functions": { "create_issue": { "title": "Create an issue", - "description": "Create a JIRA Cloud issue", + "description": "Create a JIRA SERVER issue", "input_parameters": { "properties": { "user_id": { - "type": "slack#/types/user_id", + "type": "slack#/types/user_context", "title": "Represents a user who interacted with a workflow at runtime.", - "description": "Send this to who?", - "is_required": true, - "hint": "Select a user in the workspace" + "is_required": true }, "project": { "type": "string", @@ -81,80 +79,5 @@ } } } - }, - "workflows": { - "create_jira_issue": { - "title": "Create a Jira issue", - "description": "Create a Jira issue in dev", - "input_parameters": { - "properties": { - "interactivity": { - "type": "slack#/types/interactivity" - } - }, - "required": [ - "interactivity" - ] - }, - "steps": [ - { - "id": "0", - "function_id": "slack#/functions/open_form", - "inputs": { - "title": "Create a Jira Issue", - "interactivity": "{{inputs.interactivity}}", - "submit_label": "submit", - "fields": { - "elements": [ - { - "name": "project", - "title": "Project", - "type": "string" - }, - { - "name": "issuetype", - "title": "Issue type", - "type": "string", - "default": "Bug" - }, - { - "name": "description", - "title": "Description", - "hint": "Description of the bug or issue...", - "type": "string", - "long": true - }, - { - "name": "summary", - "title": "Summary", - "hint": "Summary of the bug or issue...", - "type": "string" - } - ], - "required": [] - } - } - }, - { - "id": "1", - "function_id": "#/functions/create_issue", - "inputs": { - "user_id": "{{steps.0.interactivity.interactor.id}}", - "project": "{{steps.0.fields.project}}", - "issuetype": "{{steps.0.fields.issuetype}}", - "summary": "{{steps.0.fields.summary}}", - "description": "{{steps.0.fields.description}}" - } - }, - { - "id": "2", - "function_id": "slack#/functions/send_dm", - "inputs": { - "user_id": "{{steps.0.interactivity.interactor.id}}", - "message": "{{steps.1.issue_url}}" - } - } - ] - } } } diff --git a/requirements.txt b/requirements.txt index ae1906e..1159c89 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ pytest flake8==7.0.0 black==23.12.1 requests +Flask==3.0.3 From 7005c9a028ba8b5ab607b545f4fd6c71a0717837 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 15 May 2024 16:03:19 -0400 Subject: [PATCH 09/45] its getting better --- app.py | 24 ++++++++++++------------ controllers/app_home_builder.py | 17 +++++++++++++++++ listeners/events/app_home_open.py | 30 ++++++++++++++++++++++-------- 3 files changed, 51 insertions(+), 20 deletions(-) diff --git a/app.py b/app.py index 3316e75..e52cc2f 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,6 @@ import os from flask import Flask, request, redirect import requests -import urllib.parse from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler @@ -27,15 +26,15 @@ flask_app = Flask(__name__) -params = { - "client_id": jira_client_id, - "redirect_uri": jira_redirect_uri, - "response_type": "code", - "scope": "WRITE", - "code_challenge": jira_code_verifier, - "code_challenge_method": "plain", -} -authorization_url = f"{JIRA_BASE_URL}/rest/oauth2/latest/authorize?{urllib.parse.urlencode(params)}" +# params = { +# "client_id": jira_client_id, +# "redirect_uri": jira_redirect_uri, +# "response_type": "code", +# "scope": "WRITE", +# "code_challenge": jira_code_verifier, +# "code_challenge_method": "plain", +# } +# authorization_url = f"{JIRA_BASE_URL}/rest/oauth2/latest/authorize?{urllib.parse.urlencode(params)}" class JiraInstallation: @@ -47,11 +46,12 @@ def __init__(self, scope: str, access_token: str, token_type: str, expires_in: i self.refresh_token = refresh_token -print(f"Please go to {authorization_url} and authorize access.") +# print(f"Please go to {authorization_url} and authorize access.") -@flask_app.route(oauth_redirect_path, methods=["GET"]) +@flask_app.route("/oauth/redirect", methods=["GET"]) def oauth_redirect(): + print(request.args) headers = {"Content-Type": "application/x-www-form-urlencoded", "TSAuth-Token": os.getenv("HEADER_TSAuth_Token")} resp = requests.post( url=f"{JIRA_BASE_URL}/rest/oauth2/latest/token", diff --git a/controllers/app_home_builder.py b/controllers/app_home_builder.py index 524c386..31147f7 100644 --- a/controllers/app_home_builder.py +++ b/controllers/app_home_builder.py @@ -27,6 +27,23 @@ def __init__(self): ], } + def add_oauth_link_button(self, authorization_url): + self.view["blocks"].append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Link Jira", + }, + "url": authorization_url, + } + ], + } + ) + def add_pat_submit_button(self): self.view["blocks"].append( { diff --git a/listeners/events/app_home_open.py b/listeners/events/app_home_open.py index d425102..84a539e 100644 --- a/listeners/events/app_home_open.py +++ b/listeners/events/app_home_open.py @@ -1,22 +1,36 @@ from logging import Logger +import os from slack_sdk import WebClient +import urllib.parse -from controllers import AppHomeBuilder, PersonalAccessTokenTable +from controllers import AppHomeBuilder + +JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") +oauth_redirect_path = "/oauth/redirect" +jira_client_id = os.getenv("JIRA_CLIENT_ID") +jira_client_secret = os.getenv("JIRA_CLIENT_SECRET") +jira_code_verifier = os.getenv("CODE_VERIFIER") +jira_redirect_uri = os.getenv("APP_BASE_URL") + oauth_redirect_path def app_home_open_callback(client: WebClient, event: dict, logger: Logger): # ignore the app_home_opened event for anything but the Home tab if event["tab"] != "home": return + user_id = event["user"] try: home = AppHomeBuilder() - pat_table = PersonalAccessTokenTable() - if event["user"] in pat_table: - home.add_clear_pat_button() - else: - home.add_pat_input_field() - home.add_pat_submit_button() - client.views_publish(user_id=event["user"], view=home.view) + params = { + "client_id": jira_client_id, + "redirect_uri": jira_redirect_uri, + "response_type": "code", + "scope": "WRITE", + "code_challenge": jira_code_verifier, + "code_challenge_method": "plain", + } + authorization_url = f"{JIRA_BASE_URL}/rest/oauth2/latest/authorize?{urllib.parse.urlencode(params)}" + home.add_oauth_link_button(authorization_url) + client.views_publish(user_id=user_id, view=home.view) except Exception as e: logger.error(f"Error publishing home tab: {e}") From 09fa9504d588a85ef06726fe1708d3d7063b9263 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 15 May 2024 17:24:40 -0400 Subject: [PATCH 10/45] things are looking good --- app.py | 57 ++++++++++++++----------------- constants.py | 19 +++++++++++ controllers/app_home_builder.py | 1 + listeners/actions/__init__.py | 2 ++ listeners/actions/oauth_url.py | 5 +++ listeners/events/app_home_open.py | 26 +++++++------- 6 files changed, 65 insertions(+), 45 deletions(-) create mode 100644 constants.py create mode 100644 listeners/actions/oauth_url.py diff --git a/app.py b/app.py index e52cc2f..bb3c949 100644 --- a/app.py +++ b/app.py @@ -6,6 +6,16 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler +from constants import ( + OAUTH_REDIRECT_PATH, + JIRA_BASE_URL, + JIRA_CLIENT_ID, + JIRA_CLIENT_SECRET, + JIRA_CODE_VERIFIER, + JIRA_REDIRECT_URI, + APP_HOME_PAGE_URL, + OAUTH_STATE_TABLE, +) from listeners import register_listeners logging.basicConfig(level=logging.INFO) @@ -13,29 +23,9 @@ app = App(token=os.environ.get("SLACK_BOT_TOKEN")) flask_app = Flask(__name__) -JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") -oauth_redirect_path = "/oauth/redirect" -jira_client_id = os.getenv("JIRA_CLIENT_ID") -jira_client_secret = os.getenv("JIRA_CLIENT_SECRET") -jira_code_verifier = os.getenv("CODE_VERIFIER") -jira_redirect_uri = os.getenv("APP_BASE_URL") + oauth_redirect_path -app_home_page_url = os.getenv("APP_HOME_PAGE_URL") - # Register Listeners register_listeners(app) -flask_app = Flask(__name__) - -# params = { -# "client_id": jira_client_id, -# "redirect_uri": jira_redirect_uri, -# "response_type": "code", -# "scope": "WRITE", -# "code_challenge": jira_code_verifier, -# "code_challenge_method": "plain", -# } -# authorization_url = f"{JIRA_BASE_URL}/rest/oauth2/latest/authorize?{urllib.parse.urlencode(params)}" - class JiraInstallation: def __init__(self, scope: str, access_token: str, token_type: str, expires_in: int, refresh_token: str): @@ -46,22 +36,20 @@ def __init__(self, scope: str, access_token: str, token_type: str, expires_in: i self.refresh_token = refresh_token -# print(f"Please go to {authorization_url} and authorize access.") - - -@flask_app.route("/oauth/redirect", methods=["GET"]) +@flask_app.route(OAUTH_REDIRECT_PATH, methods=["GET"]) def oauth_redirect(): - print(request.args) + code = request.args["code"] + state = request.args["state"] headers = {"Content-Type": "application/x-www-form-urlencoded", "TSAuth-Token": os.getenv("HEADER_TSAuth_Token")} resp = requests.post( url=f"{JIRA_BASE_URL}/rest/oauth2/latest/token", params={ "grant_type": "authorization_code", - "client_id": jira_client_id, - "client_secret": jira_client_secret, - "code": request.args["code"], - "redirect_uri": jira_redirect_uri, - "code_verifier": jira_code_verifier, + "client_id": JIRA_CLIENT_ID, + "client_secret": JIRA_CLIENT_SECRET, + "code": code, + "redirect_uri": JIRA_REDIRECT_URI, + "code_verifier": JIRA_CODE_VERIFIER, }, headers=headers, ) @@ -74,8 +62,13 @@ def oauth_redirect(): expires_in=json["expires_in"], refresh_token=json["refresh_token"], ) - print(jira_installation) - return redirect(app_home_page_url, code=302) + print(jira_installation.access_token) + user_indentity = OAUTH_STATE_TABLE[state] + print(user_indentity.user_id) + print(user_indentity.enterprise_id) + print(user_indentity.team_id) + del OAUTH_STATE_TABLE[state] + return redirect(APP_HOME_PAGE_URL, code=302) if __name__ == "__main__": diff --git a/constants.py b/constants.py new file mode 100644 index 0000000..e349807 --- /dev/null +++ b/constants.py @@ -0,0 +1,19 @@ +import os +from typing import Union, Dict + + +class UserIdentity: + def __init__(self, user_id: str, team_id: Union[str, None], enterprise_id: Union[str, None]): + self.user_id = user_id + self.team_id = team_id + self.enterprise_id = enterprise_id + + +JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") +OAUTH_REDIRECT_PATH = "/oauth/redirect" +JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") +JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") +JIRA_CODE_VERIFIER = os.getenv("CODE_VERIFIER") +JIRA_REDIRECT_URI = os.getenv("APP_BASE_URL") + OAUTH_REDIRECT_PATH +APP_HOME_PAGE_URL = os.getenv("APP_HOME_PAGE_URL") +OAUTH_STATE_TABLE: Dict[str, UserIdentity] = {} diff --git a/controllers/app_home_builder.py b/controllers/app_home_builder.py index 31147f7..8a9c2ed 100644 --- a/controllers/app_home_builder.py +++ b/controllers/app_home_builder.py @@ -39,6 +39,7 @@ def add_oauth_link_button(self, authorization_url): "text": "Link Jira", }, "url": authorization_url, + "action_id": "oauth_url", } ], } diff --git a/listeners/actions/__init__.py b/listeners/actions/__init__.py index bc53cd4..882b6c4 100644 --- a/listeners/actions/__init__.py +++ b/listeners/actions/__init__.py @@ -1,8 +1,10 @@ from slack_bolt import App from .submit_pat import submit_pat_callback from .clear_pat import clear_pat_callback +from .oauth_url import oauth_url_callback def register(app: App): app.action("submit_pat")(submit_pat_callback) app.action("clear_pat")(clear_pat_callback) + app.action("oauth_url")(oauth_url_callback) diff --git a/listeners/actions/oauth_url.py b/listeners/actions/oauth_url.py new file mode 100644 index 0000000..d52cb11 --- /dev/null +++ b/listeners/actions/oauth_url.py @@ -0,0 +1,5 @@ +from slack_bolt import Ack + + +def oauth_url_callback(ack: Ack): + ack() diff --git a/listeners/events/app_home_open.py b/listeners/events/app_home_open.py index 84a539e..099a95c 100644 --- a/listeners/events/app_home_open.py +++ b/listeners/events/app_home_open.py @@ -1,36 +1,36 @@ from logging import Logger -import os +import uuid from slack_sdk import WebClient +from slack_bolt import BoltContext import urllib.parse from controllers import AppHomeBuilder -JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") -oauth_redirect_path = "/oauth/redirect" -jira_client_id = os.getenv("JIRA_CLIENT_ID") -jira_client_secret = os.getenv("JIRA_CLIENT_SECRET") -jira_code_verifier = os.getenv("CODE_VERIFIER") -jira_redirect_uri = os.getenv("APP_BASE_URL") + oauth_redirect_path +from constants import JIRA_BASE_URL, JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI, OAUTH_STATE_TABLE, UserIdentity -def app_home_open_callback(client: WebClient, event: dict, logger: Logger): +def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext): # ignore the app_home_opened event for anything but the Home tab if event["tab"] != "home": return - user_id = event["user"] + state = uuid.uuid4().hex + OAUTH_STATE_TABLE[state] = UserIdentity( + user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id + ) try: home = AppHomeBuilder() params = { - "client_id": jira_client_id, - "redirect_uri": jira_redirect_uri, + "client_id": JIRA_CLIENT_ID, + "redirect_uri": JIRA_REDIRECT_URI, "response_type": "code", "scope": "WRITE", - "code_challenge": jira_code_verifier, + "code_challenge": JIRA_CODE_VERIFIER, "code_challenge_method": "plain", + "state": state, } authorization_url = f"{JIRA_BASE_URL}/rest/oauth2/latest/authorize?{urllib.parse.urlencode(params)}" home.add_oauth_link_button(authorization_url) - client.views_publish(user_id=user_id, view=home.view) + client.views_publish(user_id=context.user_id, view=home.view) except Exception as e: logger.error(f"Error publishing home tab: {e}") From 2a5b2a23f243f2daee50a688d63afd61cfc77f6b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 16 May 2024 12:24:25 -0400 Subject: [PATCH 11/45] The installation store works --- .gitignore | 1 + app.py | 54 ++++++------ controllers/__init__.py | 2 +- controllers/app_home_builder.py | 1 - constants.py => globals.py | 15 ++-- listeners/__init__.py | 2 +- listeners/actions/__init__.py | 3 +- listeners/actions/clear_pat.py | 2 +- listeners/actions/submit_pat.py | 2 +- listeners/events/app_home_open.py | 2 +- listeners/functions/create_issue.py | 5 +- oauth/__init__.py | 5 ++ oauth/installation_store/__init__.py | 9 ++ oauth/installation_store/file/__init__.py | 83 +++++++++++++++++++ .../installation_store/installation_store.py | 30 +++++++ oauth/installation_store/models/__init__.py | 5 ++ .../models/jira_installation.py | 13 +++ oauth/models/__init__.py | 8 ++ 18 files changed, 194 insertions(+), 48 deletions(-) rename constants.py => globals.py (61%) create mode 100644 oauth/__init__.py create mode 100644 oauth/installation_store/__init__.py create mode 100644 oauth/installation_store/file/__init__.py create mode 100644 oauth/installation_store/installation_store.py create mode 100644 oauth/installation_store/models/__init__.py create mode 100644 oauth/installation_store/models/jira_installation.py create mode 100644 oauth/models/__init__.py diff --git a/.gitignore b/.gitignore index 35d3ec7..dea87df 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ logs/ #tmp requirements.txt .slack +data diff --git a/app.py b/app.py index bb3c949..e359808 100644 --- a/app.py +++ b/app.py @@ -1,19 +1,21 @@ import logging import os -from flask import Flask, request, redirect -import requests +from datetime import datetime +import requests +from flask import Flask, redirect, request from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -from constants import ( - OAUTH_REDIRECT_PATH, +from globals import ( + APP_HOME_PAGE_URL, JIRA_BASE_URL, JIRA_CLIENT_ID, JIRA_CLIENT_SECRET, JIRA_CODE_VERIFIER, + JIRA_FILE_INSTALLATION_STORE, JIRA_REDIRECT_URI, - APP_HOME_PAGE_URL, + OAUTH_REDIRECT_PATH, OAUTH_STATE_TABLE, ) from listeners import register_listeners @@ -27,21 +29,11 @@ register_listeners(app) -class JiraInstallation: - def __init__(self, scope: str, access_token: str, token_type: str, expires_in: int, refresh_token: str): - self.scope = scope - self.access_token = access_token - self.token_type = token_type - self.expires_in = expires_in - self.refresh_token = refresh_token - - @flask_app.route(OAUTH_REDIRECT_PATH, methods=["GET"]) def oauth_redirect(): code = request.args["code"] state = request.args["state"] - headers = {"Content-Type": "application/x-www-form-urlencoded", "TSAuth-Token": os.getenv("HEADER_TSAuth_Token")} - resp = requests.post( + jira_resp = requests.post( url=f"{JIRA_BASE_URL}/rest/oauth2/latest/token", params={ "grant_type": "authorization_code", @@ -51,22 +43,24 @@ def oauth_redirect(): "redirect_uri": JIRA_REDIRECT_URI, "code_verifier": JIRA_CODE_VERIFIER, }, - headers=headers, + headers={"Content-Type": "application/x-www-form-urlencoded", "TSAuth-Token": os.getenv("HEADER_TSAuth_Token")}, ) - resp.raise_for_status() - json = resp.json() - jira_installation = JiraInstallation( - scope=json["scope"], - access_token=json["access_token"], - token_type=json["token_type"], - expires_in=json["expires_in"], - refresh_token=json["refresh_token"], + jira_resp.raise_for_status() + user_identity = OAUTH_STATE_TABLE[state] + jira_resp_json = jira_resp.json() + JIRA_FILE_INSTALLATION_STORE.save( + { + "access_token": jira_resp_json["access_token"], + "enterprise_id": user_identity.enterprise_id, + "expires_in": jira_resp_json["expires_in"], + "installed_at": datetime.now().timestamp(), + "refresh_token": jira_resp_json["refresh_token"], + "scope": jira_resp_json["scope"], + "team_id": user_identity.team_id, + "token_type": jira_resp_json["token_type"], + "user_id": user_identity.user_id, + } ) - print(jira_installation.access_token) - user_indentity = OAUTH_STATE_TABLE[state] - print(user_indentity.user_id) - print(user_indentity.enterprise_id) - print(user_indentity.team_id) del OAUTH_STATE_TABLE[state] return redirect(APP_HOME_PAGE_URL, code=302) diff --git a/controllers/__init__.py b/controllers/__init__.py index 84c899c..1820c88 100644 --- a/controllers/__init__.py +++ b/controllers/__init__.py @@ -1,4 +1,4 @@ -from .personal_access_token_table import PersonalAccessTokenTable from .app_home_builder import AppHomeBuilder +from .personal_access_token_table import PersonalAccessTokenTable __all__ = ["PersonalAccessTokenTable", "AppHomeBuilder"] diff --git a/controllers/app_home_builder.py b/controllers/app_home_builder.py index 8a9c2ed..887fbca 100644 --- a/controllers/app_home_builder.py +++ b/controllers/app_home_builder.py @@ -1,6 +1,5 @@ from typing import TypedDict - context = "\ (PATs) are a secure way to use scripts and integrate external applications with your Atlassian application. To use the\ functions defined by this app you will need to add your own, click the add button to submit yours." diff --git a/constants.py b/globals.py similarity index 61% rename from constants.py rename to globals.py index e349807..d6ac6d7 100644 --- a/constants.py +++ b/globals.py @@ -1,19 +1,16 @@ import os -from typing import Union, Dict +from typing import Dict +from oauth.installation_store import FileInstallationStore +from oauth.models import UserIdentity -class UserIdentity: - def __init__(self, user_id: str, team_id: Union[str, None], enterprise_id: Union[str, None]): - self.user_id = user_id - self.team_id = team_id - self.enterprise_id = enterprise_id - +OAUTH_REDIRECT_PATH = "/oauth/redirect" +OAUTH_STATE_TABLE: Dict[str, UserIdentity] = {} +JIRA_FILE_INSTALLATION_STORE = FileInstallationStore(base_dir="./data/installations") JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") -OAUTH_REDIRECT_PATH = "/oauth/redirect" JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") JIRA_CODE_VERIFIER = os.getenv("CODE_VERIFIER") JIRA_REDIRECT_URI = os.getenv("APP_BASE_URL") + OAUTH_REDIRECT_PATH APP_HOME_PAGE_URL = os.getenv("APP_HOME_PAGE_URL") -OAUTH_STATE_TABLE: Dict[str, UserIdentity] = {} diff --git a/listeners/__init__.py b/listeners/__init__.py index 6721cbc..5c31e10 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -1,4 +1,4 @@ -from listeners import events, functions, actions +from listeners import actions, events, functions def register_listeners(app): diff --git a/listeners/actions/__init__.py b/listeners/actions/__init__.py index 882b6c4..80bac87 100644 --- a/listeners/actions/__init__.py +++ b/listeners/actions/__init__.py @@ -1,7 +1,8 @@ from slack_bolt import App -from .submit_pat import submit_pat_callback + from .clear_pat import clear_pat_callback from .oauth_url import oauth_url_callback +from .submit_pat import submit_pat_callback def register(app: App): diff --git a/listeners/actions/clear_pat.py b/listeners/actions/clear_pat.py index 8c9dc51..aa36f36 100644 --- a/listeners/actions/clear_pat.py +++ b/listeners/actions/clear_pat.py @@ -3,7 +3,7 @@ from slack_bolt import Ack from slack_sdk import WebClient -from controllers import PersonalAccessTokenTable, AppHomeBuilder +from controllers import AppHomeBuilder, PersonalAccessTokenTable def clear_pat_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): diff --git a/listeners/actions/submit_pat.py b/listeners/actions/submit_pat.py index ff75c8f..6d00826 100644 --- a/listeners/actions/submit_pat.py +++ b/listeners/actions/submit_pat.py @@ -3,7 +3,7 @@ from slack_bolt import Ack from slack_sdk import WebClient -from controllers import PersonalAccessTokenTable, AppHomeBuilder +from controllers import AppHomeBuilder, PersonalAccessTokenTable def submit_pat_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): diff --git a/listeners/events/app_home_open.py b/listeners/events/app_home_open.py index 099a95c..d713925 100644 --- a/listeners/events/app_home_open.py +++ b/listeners/events/app_home_open.py @@ -7,7 +7,7 @@ from controllers import AppHomeBuilder -from constants import JIRA_BASE_URL, JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI, OAUTH_STATE_TABLE, UserIdentity +from globals import JIRA_BASE_URL, JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI, OAUTH_STATE_TABLE, UserIdentity def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext): diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 6f5a550..22fa6cb 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -1,10 +1,11 @@ +import json import logging import os -from slack_bolt import Complete, Fail, Ack import requests +from slack_bolt import Ack, Complete, Fail + from controllers import PersonalAccessTokenTable -import json # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ diff --git a/oauth/__init__.py b/oauth/__init__.py new file mode 100644 index 0000000..2891921 --- /dev/null +++ b/oauth/__init__.py @@ -0,0 +1,5 @@ +from .installation_store import InstallationStore + +__all__ = [ + "InstallationStore", +] diff --git a/oauth/installation_store/__init__.py b/oauth/installation_store/__init__.py new file mode 100644 index 0000000..c8326d1 --- /dev/null +++ b/oauth/installation_store/__init__.py @@ -0,0 +1,9 @@ +from .file import FileInstallationStore +from .installation_store import InstallationStore +from .models import JiraInstallation + +__all__ = [ + "FileInstallationStore", + "InstallationStore", + "JiraInstallation", +] diff --git a/oauth/installation_store/file/__init__.py b/oauth/installation_store/file/__init__.py new file mode 100644 index 0000000..c71a60d --- /dev/null +++ b/oauth/installation_store/file/__init__.py @@ -0,0 +1,83 @@ +import glob +import json +import logging +import os +from logging import Logger +from pathlib import Path +from typing import Optional, Union + +from oauth.installation_store import InstallationStore +from oauth.installation_store.models.jira_installation import JiraInstallation + + +class FileInstallationStore(InstallationStore): + def __init__( + self, + *, + base_dir: str = "./data/installations", + logger: Logger = logging.getLogger(__name__), + ): + self.base_dir = base_dir + self.logger = logger + + def save(self, installation: JiraInstallation): + none = "none" + e_id = installation["enterprise_id"] or none + t_id = installation["team_id"] or none + team_installation_dir = f"{self.base_dir}/{e_id}-{t_id}" + self._mkdir(team_installation_dir) + + u_id = installation["user_id"] + installer_filepath = f"{team_installation_dir}/installer-{u_id}-latest" + with open(installer_filepath, "w") as f: + entity: str = json.dumps(installation) + f.write(entity) + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: str, + ) -> Optional[JiraInstallation]: + none = "none" + e_id = enterprise_id or none + t_id = team_id or none + installation_filepath = f"{self.base_dir}/{e_id}-{t_id}/installer-{user_id}-latest" + + try: + installation: Optional[JiraInstallation] = None + with open(installation_filepath) as f: + data = json.loads(f.read()) + installation: JiraInstallation = data + + return installation + + except FileNotFoundError as e: + message = f"Installation data missing for enterprise: {e_id}, team: {t_id}: {e}" + self.logger.debug(message) + return None + + def delete_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: Optional[str] = None, + ) -> None: + none = "none" + e_id = enterprise_id or none + t_id = team_id or none + filepath_glob = f"{self.base_dir}/{e_id}-{t_id}/installer-{user_id}-*" + for filepath in glob.glob(filepath_glob): + try: + os.remove(filepath) + except FileNotFoundError as e: + message = f"Failed to delete installation data for enterprise: {e_id}, team: {t_id}: {e}" + self.logger.warning(message) + + @staticmethod + def _mkdir(path: Union[str, Path]): + if isinstance(path, str): + path = Path(path) + path.mkdir(parents=True, exist_ok=True) diff --git a/oauth/installation_store/installation_store.py b/oauth/installation_store/installation_store.py new file mode 100644 index 0000000..392a711 --- /dev/null +++ b/oauth/installation_store/installation_store.py @@ -0,0 +1,30 @@ +from typing import Optional + +from .models.jira_installation import JiraInstallation + + +class InstallationStore: + + def save(self, installation: JiraInstallation): + """Saves an installation data""" + raise NotImplementedError() + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: str, + ) -> Optional[JiraInstallation]: + """Finds a relevant installation for the given IDs.""" + raise NotImplementedError() + + def delete_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: str, + ) -> None: + """Deletes an installation that matches the given IDs""" + raise NotImplementedError() diff --git a/oauth/installation_store/models/__init__.py b/oauth/installation_store/models/__init__.py new file mode 100644 index 0000000..1f63cd5 --- /dev/null +++ b/oauth/installation_store/models/__init__.py @@ -0,0 +1,5 @@ +from .jira_installation import JiraInstallation + +__all__ = [ + "JiraInstallation", +] diff --git a/oauth/installation_store/models/jira_installation.py b/oauth/installation_store/models/jira_installation.py new file mode 100644 index 0000000..312ed64 --- /dev/null +++ b/oauth/installation_store/models/jira_installation.py @@ -0,0 +1,13 @@ +from typing import Optional, TypedDict + + +class JiraInstallation(TypedDict): + scope: str + access_token: str + token_type: str + expires_in: int + refresh_token: str + user_id: str + team_id: Optional[str] + enterprise_id: Optional[str] + installed_at: float diff --git a/oauth/models/__init__.py b/oauth/models/__init__.py new file mode 100644 index 0000000..7034cca --- /dev/null +++ b/oauth/models/__init__.py @@ -0,0 +1,8 @@ +from typing import Optional + + +class UserIdentity: + def __init__(self, user_id: str, team_id: Optional[str], enterprise_id: Optional[str]): + self.user_id = user_id + self.team_id = team_id + self.enterprise_id = enterprise_id From 96947def3885f66a2ddd976238142fe4f62f8ec1 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 16 May 2024 13:43:03 -0400 Subject: [PATCH 12/45] Add a jira client --- app.py | 21 ++-- globals.py | 2 +- jira/__init__.py | 0 jira/client.py | 128 ++++++++++++++++++++++ listeners/events/app_home_open.py | 27 ++--- oauth/installation_store/file/__init__.py | 2 +- 6 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 jira/__init__.py create mode 100644 jira/client.py diff --git a/app.py b/app.py index e359808..a593a1a 100644 --- a/app.py +++ b/app.py @@ -2,14 +2,12 @@ import os from datetime import datetime -import requests from flask import Flask, redirect, request from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler from globals import ( APP_HOME_PAGE_URL, - JIRA_BASE_URL, JIRA_CLIENT_ID, JIRA_CLIENT_SECRET, JIRA_CODE_VERIFIER, @@ -18,6 +16,7 @@ OAUTH_REDIRECT_PATH, OAUTH_STATE_TABLE, ) +from jira.client import JiraClient from listeners import register_listeners logging.basicConfig(level=logging.INFO) @@ -33,17 +32,13 @@ def oauth_redirect(): code = request.args["code"] state = request.args["state"] - jira_resp = requests.post( - url=f"{JIRA_BASE_URL}/rest/oauth2/latest/token", - params={ - "grant_type": "authorization_code", - "client_id": JIRA_CLIENT_ID, - "client_secret": JIRA_CLIENT_SECRET, - "code": code, - "redirect_uri": JIRA_REDIRECT_URI, - "code_verifier": JIRA_CODE_VERIFIER, - }, - headers={"Content-Type": "application/x-www-form-urlencoded", "TSAuth-Token": os.getenv("HEADER_TSAuth_Token")}, + jira_client = JiraClient() + jira_resp = jira_client.oauth2_token( + code=code, + client_id=JIRA_CLIENT_ID, + client_secret=JIRA_CLIENT_SECRET, + code_verifier=JIRA_CODE_VERIFIER, + redirect_uri=JIRA_REDIRECT_URI, ) jira_resp.raise_for_status() user_identity = OAUTH_STATE_TABLE[state] diff --git a/globals.py b/globals.py index d6ac6d7..6346912 100644 --- a/globals.py +++ b/globals.py @@ -8,7 +8,7 @@ OAUTH_STATE_TABLE: Dict[str, UserIdentity] = {} JIRA_FILE_INSTALLATION_STORE = FileInstallationStore(base_dir="./data/installations") -JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") +JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") # https://jira.atlassian.com JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") JIRA_CODE_VERIFIER = os.getenv("CODE_VERIFIER") diff --git a/jira/__init__.py b/jira/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jira/client.py b/jira/client.py new file mode 100644 index 0000000..2eb4976 --- /dev/null +++ b/jira/client.py @@ -0,0 +1,128 @@ +import os +from typing import Dict, Optional +from urllib.parse import urlencode, urljoin + +import requests +from requests import Response + +from globals import JIRA_BASE_URL + + +class JiraClient: + + def __init__( + self, + token: Optional[str] = None, + base_url: str = JIRA_BASE_URL, + token_type: Optional[str] = "Bearer", + headers: Optional[dict] = None, + proxies: Optional[Dict[str, str]] = None, + ): + self.token = token + self.base_url = base_url + self.token_type = token_type + self.headers = headers or {} + self.headers["TSAuth-Token"] = os.getenv("HEADER_TSAuth_Token") + if token is not None: + self.headers["Authorization"] = f"{self.token_type} {self.token}" + self.proxies = proxies + + def api_call( + self, + api_path: str, + *, + method: str = "POST", + files: Optional[dict] = None, + data: Optional[dict] = None, + params: Optional[dict] = None, + json: Optional[dict] = None, + headers: Optional[dict] = None, + auth: Optional[dict] = None, + ) -> Response: + api_url = urljoin(self.base_url, api_path) + headers = headers or {} + headers.update(self.headers) + return requests.request( + method=method, + url=api_url, + params=params, + files=files, + data=data, + json=json, + headers=headers, + auth=auth, + proxies=self.proxies, + ) + + def build_authorization_url( + self, + *, + client_id: str, + redirect_uri: str, + scope: str, + code_challenge: str, + state: str, + response_type: str = "code", + code_challenge_method: str = "plain", + **kwargs, + ) -> str: + kwargs.update( + { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": response_type, + "scope": scope, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + "state": state, + } + ) + return f"{urljoin(self.base_url, '/rest/oauth2/latest/authorize')}?{urlencode(kwargs)}" + + def oauth2_token( + self, + *, + code: str, + client_id: str, + client_secret: str, + redirect_uri: str, + code_verifier: str, + grant_type="authorization_code", + **kwargs, + ) -> Response: + headers = {"Content-Type": "application/x-www-form-urlencoded"} + kwargs.update( + { + "grant_type": grant_type, + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + } + ) + return self.api_call("/rest/oauth2/latest/token", headers=headers, params=kwargs) + + def create_issue( + self, + *, + code: str, + client_id: str, + client_secret: str, + redirect_uri: str, + code_verifier: str, + grant_type="authorization_code", + **kwargs, + ) -> Response: + headers = {"Content-Type": "application/x-www-form-urlencoded"} + kwargs.update( + { + "grant_type": grant_type, + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + "code_verifier": code_verifier, + } + ) + return self.api_call("/rest/api/latest/issue", headers=headers, params=kwargs) diff --git a/listeners/events/app_home_open.py b/listeners/events/app_home_open.py index d713925..5f99b49 100644 --- a/listeners/events/app_home_open.py +++ b/listeners/events/app_home_open.py @@ -1,13 +1,12 @@ -from logging import Logger import uuid +from logging import Logger -from slack_sdk import WebClient from slack_bolt import BoltContext -import urllib.parse +from slack_sdk import WebClient from controllers import AppHomeBuilder - -from globals import JIRA_BASE_URL, JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI, OAUTH_STATE_TABLE, UserIdentity +from globals import JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI, OAUTH_STATE_TABLE, UserIdentity +from jira.client import JiraClient def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext): @@ -20,16 +19,14 @@ def app_home_open_callback(client: WebClient, event: dict, logger: Logger, conte ) try: home = AppHomeBuilder() - params = { - "client_id": JIRA_CLIENT_ID, - "redirect_uri": JIRA_REDIRECT_URI, - "response_type": "code", - "scope": "WRITE", - "code_challenge": JIRA_CODE_VERIFIER, - "code_challenge_method": "plain", - "state": state, - } - authorization_url = f"{JIRA_BASE_URL}/rest/oauth2/latest/authorize?{urllib.parse.urlencode(params)}" + jira_client = JiraClient() + authorization_url = jira_client.build_authorization_url( + client_id=JIRA_CLIENT_ID, + redirect_uri=JIRA_REDIRECT_URI, + scope="WRITE", + code_challenge=JIRA_CODE_VERIFIER, + state=state, + ) home.add_oauth_link_button(authorization_url) client.views_publish(user_id=context.user_id, view=home.view) except Exception as e: diff --git a/oauth/installation_store/file/__init__.py b/oauth/installation_store/file/__init__.py index c71a60d..e60296e 100644 --- a/oauth/installation_store/file/__init__.py +++ b/oauth/installation_store/file/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Optional, Union -from oauth.installation_store import InstallationStore +from oauth.installation_store.installation_store import InstallationStore from oauth.installation_store.models.jira_installation import JiraInstallation From 8faefc4adce3870f8dd9463ed17daf3d102904ea Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 16 May 2024 14:01:53 -0400 Subject: [PATCH 13/45] improve the sample --- .sample.env | 5 ++++ jira/client.py | 30 +++++----------------- listeners/functions/create_issue.py | 40 +++++++++++++++++------------ 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/.sample.env b/.sample.env index 087b545..64335f8 100644 --- a/.sample.env +++ b/.sample.env @@ -1,2 +1,7 @@ JIRA_BASE_URL=http://localhost:8080 HEADER_MY_PROXY_TOKEN=A1B2C3 +CODE_VERIFIER=ABC1234 +JIRA_CLIENT_ID=abc134 +JIRA_CLIENT_SECRET=abc134 +APP_BASE_URL=https://this-si-my-app +APP_HOME_PAGE_URL=https://slack.workspace/archives/bot-id diff --git a/jira/client.py b/jira/client.py index 2eb4976..86bce99 100644 --- a/jira/client.py +++ b/jira/client.py @@ -1,3 +1,4 @@ +import json import os from typing import Dict, Optional from urllib.parse import urlencode, urljoin @@ -103,26 +104,9 @@ def oauth2_token( ) return self.api_call("/rest/oauth2/latest/token", headers=headers, params=kwargs) - def create_issue( - self, - *, - code: str, - client_id: str, - client_secret: str, - redirect_uri: str, - code_verifier: str, - grant_type="authorization_code", - **kwargs, - ) -> Response: - headers = {"Content-Type": "application/x-www-form-urlencoded"} - kwargs.update( - { - "grant_type": grant_type, - "client_id": client_id, - "client_secret": client_secret, - "code": code, - "redirect_uri": redirect_uri, - "code_verifier": code_verifier, - } - ) - return self.api_call("/rest/api/latest/issue", headers=headers, params=kwargs) + def create_issue(self, *, data: dict) -> Response: + headers = { + "Accept": "application/json", + "Content-Type": "application/json", + } + return self.api_call("/rest/api/latest/issue", headers=headers, data=json.dumps(data)) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 22fa6cb..37e5bad 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -1,11 +1,9 @@ import json import logging -import os - -import requests from slack_bolt import Ack, Complete, Fail from controllers import PersonalAccessTokenTable +from jira.client import JiraClient # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ @@ -19,25 +17,37 @@ def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete # TODO send a message to user on how to fix this return fail(f"User {user_id} has not set up their PAT properly, visit the app home to do this") - JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") + # JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") - headers = { - "Authorization": f"Bearer {pat_table.read_pat(user_id)}", - "Accept": "application/json", - "Content-Type": "application/json", - } + # headers = { + # "Authorization": f"Bearer {pat_table.read_pat(user_id)}", + # "Accept": "application/json", + # "Content-Type": "application/json", + # } - for name, value in os.environ.items(): - if name.startswith("HEADER_"): - headers[name.split("HEADER_")[1].replace("_", "-")] = value + # for name, value in os.environ.items(): + # if name.startswith("HEADER_"): + # headers[name.split("HEADER_")[1].replace("_", "-")] = value try: project: str = inputs["project"] issue_type: str = inputs["issuetype"] - url = f"{JIRA_BASE_URL}/rest/api/latest/issue" + # url = f"{JIRA_BASE_URL}/rest/api/latest/issue" - payload = json.dumps( + # payload = json.dumps( + # { + # "fields": { + # "description": inputs["description"], + # "issuetype": {"id" if issue_type.isdigit() else "name": issue_type}, + # "project": {"id" if project.isdigit() else "key": project}, + # "summary": inputs["summary"], + # }, + # } + # ) + # response = requests.post(url, data=payload, headers=headers) + jira_client = JiraClient() + response = jira_client.create_issue( { "fields": { "description": inputs["description"], @@ -48,8 +58,6 @@ def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete } ) - response = requests.post(url, data=payload, headers=headers) - response.raise_for_status() jason_data = json.loads(response.text) complete(outputs={"issue_url": jason_data["self"]}) From a6efc5aac4b9b1ee168754e43cc43708cb4cb03f Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 16 May 2024 14:37:16 -0400 Subject: [PATCH 14/45] Allow connecting and disconnecting accounts --- controllers/__init__.py | 4 - controllers/app_home_builder.py | 93 ---------------------- controllers/personal_access_token_table.py | 14 ---- listeners/actions/__init__.py | 10 +-- listeners/actions/clear_pat.py | 22 ----- listeners/actions/connect_account.py | 5 ++ listeners/actions/disconnect_account.py | 10 +++ listeners/actions/oauth_url.py | 5 -- listeners/actions/submit_pat.py | 25 ------ listeners/events/__init__.py | 2 +- listeners/events/app_home/__init__.py | 0 listeners/events/app_home/app_home_open.py | 47 +++++++++++ listeners/events/app_home/builder.py | 60 ++++++++++++++ listeners/events/app_home_open.py | 33 -------- listeners/functions/create_issue.py | 23 ++++-- oauth/installation_store/file/__init__.py | 2 +- 16 files changed, 143 insertions(+), 212 deletions(-) delete mode 100644 controllers/__init__.py delete mode 100644 controllers/app_home_builder.py delete mode 100644 controllers/personal_access_token_table.py delete mode 100644 listeners/actions/clear_pat.py create mode 100644 listeners/actions/connect_account.py create mode 100644 listeners/actions/disconnect_account.py delete mode 100644 listeners/actions/oauth_url.py delete mode 100644 listeners/actions/submit_pat.py create mode 100644 listeners/events/app_home/__init__.py create mode 100644 listeners/events/app_home/app_home_open.py create mode 100644 listeners/events/app_home/builder.py delete mode 100644 listeners/events/app_home_open.py diff --git a/controllers/__init__.py b/controllers/__init__.py deleted file mode 100644 index 1820c88..0000000 --- a/controllers/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .app_home_builder import AppHomeBuilder -from .personal_access_token_table import PersonalAccessTokenTable - -__all__ = ["PersonalAccessTokenTable", "AppHomeBuilder"] diff --git a/controllers/app_home_builder.py b/controllers/app_home_builder.py deleted file mode 100644 index 887fbca..0000000 --- a/controllers/app_home_builder.py +++ /dev/null @@ -1,93 +0,0 @@ -from typing import TypedDict - -context = "\ -(PATs) are a secure way to use scripts and integrate external applications with your Atlassian application. To use the\ -functions defined by this app you will need to add your own, click the add button to submit yours." - - -class AppHome(TypedDict): - type: str - blocks: list - - -class AppHomeBuilder: - def __init__(self): - self.view: AppHome = { - "type": "home", - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": context, - }, - }, - {"type": "divider"}, - ], - } - - def add_oauth_link_button(self, authorization_url): - self.view["blocks"].append( - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Link Jira", - }, - "url": authorization_url, - "action_id": "oauth_url", - } - ], - } - ) - - def add_pat_submit_button(self): - self.view["blocks"].append( - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Submit", - }, - "action_id": "submit_pat", - } - ], - } - ) - - def add_pat_input_field(self): - self.view["blocks"].append( - { - "type": "input", - "block_id": "user_jira_pat_input", - "element": { - "type": "plain_text_input", - "action_id": "user_jira_pat", - "placeholder": {"type": "plain_text", "text": "Enter your personal access token"}, - }, - "label": {"type": "plain_text", "text": "PAT"}, - } - ) - - def add_clear_pat_button(self): - self.view["blocks"].append( - { - "type": "actions", - "elements": [ - { - "type": "button", - "text": { - "type": "plain_text", - "text": "Clear PAT", - }, - "action_id": "clear_pat", - } - ], - } - ) diff --git a/controllers/personal_access_token_table.py b/controllers/personal_access_token_table.py deleted file mode 100644 index 33ff582..0000000 --- a/controllers/personal_access_token_table.py +++ /dev/null @@ -1,14 +0,0 @@ -class PersonalAccessTokenTable(dict): - def __new__(cls): - if not hasattr(cls, "instance"): - cls.instance = super(PersonalAccessTokenTable, cls).__new__(cls) - return cls.instance - - def create_user(self, user_id: str, personal_access_token: str) -> None: - self[user_id] = personal_access_token - - def read_pat(self, user_id: str) -> str: - return self[user_id] - - def delete_user(self, user_id: str) -> None: - self.pop(user_id, None) diff --git a/listeners/actions/__init__.py b/listeners/actions/__init__.py index 80bac87..2585f33 100644 --- a/listeners/actions/__init__.py +++ b/listeners/actions/__init__.py @@ -1,11 +1,9 @@ from slack_bolt import App -from .clear_pat import clear_pat_callback -from .oauth_url import oauth_url_callback -from .submit_pat import submit_pat_callback +from .connect_account import connect_account_callback +from .disconnect_account import disconnect_account_callback def register(app: App): - app.action("submit_pat")(submit_pat_callback) - app.action("clear_pat")(clear_pat_callback) - app.action("oauth_url")(oauth_url_callback) + app.action("connect_account")(connect_account_callback) + app.action("disconnect_account")(disconnect_account_callback) diff --git a/listeners/actions/clear_pat.py b/listeners/actions/clear_pat.py deleted file mode 100644 index aa36f36..0000000 --- a/listeners/actions/clear_pat.py +++ /dev/null @@ -1,22 +0,0 @@ -from logging import Logger - -from slack_bolt import Ack -from slack_sdk import WebClient - -from controllers import AppHomeBuilder, PersonalAccessTokenTable - - -def clear_pat_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): - try: - ack() - user_id = body["user"]["id"] - - pat_table = PersonalAccessTokenTable() - pat_table.delete_user(user_id=user_id) - - home = AppHomeBuilder() - home.add_pat_input_field() - home.add_pat_submit_button() - client.views_publish(user_id=user_id, view=home.view) - except Exception as e: - logger.error(e) diff --git a/listeners/actions/connect_account.py b/listeners/actions/connect_account.py new file mode 100644 index 0000000..fc24bad --- /dev/null +++ b/listeners/actions/connect_account.py @@ -0,0 +1,5 @@ +from slack_bolt import Ack + + +def connect_account_callback(ack: Ack): + ack() diff --git a/listeners/actions/disconnect_account.py b/listeners/actions/disconnect_account.py new file mode 100644 index 0000000..265e49b --- /dev/null +++ b/listeners/actions/disconnect_account.py @@ -0,0 +1,10 @@ +from slack_bolt import Ack, BoltContext + +from globals import JIRA_FILE_INSTALLATION_STORE + + +def disconnect_account_callback(ack: Ack, context: BoltContext): + ack() + JIRA_FILE_INSTALLATION_STORE.delete_installation( + enterprise_id=context.enterprise_id, team_id=context.team_id, user_id=context.user_id + ) diff --git a/listeners/actions/oauth_url.py b/listeners/actions/oauth_url.py deleted file mode 100644 index d52cb11..0000000 --- a/listeners/actions/oauth_url.py +++ /dev/null @@ -1,5 +0,0 @@ -from slack_bolt import Ack - - -def oauth_url_callback(ack: Ack): - ack() diff --git a/listeners/actions/submit_pat.py b/listeners/actions/submit_pat.py deleted file mode 100644 index 6d00826..0000000 --- a/listeners/actions/submit_pat.py +++ /dev/null @@ -1,25 +0,0 @@ -from logging import Logger - -from slack_bolt import Ack -from slack_sdk import WebClient - -from controllers import AppHomeBuilder, PersonalAccessTokenTable - - -def submit_pat_callback(ack: Ack, client: WebClient, body: dict, logger: Logger): - try: - ack() - user_id = body["user"]["id"] - - home = AppHomeBuilder() - home.add_clear_pat_button() - client.views_publish(user_id=user_id, view=home.view) - - pat_table = PersonalAccessTokenTable() - pat_table.create_user( - user_id=user_id, - personal_access_token=body["view"]["state"]["values"]["user_jira_pat_input"]["user_jira_pat"]["value"], - ) - - except Exception as e: - logger.error(e) diff --git a/listeners/events/__init__.py b/listeners/events/__init__.py index df6d129..ce7c468 100644 --- a/listeners/events/__init__.py +++ b/listeners/events/__init__.py @@ -1,6 +1,6 @@ from slack_bolt import App -from .app_home_open import app_home_open_callback +from .app_home.app_home_open import app_home_open_callback def register(app: App): diff --git a/listeners/events/app_home/__init__.py b/listeners/events/app_home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py new file mode 100644 index 0000000..6c808be --- /dev/null +++ b/listeners/events/app_home/app_home_open.py @@ -0,0 +1,47 @@ +import uuid +from logging import Logger + +from slack_bolt import BoltContext +from slack_sdk import WebClient + +from .builder import AppHomeBuilder +from globals import ( + JIRA_CLIENT_ID, + JIRA_CODE_VERIFIER, + JIRA_FILE_INSTALLATION_STORE, + JIRA_REDIRECT_URI, + OAUTH_STATE_TABLE, + UserIdentity, +) +from jira.client import JiraClient + + +def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext): + # ignore the app_home_opened event for anything but the Home tab + if event["tab"] != "home": + return + try: + home = AppHomeBuilder() + installation = JIRA_FILE_INSTALLATION_STORE.find_installation( + user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id + ) + + if installation is None: + state = uuid.uuid4().hex + OAUTH_STATE_TABLE[state] = UserIdentity( + user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id + ) + jira_client = JiraClient() + authorization_url = jira_client.build_authorization_url( + client_id=JIRA_CLIENT_ID, + redirect_uri=JIRA_REDIRECT_URI, + scope="WRITE", + code_challenge=JIRA_CODE_VERIFIER, + state=state, + ) + home.add_connect_account_button(authorization_url) + else: + home.add_disconnect_account_button() + client.views_publish(user_id=context.user_id, view=home.view) + except Exception as e: + logger.error(f"Error publishing home tab: {e}") diff --git a/listeners/events/app_home/builder.py b/listeners/events/app_home/builder.py new file mode 100644 index 0000000..bca2737 --- /dev/null +++ b/listeners/events/app_home/builder.py @@ -0,0 +1,60 @@ +from typing import TypedDict + +context = "Welcome to jira" + + +class AppHome(TypedDict): + type: str + blocks: list + + +class AppHomeBuilder: + def __init__(self): + self.view: AppHome = { + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": context, + }, + }, + {"type": "divider"}, + ], + } + + def add_connect_account_button(self, authorization_url): + self.view["blocks"].append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Connect an Account", + }, + "url": authorization_url, + "action_id": "connect_account", + } + ], + } + ) + + def add_disconnect_account_button(self): + self.view["blocks"].append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Disconnect Account", + }, + "action_id": "disconnect_account", + } + ], + } + ) diff --git a/listeners/events/app_home_open.py b/listeners/events/app_home_open.py deleted file mode 100644 index 5f99b49..0000000 --- a/listeners/events/app_home_open.py +++ /dev/null @@ -1,33 +0,0 @@ -import uuid -from logging import Logger - -from slack_bolt import BoltContext -from slack_sdk import WebClient - -from controllers import AppHomeBuilder -from globals import JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI, OAUTH_STATE_TABLE, UserIdentity -from jira.client import JiraClient - - -def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext): - # ignore the app_home_opened event for anything but the Home tab - if event["tab"] != "home": - return - state = uuid.uuid4().hex - OAUTH_STATE_TABLE[state] = UserIdentity( - user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id - ) - try: - home = AppHomeBuilder() - jira_client = JiraClient() - authorization_url = jira_client.build_authorization_url( - client_id=JIRA_CLIENT_ID, - redirect_uri=JIRA_REDIRECT_URI, - scope="WRITE", - code_challenge=JIRA_CODE_VERIFIER, - state=state, - ) - home.add_oauth_link_button(authorization_url) - client.views_publish(user_id=context.user_id, view=home.view) - except Exception as e: - logger.error(f"Error publishing home tab: {e}") diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 37e5bad..ab6afe0 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -1,21 +1,28 @@ import json import logging -from slack_bolt import Ack, Complete, Fail - -from controllers import PersonalAccessTokenTable +from slack_bolt import Ack, BoltContext, Complete, Fail +from globals import JIRA_FILE_INSTALLATION_STORE from jira.client import JiraClient # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ # https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issues/#api-rest-api-2-issue-post -def create_issue_callback(ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger): +def create_issue_callback( + ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger, context: BoltContext +): ack() - pat_table = PersonalAccessTokenTable() + # pat_table = PersonalAccessTokenTable() user_id = inputs["user_id"] - if user_id not in pat_table: - # TODO send a message to user on how to fix this - return fail(f"User {user_id} has not set up their PAT properly, visit the app home to do this") + installation = JIRA_FILE_INSTALLATION_STORE.find_installation( + user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id + ) + + if installation is None: + return fail(f"User {user_id} has not connected their account properly, visit the app home to do this") + # if user_id not in pat_table: + # # TODO send a message to user on how to fix this + # return fail(f"User {user_id} has not set up their PAT properly, visit the app home to do this") # JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") diff --git a/oauth/installation_store/file/__init__.py b/oauth/installation_store/file/__init__.py index e60296e..324b743 100644 --- a/oauth/installation_store/file/__init__.py +++ b/oauth/installation_store/file/__init__.py @@ -63,7 +63,7 @@ def delete_installation( *, enterprise_id: Optional[str], team_id: Optional[str], - user_id: Optional[str] = None, + user_id: str, ) -> None: none = "none" e_id = enterprise_id or none From 6102cbb033d2c7ec309a5e8ae53006b8d889be44 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 16 May 2024 15:10:06 -0400 Subject: [PATCH 15/45] Improve the OAuth state store --- app.py | 5 ++--- globals.py | 3 --- listeners/events/app_home/app_home_open.py | 15 ++++++++------- .../{file/__init__.py => file.py} | 2 +- oauth/installation_store/installation_store.py | 2 +- .../{models/jira_installation.py => models.py} | 0 oauth/installation_store/models/__init__.py | 5 ----- oauth/state_store/__init__.py | 0 oauth/state_store/memory.py | 18 ++++++++++++++++++ .../__init__.py => state_store/models.py} | 0 10 files changed, 30 insertions(+), 20 deletions(-) rename oauth/installation_store/{file/__init__.py => file.py} (97%) rename oauth/installation_store/{models/jira_installation.py => models.py} (100%) delete mode 100644 oauth/installation_store/models/__init__.py create mode 100644 oauth/state_store/__init__.py create mode 100644 oauth/state_store/memory.py rename oauth/{models/__init__.py => state_store/models.py} (100%) diff --git a/app.py b/app.py index a593a1a..e7c35ad 100644 --- a/app.py +++ b/app.py @@ -14,10 +14,10 @@ JIRA_FILE_INSTALLATION_STORE, JIRA_REDIRECT_URI, OAUTH_REDIRECT_PATH, - OAUTH_STATE_TABLE, ) from jira.client import JiraClient from listeners import register_listeners +from oauth.state_store.memory import MemoryOAuthStateStore logging.basicConfig(level=logging.INFO) @@ -41,7 +41,7 @@ def oauth_redirect(): redirect_uri=JIRA_REDIRECT_URI, ) jira_resp.raise_for_status() - user_identity = OAUTH_STATE_TABLE[state] + user_identity = MemoryOAuthStateStore.consume(state) jira_resp_json = jira_resp.json() JIRA_FILE_INSTALLATION_STORE.save( { @@ -56,7 +56,6 @@ def oauth_redirect(): "user_id": user_identity.user_id, } ) - del OAUTH_STATE_TABLE[state] return redirect(APP_HOME_PAGE_URL, code=302) diff --git a/globals.py b/globals.py index 6346912..537b266 100644 --- a/globals.py +++ b/globals.py @@ -1,11 +1,8 @@ import os -from typing import Dict from oauth.installation_store import FileInstallationStore -from oauth.models import UserIdentity OAUTH_REDIRECT_PATH = "/oauth/redirect" -OAUTH_STATE_TABLE: Dict[str, UserIdentity] = {} JIRA_FILE_INSTALLATION_STORE = FileInstallationStore(base_dir="./data/installations") JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") # https://jira.atlassian.com diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index 6c808be..77cb148 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -1,22 +1,22 @@ -import uuid from logging import Logger from slack_bolt import BoltContext from slack_sdk import WebClient +from oauth.state_store.memory import MemoryOAuthStateStore +from oauth.state_store.models import UserIdentity + from .builder import AppHomeBuilder from globals import ( JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_FILE_INSTALLATION_STORE, JIRA_REDIRECT_URI, - OAUTH_STATE_TABLE, - UserIdentity, ) from jira.client import JiraClient -def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext): +def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext, payload: dict): # ignore the app_home_opened event for anything but the Home tab if event["tab"] != "home": return @@ -27,9 +27,10 @@ def app_home_open_callback(client: WebClient, event: dict, logger: Logger, conte ) if installation is None: - state = uuid.uuid4().hex - OAUTH_STATE_TABLE[state] = UserIdentity( - user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id + state = MemoryOAuthStateStore.issue( + user_identity=UserIdentity( + user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id + ) ) jira_client = JiraClient() authorization_url = jira_client.build_authorization_url( diff --git a/oauth/installation_store/file/__init__.py b/oauth/installation_store/file.py similarity index 97% rename from oauth/installation_store/file/__init__.py rename to oauth/installation_store/file.py index 324b743..40213cb 100644 --- a/oauth/installation_store/file/__init__.py +++ b/oauth/installation_store/file.py @@ -7,7 +7,7 @@ from typing import Optional, Union from oauth.installation_store.installation_store import InstallationStore -from oauth.installation_store.models.jira_installation import JiraInstallation +from oauth.installation_store.models import JiraInstallation class FileInstallationStore(InstallationStore): diff --git a/oauth/installation_store/installation_store.py b/oauth/installation_store/installation_store.py index 392a711..e0e7fe9 100644 --- a/oauth/installation_store/installation_store.py +++ b/oauth/installation_store/installation_store.py @@ -1,6 +1,6 @@ from typing import Optional -from .models.jira_installation import JiraInstallation +from .models import JiraInstallation class InstallationStore: diff --git a/oauth/installation_store/models/jira_installation.py b/oauth/installation_store/models.py similarity index 100% rename from oauth/installation_store/models/jira_installation.py rename to oauth/installation_store/models.py diff --git a/oauth/installation_store/models/__init__.py b/oauth/installation_store/models/__init__.py deleted file mode 100644 index 1f63cd5..0000000 --- a/oauth/installation_store/models/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .jira_installation import JiraInstallation - -__all__ = [ - "JiraInstallation", -] diff --git a/oauth/state_store/__init__.py b/oauth/state_store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/oauth/state_store/memory.py b/oauth/state_store/memory.py new file mode 100644 index 0000000..f405e2f --- /dev/null +++ b/oauth/state_store/memory.py @@ -0,0 +1,18 @@ +from typing import Dict +import uuid +from .models import UserIdentity + + +OAUTH_STATE_TABLE: Dict[str, UserIdentity] = {} + + +class MemoryOAuthStateStore: + @staticmethod + def issue(user_identity: UserIdentity) -> str: + state = uuid.uuid4().hex + OAUTH_STATE_TABLE[state] = user_identity + return state + + @staticmethod + def consume(state: str) -> UserIdentity: + return OAUTH_STATE_TABLE.pop(state) diff --git a/oauth/models/__init__.py b/oauth/state_store/models.py similarity index 100% rename from oauth/models/__init__.py rename to oauth/state_store/models.py From 966e70dbc81f5b6e89c5477a483dba4d583e4937 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 16 May 2024 15:34:14 -0400 Subject: [PATCH 16/45] Its taking shape --- .sample.env | 1 - app.py | 9 ++++++--- globals.py | 11 +++++------ listeners/actions/disconnect_account.py | 4 ++-- listeners/events/app_home/app_home_open.py | 4 ++-- listeners/functions/create_issue.py | 4 ++-- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.sample.env b/.sample.env index 64335f8..e3d10ce 100644 --- a/.sample.env +++ b/.sample.env @@ -1,6 +1,5 @@ JIRA_BASE_URL=http://localhost:8080 HEADER_MY_PROXY_TOKEN=A1B2C3 -CODE_VERIFIER=ABC1234 JIRA_CLIENT_ID=abc134 JIRA_CLIENT_SECRET=abc134 APP_BASE_URL=https://this-si-my-app diff --git a/app.py b/app.py index e7c35ad..5c2be20 100644 --- a/app.py +++ b/app.py @@ -11,12 +11,12 @@ JIRA_CLIENT_ID, JIRA_CLIENT_SECRET, JIRA_CODE_VERIFIER, - JIRA_FILE_INSTALLATION_STORE, JIRA_REDIRECT_URI, OAUTH_REDIRECT_PATH, ) from jira.client import JiraClient from listeners import register_listeners +from oauth.installation_store.file import FileInstallationStore from oauth.state_store.memory import MemoryOAuthStateStore logging.basicConfig(level=logging.INFO) @@ -32,6 +32,7 @@ def oauth_redirect(): code = request.args["code"] state = request.args["state"] + jira_client = JiraClient() jira_resp = jira_client.oauth2_token( code=code, @@ -41,9 +42,11 @@ def oauth_redirect(): redirect_uri=JIRA_REDIRECT_URI, ) jira_resp.raise_for_status() - user_identity = MemoryOAuthStateStore.consume(state) jira_resp_json = jira_resp.json() - JIRA_FILE_INSTALLATION_STORE.save( + + user_identity = MemoryOAuthStateStore.consume(state) + + FileInstallationStore().save( { "access_token": jira_resp_json["access_token"], "enterprise_id": user_identity.enterprise_id, diff --git a/globals.py b/globals.py index 537b266..52169a1 100644 --- a/globals.py +++ b/globals.py @@ -1,13 +1,12 @@ import os - -from oauth.installation_store import FileInstallationStore +import secrets +from urllib.parse import urljoin OAUTH_REDIRECT_PATH = "/oauth/redirect" -JIRA_FILE_INSTALLATION_STORE = FileInstallationStore(base_dir="./data/installations") +JIRA_CODE_VERIFIER = secrets.token_urlsafe(96)[:128] -JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") # https://jira.atlassian.com +JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") # ex: https://jira.atlassian.com JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") -JIRA_CODE_VERIFIER = os.getenv("CODE_VERIFIER") -JIRA_REDIRECT_URI = os.getenv("APP_BASE_URL") + OAUTH_REDIRECT_PATH +JIRA_REDIRECT_URI = urljoin(os.getenv("APP_BASE_URL"), OAUTH_REDIRECT_PATH) APP_HOME_PAGE_URL = os.getenv("APP_HOME_PAGE_URL") diff --git a/listeners/actions/disconnect_account.py b/listeners/actions/disconnect_account.py index 265e49b..9eaff4f 100644 --- a/listeners/actions/disconnect_account.py +++ b/listeners/actions/disconnect_account.py @@ -1,10 +1,10 @@ from slack_bolt import Ack, BoltContext -from globals import JIRA_FILE_INSTALLATION_STORE +from oauth.installation_store.file import FileInstallationStore def disconnect_account_callback(ack: Ack, context: BoltContext): ack() - JIRA_FILE_INSTALLATION_STORE.delete_installation( + FileInstallationStore().delete_installation( enterprise_id=context.enterprise_id, team_id=context.team_id, user_id=context.user_id ) diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index 77cb148..8427f8c 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -3,6 +3,7 @@ from slack_bolt import BoltContext from slack_sdk import WebClient +from oauth.installation_store.file import FileInstallationStore from oauth.state_store.memory import MemoryOAuthStateStore from oauth.state_store.models import UserIdentity @@ -10,7 +11,6 @@ from globals import ( JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, - JIRA_FILE_INSTALLATION_STORE, JIRA_REDIRECT_URI, ) from jira.client import JiraClient @@ -22,7 +22,7 @@ def app_home_open_callback(client: WebClient, event: dict, logger: Logger, conte return try: home = AppHomeBuilder() - installation = JIRA_FILE_INSTALLATION_STORE.find_installation( + installation = FileInstallationStore().find_installation( user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index ab6afe0..7b899c9 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -1,8 +1,8 @@ import json import logging from slack_bolt import Ack, BoltContext, Complete, Fail -from globals import JIRA_FILE_INSTALLATION_STORE from jira.client import JiraClient +from oauth.installation_store.file import FileInstallationStore # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ @@ -14,7 +14,7 @@ def create_issue_callback( # pat_table = PersonalAccessTokenTable() user_id = inputs["user_id"] - installation = JIRA_FILE_INSTALLATION_STORE.find_installation( + installation = FileInstallationStore().find_installation( user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) From fd50dd97bfbeb720a398d97ba245a8fe914a0341 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 16 May 2024 15:46:48 -0400 Subject: [PATCH 17/45] It all works --- listeners/functions/create_issue.py | 40 ++++------------------------- manifest.json | 2 +- 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 7b899c9..3cfe9de 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -11,51 +11,21 @@ def create_issue_callback( ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger, context: BoltContext ): ack() - # pat_table = PersonalAccessTokenTable() + user_id = inputs["user_context"]["id"] - user_id = inputs["user_id"] installation = FileInstallationStore().find_installation( - user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id + user_id=user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) - if installation is None: - return fail(f"User {user_id} has not connected their account properly, visit the app home to do this") - # if user_id not in pat_table: - # # TODO send a message to user on how to fix this - # return fail(f"User {user_id} has not set up their PAT properly, visit the app home to do this") - - # JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") - - # headers = { - # "Authorization": f"Bearer {pat_table.read_pat(user_id)}", - # "Accept": "application/json", - # "Content-Type": "application/json", - # } - - # for name, value in os.environ.items(): - # if name.startswith("HEADER_"): - # headers[name.split("HEADER_")[1].replace("_", "-")] = value + return fail(f"User {user_id} has not connected their account properly, visit the app home to solve this") try: project: str = inputs["project"] issue_type: str = inputs["issuetype"] - # url = f"{JIRA_BASE_URL}/rest/api/latest/issue" - - # payload = json.dumps( - # { - # "fields": { - # "description": inputs["description"], - # "issuetype": {"id" if issue_type.isdigit() else "name": issue_type}, - # "project": {"id" if project.isdigit() else "key": project}, - # "summary": inputs["summary"], - # }, - # } - # ) - # response = requests.post(url, data=payload, headers=headers) - jira_client = JiraClient() + jira_client = JiraClient(token=installation["access_token"]) response = jira_client.create_issue( - { + data={ "fields": { "description": inputs["description"], "issuetype": {"id" if issue_type.isdigit() else "name": issue_type}, diff --git a/manifest.json b/manifest.json index 2c7cefe..dccd2a7 100644 --- a/manifest.json +++ b/manifest.json @@ -37,7 +37,7 @@ "description": "Create a JIRA SERVER issue", "input_parameters": { "properties": { - "user_id": { + "user_context": { "type": "slack#/types/user_context", "title": "Represents a user who interacted with a workflow at runtime.", "is_required": true From 97a9611c14715ef5ffe1406c8c2592d91587dd23 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 16 May 2024 15:57:42 -0400 Subject: [PATCH 18/45] Its works --- app.py | 11 +++-------- listeners/events/app_home/app_home_open.py | 8 ++------ listeners/functions/create_issue.py | 2 ++ oauth/state_store/memory.py | 4 ++-- tests/functions/test_create_issue.py | 2 +- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/app.py b/app.py index 5c2be20..57b2d65 100644 --- a/app.py +++ b/app.py @@ -6,14 +6,9 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -from globals import ( - APP_HOME_PAGE_URL, - JIRA_CLIENT_ID, - JIRA_CLIENT_SECRET, - JIRA_CODE_VERIFIER, - JIRA_REDIRECT_URI, - OAUTH_REDIRECT_PATH, -) +from globals import (APP_HOME_PAGE_URL, JIRA_CLIENT_ID, JIRA_CLIENT_SECRET, + JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI, + OAUTH_REDIRECT_PATH) from jira.client import JiraClient from listeners import register_listeners from oauth.installation_store.file import FileInstallationStore diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index 8427f8c..d3e51d4 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -3,17 +3,13 @@ from slack_bolt import BoltContext from slack_sdk import WebClient +from globals import JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI +from jira.client import JiraClient from oauth.installation_store.file import FileInstallationStore from oauth.state_store.memory import MemoryOAuthStateStore from oauth.state_store.models import UserIdentity from .builder import AppHomeBuilder -from globals import ( - JIRA_CLIENT_ID, - JIRA_CODE_VERIFIER, - JIRA_REDIRECT_URI, -) -from jira.client import JiraClient def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext, payload: dict): diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 3cfe9de..24ff516 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -1,6 +1,8 @@ import json import logging + from slack_bolt import Ack, BoltContext, Complete, Fail + from jira.client import JiraClient from oauth.installation_store.file import FileInstallationStore diff --git a/oauth/state_store/memory.py b/oauth/state_store/memory.py index f405e2f..78f4f79 100644 --- a/oauth/state_store/memory.py +++ b/oauth/state_store/memory.py @@ -1,7 +1,7 @@ -from typing import Dict import uuid -from .models import UserIdentity +from typing import Dict +from .models import UserIdentity OAUTH_STATE_TABLE: Dict[str, UserIdentity] = {} diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index bac88a7..0865909 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -4,8 +4,8 @@ from unittest.mock import MagicMock, patch import requests - from controllers import PersonalAccessTokenTable + from listeners.functions.create_issue import create_issue_callback from tests.utils import remove_os_env_temporarily, restore_os_env From 3dc5204ec0147a6a9cffeb333fe0d46d2cb25c14 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 16 May 2024 16:29:06 -0400 Subject: [PATCH 19/45] clean up --- .gitignore | 1 - requirements.txt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dea87df..5477389 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,5 @@ logs/ .pytype/ #tmp -requirements.txt .slack data diff --git a/requirements.txt b/requirements.txt index 1159c89..e121f7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,5 @@ slack-bolt==1.19.0rc1 pytest flake8==7.0.0 black==23.12.1 -requests +requests==2.31.0 Flask==3.0.3 From 701aeb6bb0953c8404825e928f2cd10bb1dd8118 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 28 May 2024 11:36:56 -0400 Subject: [PATCH 20/45] Improve the secret headers --- .sample.env | 3 ++- jira/client.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.sample.env b/.sample.env index e3d10ce..28f9b26 100644 --- a/.sample.env +++ b/.sample.env @@ -1,5 +1,6 @@ JIRA_BASE_URL=http://localhost:8080 -HEADER_MY_PROXY_TOKEN=A1B2C3 +SECRET_HEADER_KEY=My-Header +SECRET_HEADER_VALUE=a123 JIRA_CLIENT_ID=abc134 JIRA_CLIENT_SECRET=abc134 APP_BASE_URL=https://this-si-my-app diff --git a/jira/client.py b/jira/client.py index 86bce99..da5acf6 100644 --- a/jira/client.py +++ b/jira/client.py @@ -23,7 +23,7 @@ def __init__( self.base_url = base_url self.token_type = token_type self.headers = headers or {} - self.headers["TSAuth-Token"] = os.getenv("HEADER_TSAuth_Token") + self.headers[os.getenv("SECRET_HEADER_KEY")] = os.getenv("SECRET_HEADER_VALUE") if token is not None: self.headers["Authorization"] = f"{self.token_type} {self.token}" self.proxies = proxies From 9562159cfd499b536312a62c7da43335dd271e40 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 28 May 2024 14:33:17 -0400 Subject: [PATCH 21/45] Improve the behavior --- app.py | 17 ++++-- globals.py => internals.py | 1 - jira/client.py | 14 +++-- listeners/events/app_home/app_home_open.py | 2 +- listeners/functions/create_issue.py | 12 +++- oauth/state_store/models.py | 13 +++-- tests/functions/test_create_issue.py | 58 ++++++++++++++++---- tests/mock_installation_store.py | 64 ++++++++++++++++++++++ 8 files changed, 149 insertions(+), 32 deletions(-) rename globals.py => internals.py (83%) create mode 100644 tests/mock_installation_store.py diff --git a/app.py b/app.py index 57b2d65..9077d27 100644 --- a/app.py +++ b/app.py @@ -6,9 +6,14 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -from globals import (APP_HOME_PAGE_URL, JIRA_CLIENT_ID, JIRA_CLIENT_SECRET, - JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI, - OAUTH_REDIRECT_PATH) +from internals import ( + APP_HOME_PAGE_URL, + JIRA_CLIENT_ID, + JIRA_CLIENT_SECRET, + JIRA_CODE_VERIFIER, + JIRA_REDIRECT_URI, + OAUTH_REDIRECT_PATH, +) from jira.client import JiraClient from listeners import register_listeners from oauth.installation_store.file import FileInstallationStore @@ -44,14 +49,14 @@ def oauth_redirect(): FileInstallationStore().save( { "access_token": jira_resp_json["access_token"], - "enterprise_id": user_identity.enterprise_id, + "enterprise_id": user_identity["enterprise_id"], "expires_in": jira_resp_json["expires_in"], "installed_at": datetime.now().timestamp(), "refresh_token": jira_resp_json["refresh_token"], "scope": jira_resp_json["scope"], - "team_id": user_identity.team_id, + "team_id": user_identity["team_id"], "token_type": jira_resp_json["token_type"], - "user_id": user_identity.user_id, + "user_id": user_identity["user_id"], } ) return redirect(APP_HOME_PAGE_URL, code=302) diff --git a/globals.py b/internals.py similarity index 83% rename from globals.py rename to internals.py index 52169a1..658dd07 100644 --- a/globals.py +++ b/internals.py @@ -5,7 +5,6 @@ OAUTH_REDIRECT_PATH = "/oauth/redirect" JIRA_CODE_VERIFIER = secrets.token_urlsafe(96)[:128] -JIRA_BASE_URL = os.getenv("JIRA_BASE_URL") # ex: https://jira.atlassian.com JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") JIRA_REDIRECT_URI = urljoin(os.getenv("APP_BASE_URL"), OAUTH_REDIRECT_PATH) diff --git a/jira/client.py b/jira/client.py index da5acf6..5f8f21a 100644 --- a/jira/client.py +++ b/jira/client.py @@ -6,21 +6,19 @@ import requests from requests import Response -from globals import JIRA_BASE_URL - class JiraClient: def __init__( self, token: Optional[str] = None, - base_url: str = JIRA_BASE_URL, + base_url: Optional[str] = None, token_type: Optional[str] = "Bearer", headers: Optional[dict] = None, proxies: Optional[Dict[str, str]] = None, ): self.token = token - self.base_url = base_url + self.base_url = base_url or os.environ.get("JIRA_BASE_URL") self.token_type = token_type self.headers = headers or {} self.headers[os.getenv("SECRET_HEADER_KEY")] = os.getenv("SECRET_HEADER_VALUE") @@ -80,6 +78,14 @@ def build_authorization_url( ) return f"{urljoin(self.base_url, '/rest/oauth2/latest/authorize')}?{urlencode(kwargs)}" + def build_issue_url( + self, + *, + key: str, + **kwargs, + ) -> str: + return f"{urljoin(self.base_url, f'/browse/{key}')}" + def oauth2_token( self, *, diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index d3e51d4..02de1c5 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -3,7 +3,7 @@ from slack_bolt import BoltContext from slack_sdk import WebClient -from globals import JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI +from internals import JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI from jira.client import JiraClient from oauth.installation_store.file import FileInstallationStore from oauth.state_store.memory import MemoryOAuthStateStore diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 24ff516..29a240f 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -2,6 +2,7 @@ import logging from slack_bolt import Ack, BoltContext, Complete, Fail +from slack_sdk import WebClient from jira.client import JiraClient from oauth.installation_store.file import FileInstallationStore @@ -10,7 +11,7 @@ # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ # https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issues/#api-rest-api-2-issue-post def create_issue_callback( - ack: Ack, inputs: dict, fail: Fail, complete: Complete, logger: logging.Logger, context: BoltContext + ack: Ack, inputs: dict, fail: Fail, complete: Complete, context: BoltContext, client: WebClient, logger: logging.Logger ): ack() user_id = inputs["user_context"]["id"] @@ -19,6 +20,9 @@ def create_issue_callback( user_id=user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) if installation is None: + client.chat_postMessage( + text="The function failed because the is no connected jira account, visit the app home to solve this" + ) return fail(f"User {user_id} has not connected their account properly, visit the app home to solve this") try: @@ -39,7 +43,11 @@ def create_issue_callback( response.raise_for_status() jason_data = json.loads(response.text) - complete(outputs={"issue_url": jason_data["self"]}) + complete( + outputs={ + "issue_url": jira_client.build_issue_url(key=jason_data["key"]), + } + ) except Exception as e: logger.exception(e) fail(f"Failed to handle a function request (error: {e})") diff --git a/oauth/state_store/models.py b/oauth/state_store/models.py index 7034cca..9ebe8e9 100644 --- a/oauth/state_store/models.py +++ b/oauth/state_store/models.py @@ -1,8 +1,9 @@ -from typing import Optional +from typing import Optional, TypedDict -class UserIdentity: - def __init__(self, user_id: str, team_id: Optional[str], enterprise_id: Optional[str]): - self.user_id = user_id - self.team_id = team_id - self.enterprise_id = enterprise_id +class UserIdentity(TypedDict): + """Class for keeping track of individual slack users""" + + user_id: str + team_id: Optional[str] + enterprise_id: Optional[str] diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index 0865909..e1b193b 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -1,12 +1,13 @@ +from datetime import datetime import json import logging import os from unittest.mock import MagicMock, patch import requests -from controllers import PersonalAccessTokenTable from listeners.functions.create_issue import create_issue_callback +from tests.mock_installation_store import MockInstallationStore from tests.utils import remove_os_env_temporarily, restore_os_env @@ -20,30 +21,54 @@ def mock_response(status=200, data: dict = None): class TestCreateIssue: + user_id = "U1234" + team_id = "T1234" + enterprise_id = "E1234" + + def build_mock_installation_store(self): + installation_store = MockInstallationStore() + installation_store.save( + { + "scope": "WRITE", + "access_token": "jira_access_token", + "token_type": "Bearer", + "expires_in": 1000, + "refresh_token": "jira_refresh_token", + "user_id": self.user_id, + "team_id": "T1234", + "enterprise_id": "E1234", + "installed_at": datetime.now().timestamp(), + } + ) + return installation_store + def setup_method(self): self.old_os_env = remove_os_env_temporarily() - PersonalAccessTokenTable().clear() + os.environ["JIRA_BASE_URL"] = "https://jira-dev/" + self.mock_installation_store = patch( + "listeners.functions.create_issue.FileInstallationStore", self.build_mock_installation_store + ) + self.mock_installation_store.start() def teardown_method(self): - PersonalAccessTokenTable().clear() + self.mock_installation_store.stop() restore_os_env(self.old_os_env) def test_create_issue(self): mock_ack = MagicMock() mock_fail = MagicMock() mock_complete = MagicMock() - mock_input = { - "user_id": "me", + mock_context = MagicMock(team_id=self.team_id, enterprise_id=self.enterprise_id) + mock_client = MagicMock() + mock_inputs = { + "user_context": {"id": self.user_id}, "project": "PROJ", "issuetype": "Bug", "summary": "this is a test from python", "description": "this is a test from python", } - PersonalAccessTokenTable().create_user("me", "my_pat_token") - os.environ["JIRA_BASE_URL"] = "https://jira-dev/" - - with patch.object(requests, "post") as mock_requests: + with patch.object(requests, "request") as mock_requests: mock_requests.return_value = mock_response( status=201, data={ @@ -52,9 +77,18 @@ def test_create_issue(self): "self": "https://jira-dev/rest/api/2/issue/1234", }, ) - create_issue_callback(mock_ack, mock_input, mock_fail, mock_complete, logging.getLogger()) - mock_requests.assert_called_once() + create_issue_callback( + ack=mock_ack, + inputs=mock_inputs, + fail=mock_fail, + complete=mock_complete, + context=mock_context, + client=mock_client, + logger=logging.getLogger(), + ) + mock_fail.assert_not_called() + mock_requests.assert_called_once() mock_ack.assert_called_once() mock_complete.assert_called_once() - assert mock_complete.call_args[1] == {"outputs": {"issue_url": "https://jira-dev/rest/api/2/issue/1234"}} + assert mock_complete.call_args[1] == {"outputs": {"issue_url": "https://jira-dev/browse/PROJ-1"}} diff --git a/tests/mock_installation_store.py b/tests/mock_installation_store.py new file mode 100644 index 0000000..8f9f77c --- /dev/null +++ b/tests/mock_installation_store.py @@ -0,0 +1,64 @@ +from logging import Logger +import logging +from typing import Dict, Optional + +from oauth.installation_store.installation_store import InstallationStore +from oauth.installation_store.models import JiraInstallation + + +class MockInstallationStore(InstallationStore): + def __init__( + self, + *, + logger: Logger = logging.getLogger(__name__), + ): + self.installation_table: Dict[str, JiraInstallation] = {} + self.logger = logger + + def save(self, installation: JiraInstallation): + none = "none" + e_id = installation["enterprise_id"] or none + t_id = installation["team_id"] or none + team_installation_dir = f"{e_id}-{t_id}" + + u_id = installation["user_id"] + installer_filepath = f"{team_installation_dir}/installer-{u_id}-latest" + self.installation_table[installer_filepath] = installation + + def find_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: str, + ) -> Optional[JiraInstallation]: + none = "none" + e_id = enterprise_id or none + t_id = team_id or none + installation_filepath = f"{e_id}-{t_id}/installer-{user_id}-latest" + + installation = self.installation_table.get(installation_filepath, None) + if installation is None: + message = f"Installation data missing for enterprise: {e_id}, team: {t_id}: not found" + self.logger.debug(message) + return installation + + def delete_installation( + self, + *, + enterprise_id: Optional[str], + team_id: Optional[str], + user_id: str, + ) -> None: + none = "none" + e_id = enterprise_id or none + t_id = team_id or none + installation_filepath = f"{e_id}-{t_id}/installer-{user_id}-latest" + if installation_filepath in self.installation_table: + del self.installation_table[installation_filepath] + else: + message = f"Failed to delete installation data for enterprise: {e_id}, team: {t_id}: not found" + self.logger.warning(message) + + def clear(self): + self.installation_table.clear() From cecf18cbee0a3fbbab0a450aedce0698136afa6c Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 28 May 2024 14:39:07 -0400 Subject: [PATCH 22/45] Improve mocks --- tests/mock_installation_store.py | 36 +++++++++++++------------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/tests/mock_installation_store.py b/tests/mock_installation_store.py index 8f9f77c..c49cb57 100644 --- a/tests/mock_installation_store.py +++ b/tests/mock_installation_store.py @@ -16,14 +16,8 @@ def __init__( self.logger = logger def save(self, installation: JiraInstallation): - none = "none" - e_id = installation["enterprise_id"] or none - t_id = installation["team_id"] or none - team_installation_dir = f"{e_id}-{t_id}" - - u_id = installation["user_id"] - installer_filepath = f"{team_installation_dir}/installer-{u_id}-latest" - self.installation_table[installer_filepath] = installation + installation_key = self._get_key(installation["enterprise_id"], installation["team_id"], installation["user_id"]) + self.installation_table[installation_key] = installation def find_installation( self, @@ -32,14 +26,11 @@ def find_installation( team_id: Optional[str], user_id: str, ) -> Optional[JiraInstallation]: - none = "none" - e_id = enterprise_id or none - t_id = team_id or none - installation_filepath = f"{e_id}-{t_id}/installer-{user_id}-latest" + installation_key = self._get_key(enterprise_id, team_id, user_id) - installation = self.installation_table.get(installation_filepath, None) + installation = self.installation_table.get(installation_key, None) if installation is None: - message = f"Installation data missing for enterprise: {e_id}, team: {t_id}: not found" + message = f"Installation data missing for enterprise: {enterprise_id}, team: {team_id}: not found" self.logger.debug(message) return installation @@ -50,15 +41,18 @@ def delete_installation( team_id: Optional[str], user_id: str, ) -> None: - none = "none" - e_id = enterprise_id or none - t_id = team_id or none - installation_filepath = f"{e_id}-{t_id}/installer-{user_id}-latest" - if installation_filepath in self.installation_table: - del self.installation_table[installation_filepath] + installation_key = self._get_key(enterprise_id, team_id, user_id) + if installation_key in self.installation_table: + del self.installation_table[installation_key] else: - message = f"Failed to delete installation data for enterprise: {e_id}, team: {t_id}: not found" + message = f"Failed to delete installation data for enterprise: {enterprise_id}, team: {team_id}: not found" self.logger.warning(message) def clear(self): self.installation_table.clear() + + def _get_key(self, enterprise_id: Optional[str], team_id: Optional[str], user_id: str): + none = "none" + e_id = enterprise_id or none + t_id = team_id or none + return f"{e_id}-{t_id}/installer-{user_id}-latest" From a883804f493482f35ea7ffccd0c4094627a213da Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Tue, 28 May 2024 15:16:05 -0400 Subject: [PATCH 23/45] Improve tests --- listeners/events/app_home/app_home_open.py | 2 +- listeners/functions/create_issue.py | 3 +- tests/functions/test_create_issue.py | 41 ++++++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index 02de1c5..87eb18d 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -12,7 +12,7 @@ from .builder import AppHomeBuilder -def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext, payload: dict): +def app_home_open_callback(client: WebClient, event: dict, logger: Logger, context: BoltContext): # ignore the app_home_opened event for anything but the Home tab if event["tab"] != "home": return diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 29a240f..61cb066 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -21,7 +21,8 @@ def create_issue_callback( ) if installation is None: client.chat_postMessage( - text="The function failed because the is no connected jira account, visit the app home to solve this" + channel=user_id, + text="The function failed because the is no connected jira account, visit the app home to solve this", ) return fail(f"User {user_id} has not connected their account properly, visit the app home to solve this") diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index e1b193b..3d66c07 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -12,8 +12,7 @@ def mock_response(status=200, data: dict = None): - mock_resp = MagicMock() - mock_resp.status_code = status + mock_resp = MagicMock(status_code=status) if data: mock_resp.json = MagicMock(return_value=data) mock_resp.text = json.dumps(data) @@ -92,3 +91,41 @@ def test_create_issue(self): mock_ack.assert_called_once() mock_complete.assert_called_once() assert mock_complete.call_args[1] == {"outputs": {"issue_url": "https://jira-dev/browse/PROJ-1"}} + + def test_create_issue_fail(self): + mock_ack = MagicMock() + mock_fail = MagicMock() + mock_complete = MagicMock() + mock_context = MagicMock(team_id=self.team_id, enterprise_id=self.enterprise_id) + mock_client = MagicMock(chat_postMessage=lambda channel, text: True) + mock_inputs = { + "user_context": {"id": "wrong_id"}, + "project": "PROJ", + "issuetype": "Bug", + "summary": "this is a test from python", + "description": "this is a test from python", + } + + with patch.object(requests, "request") as mock_requests: + mock_requests.return_value = mock_response( + status=201, + data={ + "id": "1234", + "key": "PROJ-1", + "self": "https://jira-dev/rest/api/2/issue/1234", + }, + ) + create_issue_callback( + ack=mock_ack, + inputs=mock_inputs, + fail=mock_fail, + complete=mock_complete, + context=mock_context, + client=mock_client, + logger=logging.getLogger(), + ) + + mock_ack.assert_called_once() + mock_fail.assert_called_once() + mock_requests.assert_not_called() + mock_complete.assert_not_called() From 5b386d0819acb198e1014ad6d95ea429bd2bdf41 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 29 May 2024 11:29:35 -0400 Subject: [PATCH 24/45] improve project --- .github/dependabot.yml | 6 +++- .github/workflows/black.yml | 32 +++++++++---------- .github/workflows/flake8.yml | 30 ++++++++--------- .github/workflows/pytest.yml | 28 ++++++++++++++++ jira/client.py | 1 - .../installation_store/installation_store.py | 1 - requirements.txt | 10 +++--- tests/functions/test_create_issue.py | 2 +- tests/mock_installation_store.py | 2 +- 9 files changed, 71 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/pytest.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8ee200b..82ba215 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,4 +6,8 @@ updates: interval: "monthly" labels: - "pip" - - "dependencies" \ No newline at end of file + - "dependencies" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 5f8d919..702633a 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -2,7 +2,7 @@ name: Formatting validation using black on: push: - branches: [ main ] + branches: [main] pull_request: jobs: @@ -11,19 +11,19 @@ jobs: timeout-minutes: 5 strategy: matrix: - python-version: ['3.9'] - + python-version: ["3.12"] + steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install -U pip - pip install -r requirements.txt - - name: Format with black - run: | - black . - if git status --porcelain | grep .; then git --no-pager diff; exit 1; fi \ No newline at end of file + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -U pip + pip install -r requirements.txt + - name: Format with black + run: | + black . + if git status --porcelain | grep .; then git --no-pager diff; exit 1; fi diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index 7b0863c..8e85fa7 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -2,7 +2,7 @@ name: Linting validation using flake8 on: push: - branches: [ main ] + branches: [main] pull_request: jobs: @@ -11,18 +11,18 @@ jobs: timeout-minutes: 5 strategy: matrix: - python-version: ['3.9'] - + python-version: ["3.12"] + steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install -U pip - pip install -r requirements.txt - - name: Lint with flake8 - run: | - flake8 *.py + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -U pip + pip install -r requirements.txt + - name: Lint with flake8 + run: | + flake8 *.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..c2a314e --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,28 @@ +name: Linting validation using flake8 + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -U pip + pip install -r requirements.txt + - name: Test with pytest + run: | + pytest . diff --git a/jira/client.py b/jira/client.py index 5f8f21a..5098ef0 100644 --- a/jira/client.py +++ b/jira/client.py @@ -8,7 +8,6 @@ class JiraClient: - def __init__( self, token: Optional[str] = None, diff --git a/oauth/installation_store/installation_store.py b/oauth/installation_store/installation_store.py index e0e7fe9..edc7297 100644 --- a/oauth/installation_store/installation_store.py +++ b/oauth/installation_store/installation_store.py @@ -4,7 +4,6 @@ class InstallationStore: - def save(self, installation: JiraInstallation): """Saves an installation data""" raise NotImplementedError() diff --git a/requirements.txt b/requirements.txt index e121f7c..efae1be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ slack-cli-hooks slack-bolt==1.19.0rc1 -pytest -flake8==7.0.0 -black==23.12.1 -requests==2.31.0 -Flask==3.0.3 +pytest==8.2 +flake8==7.0 +black==24.4 +requests==2.32 +Flask==3.0 diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index 3d66c07..5575056 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -1,7 +1,7 @@ -from datetime import datetime import json import logging import os +from datetime import datetime from unittest.mock import MagicMock, patch import requests diff --git a/tests/mock_installation_store.py b/tests/mock_installation_store.py index c49cb57..1d51e42 100644 --- a/tests/mock_installation_store.py +++ b/tests/mock_installation_store.py @@ -1,5 +1,5 @@ -from logging import Logger import logging +from logging import Logger from typing import Dict, Optional from oauth.installation_store.installation_store import InstallationStore From 4e4afeac74e4ec91a756ec00502b0600883d8505 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 29 May 2024 11:51:23 -0400 Subject: [PATCH 25/45] improve code --- jira/client.py | 2 +- listeners/actions/__init__.py | 7 ++++--- listeners/actions/connect_account.py | 5 ----- listeners/events/app_home/builder.py | 8 ++++---- listeners/internals.py | 2 ++ 5 files changed, 11 insertions(+), 13 deletions(-) delete mode 100644 listeners/actions/connect_account.py create mode 100644 listeners/internals.py diff --git a/jira/client.py b/jira/client.py index 5098ef0..4c012cc 100644 --- a/jira/client.py +++ b/jira/client.py @@ -83,7 +83,7 @@ def build_issue_url( key: str, **kwargs, ) -> str: - return f"{urljoin(self.base_url, f'/browse/{key}')}" + return f"{urljoin(self.base_url, f'/browse/{key}')}?{urlencode(kwargs)}" def oauth2_token( self, diff --git a/listeners/actions/__init__.py b/listeners/actions/__init__.py index 2585f33..f71f953 100644 --- a/listeners/actions/__init__.py +++ b/listeners/actions/__init__.py @@ -1,9 +1,10 @@ from slack_bolt import App -from .connect_account import connect_account_callback +from listeners.internals import CONNECT_ACCOUNT_ACTION, DISCONNECT_ACCOUNT_ACTION + from .disconnect_account import disconnect_account_callback def register(app: App): - app.action("connect_account")(connect_account_callback) - app.action("disconnect_account")(disconnect_account_callback) + app.action(CONNECT_ACCOUNT_ACTION)(lambda ack: ack()) + app.action(DISCONNECT_ACCOUNT_ACTION)(disconnect_account_callback) diff --git a/listeners/actions/connect_account.py b/listeners/actions/connect_account.py deleted file mode 100644 index fc24bad..0000000 --- a/listeners/actions/connect_account.py +++ /dev/null @@ -1,5 +0,0 @@ -from slack_bolt import Ack - - -def connect_account_callback(ack: Ack): - ack() diff --git a/listeners/events/app_home/builder.py b/listeners/events/app_home/builder.py index bca2737..6239767 100644 --- a/listeners/events/app_home/builder.py +++ b/listeners/events/app_home/builder.py @@ -1,6 +1,6 @@ from typing import TypedDict -context = "Welcome to jira" +from listeners.internals import CONNECT_ACCOUNT_ACTION, DISCONNECT_ACCOUNT_ACTION class AppHome(TypedDict): @@ -17,7 +17,7 @@ def __init__(self): "type": "section", "text": { "type": "mrkdwn", - "text": context, + "text": "Welcome to the Jira Server App", }, }, {"type": "divider"}, @@ -36,7 +36,7 @@ def add_connect_account_button(self, authorization_url): "text": "Connect an Account", }, "url": authorization_url, - "action_id": "connect_account", + "action_id": CONNECT_ACCOUNT_ACTION, } ], } @@ -53,7 +53,7 @@ def add_disconnect_account_button(self): "type": "plain_text", "text": "Disconnect Account", }, - "action_id": "disconnect_account", + "action_id": DISCONNECT_ACCOUNT_ACTION, } ], } diff --git a/listeners/internals.py b/listeners/internals.py new file mode 100644 index 0000000..9954b67 --- /dev/null +++ b/listeners/internals.py @@ -0,0 +1,2 @@ +CONNECT_ACCOUNT_ACTION = "connect_account" +DISCONNECT_ACCOUNT_ACTION = "disconnect_account" From a86faf29df7228419a212a1ca266e2aa6e71f5e5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 29 May 2024 11:53:50 -0400 Subject: [PATCH 26/45] fix test --- jira/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/jira/client.py b/jira/client.py index 4c012cc..a1d5fd4 100644 --- a/jira/client.py +++ b/jira/client.py @@ -81,9 +81,8 @@ def build_issue_url( self, *, key: str, - **kwargs, ) -> str: - return f"{urljoin(self.base_url, f'/browse/{key}')}?{urlencode(kwargs)}" + return f"{urljoin(self.base_url, f'/browse/{key}')}" def oauth2_token( self, From 79d74e1c664f0ce3192a8d9ef7786d7e7ea5f4a0 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 29 May 2024 11:54:31 -0400 Subject: [PATCH 27/45] Update pytest.yml --- .github/workflows/pytest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c2a314e..a9886cc 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,4 +1,4 @@ -name: Linting validation using flake8 +name: Testing with pytest on: push: From db39c3d0787fc1efa096a8ca1bc8c1af4573a391 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 29 May 2024 11:55:49 -0400 Subject: [PATCH 28/45] We don't need black validation --- .github/workflows/black.yml | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 .github/workflows/black.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index 702633a..0000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Formatting validation using black - -on: - push: - branches: [main] - pull_request: - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 5 - strategy: - matrix: - python-version: ["3.12"] - - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install -U pip - pip install -r requirements.txt - - name: Format with black - run: | - black . - if git status --porcelain | grep .; then git --no-pager diff; exit 1; fi From e2481a530d72c0ce8f17c9a7d40949e2b9db4174 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 29 May 2024 13:07:09 -0400 Subject: [PATCH 29/45] Improve the readme --- .github/workflows/pytest.yml | 2 +- README.md | 95 +++++++++++++++++++++++++++--------- 2 files changed, 73 insertions(+), 24 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a9886cc..1b246f7 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -25,4 +25,4 @@ jobs: pip install -r requirements.txt - name: Test with pytest run: | - pytest . + pytest tests/ diff --git a/README.md b/README.md index 712e59e..c3b1d61 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,27 @@ -# Bolt for Python Automation Template App +# Bolt for Python Jira Functions -This is a generic Bolt for Python template app used to build out Slack automations. +This is a Bolt for Python app used to interact with Jira Server. -Before getting started, make sure you have a development workspace where you have permissions to install apps. If you don’t have one setup, go ahead and [create one](https://slack.com/create). +Before getting started, make sure you have a development workspace where you +have permissions to install apps. If you don’t have one setup, go ahead and +[create one](https://slack.com/create). ## Installation -#### Create a Slack App +### Install the Slack CLI -1. Open [https://api.slack.com/apps/new](https://api.slack.com/apps/new) and choose "From an app manifest" -2. Choose the workspace you want to install the application to -3. Copy the contents of [manifest.json](./manifest.json) into the text box that says `*Paste your manifest code here*` (within the JSON tab) and click *Next* -4. Review the configuration and click *Create* -5. Click *Install to Workspace* and *Allow* on the screen that follows. You'll then be redirected to the App Configuration dashboard. +To use this template, you need to install and configure the Slack CLI. +Step-by-step instructions can be found in our +[Quickstart Guide](https://api.slack.com/automation/quickstart). -#### Environment Variables - -Before you can run the app, you'll need to store some environment variables. - -1. Rename `.env.sample` to `.env` -2. Open your apps configuration page from [this list](https://api.slack.com/apps), click *OAuth & Permissions* in the left hand menu, then copy the *Bot User OAuth Token* into your `.env` file under `SLACK_BOT_TOKEN` -3. Click *Basic Information* from the left hand menu and follow the steps in the *App-Level Tokens* section to create an app-level token with the `connections:write` scope. Copy that token into your `.env` as `SLACK_APP_TOKEN`. - -### Setup Your Local Project +### Create a Slack App ```zsh # Clone this project onto your machine -git clone https://github.com/slack-samples/bolt-python-automation-template.git +slack create my-app -t https://github.com/slack-samples/bolt-python-jira-functions.git -# Change into this project directory -cd bolt-python-automation-template +# Change into the project directory +cd my-app # Set up virtual environment python3 -m venv .venv @@ -37,11 +29,33 @@ source .venv/bin/activate # Install dependencies pip install -r requirements.txt +``` + +### Environment Variables +Before you can run the app, you'll need to store some environment variables. + +1. Rename `.env.sample` to `.env` +2. Follow these + [Jira Instruction](https://confluence.atlassian.com/adminjiraserver0909/configure-an-incoming-link-1251415519.html) + to get the `Client ID` (`JIRA_CLIENT_ID`) and `Client secret` + (`JIRA_CLIENT_SECRET`) values. +3. Populate the other environment variable value with proper values. + +### Running Your Project Locally + +You'll know an app is the development version if the name has the string +`(local)` appended. + +```zsh # Run Bolt server slack run + +INFO:slack_bolt.App:Starting to receive messages from a new connection ``` +To stop running locally, press ` + C` to end the process. + #### Linting ```zsh @@ -52,12 +66,47 @@ flake8 *.py black . ``` +## Testing + +For an example of how to test a function, see +`tests/functions/test_create_issue.py`. + +Run all tests with: + +```zsh +pytest tests/ +``` + ## Project Structure +### `.slack/` + +Contains `apps.dev.json` and `apps.json`, which include installation details for +development and deployed apps. + ### `manifest.json` -`manifest.json` is a configuration for Slack apps. With a manifest, you can create an app with a pre-defined configuration, or adjust the configuration of an existing app. +`manifest.json` is a configuration for Slack apps. With a manifest, you can +create an app with a pre-defined configuration, or adjust the configuration of +an existing app. ### `app.py` -`app.py` is the entry point for the application and is the file you'll run to start the server. This project aims to keep this file as thin as possible, primarily using it as a way to route inbound requests. +`app.py` is the entry point for the application and is the file you'll run to +start the server. This project aims to keep this file as thin as possible, +primarily using it as a way to route inbound requests. + +### `/listeners` + +Every incoming request is routed to a "listener". Inside this directory, we +group each listener based on the Slack Platform feature used, so +`/listeners/shortcuts` handles incoming +[Shortcuts](https://api.slack.com/interactivity/shortcuts) requests, +`/listeners/views` handles +[View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) +and so on. + +### `slack.json` + +Used by the CLI to interact with the project's SDK dependencies. It contains +script hooks that are executed by the CLI and implemented by the SDK. From 8ab145defbebc703c9e81c477e9192cf6913b604 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Wed, 29 May 2024 15:02:53 -0400 Subject: [PATCH 30/45] use ruff --- .flake8 | 2 -- .github/workflows/{flake8.yml => lint.yml} | 8 ++++---- .github/workflows/pytest.yml | 2 +- README.md | 9 +++++---- pyproject.toml | 17 ++++++++++++++--- requirements.txt | 5 ++--- 6 files changed, 26 insertions(+), 17 deletions(-) delete mode 100644 .flake8 rename .github/workflows/{flake8.yml => lint.yml} (83%) diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 113ca5f..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 125 diff --git a/.github/workflows/flake8.yml b/.github/workflows/lint.yml similarity index 83% rename from .github/workflows/flake8.yml rename to .github/workflows/lint.yml index 8e85fa7..0bfd344 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: Linting validation using flake8 +name: Linting validation using ruff on: push: @@ -6,7 +6,7 @@ on: pull_request: jobs: - build: + lint: runs-on: ubuntu-latest timeout-minutes: 5 strategy: @@ -23,6 +23,6 @@ jobs: run: | pip install -U pip pip install -r requirements.txt - - name: Lint with flake8 + - name: Lint with ruff run: | - flake8 *.py + ruff check diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 1b246f7..17eda2e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - build: + test: runs-on: ubuntu-latest timeout-minutes: 5 strategy: diff --git a/README.md b/README.md index c3b1d61..600fdba 100644 --- a/README.md +++ b/README.md @@ -59,11 +59,12 @@ To stop running locally, press ` + C` to end the process. #### Linting ```zsh -# Run flake8 from root directory for linting -flake8 *.py +# Run ruff from root directory for linting +ruff check -# Run black from root directory for code formatting -black . +# Run ruff from root directory for code formatting +ruff format +ruff check --fix ``` ## Testing diff --git a/pyproject.toml b/pyproject.toml index 7bf3f19..1c0a283 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,19 @@ -# black project prefers pyproject.toml -# that's why we have this file in addition to other setting files -[tool.black] +[tool.ruff] line-length = 125 +[tool.ruff.lint] +ignore = [] +select = [ + # pycodestyle error + "E", + # pycodestyle warning + "W", + # Pyflakes + "F", + # isort + "I", +] + [tool.pytest.ini_options] testpaths = ["tests"] log_file = "logs/pytest.log" diff --git a/requirements.txt b/requirements.txt index efae1be..fe6311d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ slack-cli-hooks slack-bolt==1.19.0rc1 -pytest==8.2 -flake8==7.0 -black==24.4 requests==2.32 Flask==3.0 +pytest==8.2 +ruff==0.4 From 7adde4cefd31e5831b22d0b2dbe0784323d24319 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 10:51:08 -0400 Subject: [PATCH 31/45] Update jira/client.py Co-authored-by: Kazuhiro Sera --- jira/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jira/client.py b/jira/client.py index a1d5fd4..300dc12 100644 --- a/jira/client.py +++ b/jira/client.py @@ -92,7 +92,7 @@ def oauth2_token( client_secret: str, redirect_uri: str, code_verifier: str, - grant_type="authorization_code", + grant_type: str = "authorization_code", **kwargs, ) -> Response: headers = {"Content-Type": "application/x-www-form-urlencoded"} From 71f7173ccac74a8693b0f304b0468f57d817acd9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 11:19:12 -0400 Subject: [PATCH 32/45] rename with jira prefixes --- .sample.env | 2 +- app.py | 8 ++++---- {oauth/state_store => jira/oauth}/__init__.py | 0 jira/oauth/installation_store/__init__.py | 0 .../oauth}/installation_store/file.py | 6 +++--- .../installation_store/installation_store.py | 2 +- .../oauth}/installation_store/models.py | 0 jira/oauth/state_store/__init__.py | 0 jira/oauth/state_store/memory.py | 18 ++++++++++++++++++ {oauth => jira/oauth}/state_store/models.py | 2 +- listeners/actions/disconnect_account.py | 4 ++-- listeners/events/app_home/app_home_open.py | 12 ++++++------ listeners/functions/create_issue.py | 4 ++-- oauth/__init__.py | 5 ----- oauth/installation_store/__init__.py | 9 --------- oauth/state_store/memory.py | 18 ------------------ tests/functions/test_create_issue.py | 6 +++--- ...tore.py => mock_jira_installation_store.py} | 6 +++--- 18 files changed, 44 insertions(+), 58 deletions(-) rename {oauth/state_store => jira/oauth}/__init__.py (100%) create mode 100644 jira/oauth/installation_store/__init__.py rename {oauth => jira/oauth}/installation_store/file.py (92%) rename {oauth => jira/oauth}/installation_store/installation_store.py (96%) rename {oauth => jira/oauth}/installation_store/models.py (100%) create mode 100644 jira/oauth/state_store/__init__.py create mode 100644 jira/oauth/state_store/memory.py rename {oauth => jira/oauth}/state_store/models.py (83%) delete mode 100644 oauth/__init__.py delete mode 100644 oauth/installation_store/__init__.py delete mode 100644 oauth/state_store/memory.py rename tests/{mock_installation_store.py => mock_jira_installation_store.py} (90%) diff --git a/.sample.env b/.sample.env index 28f9b26..c2bb915 100644 --- a/.sample.env +++ b/.sample.env @@ -4,4 +4,4 @@ SECRET_HEADER_VALUE=a123 JIRA_CLIENT_ID=abc134 JIRA_CLIENT_SECRET=abc134 APP_BASE_URL=https://this-si-my-app -APP_HOME_PAGE_URL=https://slack.workspace/archives/bot-id +APP_HOME_PAGE_URL=slack://app?team={TEAM_ID}&id={APP_ID}&tab=home diff --git a/app.py b/app.py index 9077d27..c539e55 100644 --- a/app.py +++ b/app.py @@ -15,9 +15,9 @@ OAUTH_REDIRECT_PATH, ) from jira.client import JiraClient +from jira.oauth.installation_store.file import JiraFileInstallationStore +from jira.oauth.state_store.memory import JiraMemoryOAuthStateStore from listeners import register_listeners -from oauth.installation_store.file import FileInstallationStore -from oauth.state_store.memory import MemoryOAuthStateStore logging.basicConfig(level=logging.INFO) @@ -44,9 +44,9 @@ def oauth_redirect(): jira_resp.raise_for_status() jira_resp_json = jira_resp.json() - user_identity = MemoryOAuthStateStore.consume(state) + user_identity = JiraMemoryOAuthStateStore.consume(state) - FileInstallationStore().save( + JiraFileInstallationStore().save( { "access_token": jira_resp_json["access_token"], "enterprise_id": user_identity["enterprise_id"], diff --git a/oauth/state_store/__init__.py b/jira/oauth/__init__.py similarity index 100% rename from oauth/state_store/__init__.py rename to jira/oauth/__init__.py diff --git a/jira/oauth/installation_store/__init__.py b/jira/oauth/installation_store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/oauth/installation_store/file.py b/jira/oauth/installation_store/file.py similarity index 92% rename from oauth/installation_store/file.py rename to jira/oauth/installation_store/file.py index 40213cb..7b8b48b 100644 --- a/oauth/installation_store/file.py +++ b/jira/oauth/installation_store/file.py @@ -6,11 +6,11 @@ from pathlib import Path from typing import Optional, Union -from oauth.installation_store.installation_store import InstallationStore -from oauth.installation_store.models import JiraInstallation +from jira.oauth.installation_store.installation_store import JiraInstallationStore +from jira.oauth.installation_store.models import JiraInstallation -class FileInstallationStore(InstallationStore): +class JiraFileInstallationStore(JiraInstallationStore): def __init__( self, *, diff --git a/oauth/installation_store/installation_store.py b/jira/oauth/installation_store/installation_store.py similarity index 96% rename from oauth/installation_store/installation_store.py rename to jira/oauth/installation_store/installation_store.py index edc7297..4bc89dc 100644 --- a/oauth/installation_store/installation_store.py +++ b/jira/oauth/installation_store/installation_store.py @@ -3,7 +3,7 @@ from .models import JiraInstallation -class InstallationStore: +class JiraInstallationStore: def save(self, installation: JiraInstallation): """Saves an installation data""" raise NotImplementedError() diff --git a/oauth/installation_store/models.py b/jira/oauth/installation_store/models.py similarity index 100% rename from oauth/installation_store/models.py rename to jira/oauth/installation_store/models.py diff --git a/jira/oauth/state_store/__init__.py b/jira/oauth/state_store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jira/oauth/state_store/memory.py b/jira/oauth/state_store/memory.py new file mode 100644 index 0000000..c834a0b --- /dev/null +++ b/jira/oauth/state_store/memory.py @@ -0,0 +1,18 @@ +import uuid +from typing import Dict + +from .models import JiraUserIdentity + +OAUTH_STATE_TABLE: Dict[str, JiraUserIdentity] = {} + + +class JiraMemoryOAuthStateStore: + @staticmethod + def issue(user_identity: JiraUserIdentity) -> str: + state = uuid.uuid4().hex + OAUTH_STATE_TABLE[state] = user_identity + return state + + @staticmethod + def consume(state: str) -> JiraUserIdentity: + return OAUTH_STATE_TABLE.pop(state) diff --git a/oauth/state_store/models.py b/jira/oauth/state_store/models.py similarity index 83% rename from oauth/state_store/models.py rename to jira/oauth/state_store/models.py index 9ebe8e9..3ae1177 100644 --- a/oauth/state_store/models.py +++ b/jira/oauth/state_store/models.py @@ -1,7 +1,7 @@ from typing import Optional, TypedDict -class UserIdentity(TypedDict): +class JiraUserIdentity(TypedDict): """Class for keeping track of individual slack users""" user_id: str diff --git a/listeners/actions/disconnect_account.py b/listeners/actions/disconnect_account.py index 9eaff4f..33d6635 100644 --- a/listeners/actions/disconnect_account.py +++ b/listeners/actions/disconnect_account.py @@ -1,10 +1,10 @@ from slack_bolt import Ack, BoltContext -from oauth.installation_store.file import FileInstallationStore +from jira.oauth.installation_store.file import JiraFileInstallationStore def disconnect_account_callback(ack: Ack, context: BoltContext): ack() - FileInstallationStore().delete_installation( + JiraFileInstallationStore().delete_installation( enterprise_id=context.enterprise_id, team_id=context.team_id, user_id=context.user_id ) diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index 87eb18d..d6121ab 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -5,9 +5,9 @@ from internals import JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI from jira.client import JiraClient -from oauth.installation_store.file import FileInstallationStore -from oauth.state_store.memory import MemoryOAuthStateStore -from oauth.state_store.models import UserIdentity +from jira.oauth.installation_store.file import JiraFileInstallationStore +from jira.oauth.state_store.memory import JiraMemoryOAuthStateStore +from jira.oauth.state_store.models import JiraUserIdentity from .builder import AppHomeBuilder @@ -18,13 +18,13 @@ def app_home_open_callback(client: WebClient, event: dict, logger: Logger, conte return try: home = AppHomeBuilder() - installation = FileInstallationStore().find_installation( + installation = JiraFileInstallationStore().find_installation( user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) if installation is None: - state = MemoryOAuthStateStore.issue( - user_identity=UserIdentity( + state = JiraMemoryOAuthStateStore.issue( + user_identity=JiraUserIdentity( user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) ) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 61cb066..853db9f 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -5,7 +5,7 @@ from slack_sdk import WebClient from jira.client import JiraClient -from oauth.installation_store.file import FileInstallationStore +from jira.oauth.installation_store.file import JiraFileInstallationStore # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ @@ -16,7 +16,7 @@ def create_issue_callback( ack() user_id = inputs["user_context"]["id"] - installation = FileInstallationStore().find_installation( + installation = JiraFileInstallationStore().find_installation( user_id=user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) if installation is None: diff --git a/oauth/__init__.py b/oauth/__init__.py deleted file mode 100644 index 2891921..0000000 --- a/oauth/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .installation_store import InstallationStore - -__all__ = [ - "InstallationStore", -] diff --git a/oauth/installation_store/__init__.py b/oauth/installation_store/__init__.py deleted file mode 100644 index c8326d1..0000000 --- a/oauth/installation_store/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .file import FileInstallationStore -from .installation_store import InstallationStore -from .models import JiraInstallation - -__all__ = [ - "FileInstallationStore", - "InstallationStore", - "JiraInstallation", -] diff --git a/oauth/state_store/memory.py b/oauth/state_store/memory.py deleted file mode 100644 index 78f4f79..0000000 --- a/oauth/state_store/memory.py +++ /dev/null @@ -1,18 +0,0 @@ -import uuid -from typing import Dict - -from .models import UserIdentity - -OAUTH_STATE_TABLE: Dict[str, UserIdentity] = {} - - -class MemoryOAuthStateStore: - @staticmethod - def issue(user_identity: UserIdentity) -> str: - state = uuid.uuid4().hex - OAUTH_STATE_TABLE[state] = user_identity - return state - - @staticmethod - def consume(state: str) -> UserIdentity: - return OAUTH_STATE_TABLE.pop(state) diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index 5575056..827b624 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -7,7 +7,7 @@ import requests from listeners.functions.create_issue import create_issue_callback -from tests.mock_installation_store import MockInstallationStore +from tests.mock_jira_installation_store import MockJiraInstallationStore from tests.utils import remove_os_env_temporarily, restore_os_env @@ -25,7 +25,7 @@ class TestCreateIssue: enterprise_id = "E1234" def build_mock_installation_store(self): - installation_store = MockInstallationStore() + installation_store = MockJiraInstallationStore() installation_store.save( { "scope": "WRITE", @@ -45,7 +45,7 @@ def setup_method(self): self.old_os_env = remove_os_env_temporarily() os.environ["JIRA_BASE_URL"] = "https://jira-dev/" self.mock_installation_store = patch( - "listeners.functions.create_issue.FileInstallationStore", self.build_mock_installation_store + "listeners.functions.create_issue.JiraFileInstallationStore", self.build_mock_installation_store ) self.mock_installation_store.start() diff --git a/tests/mock_installation_store.py b/tests/mock_jira_installation_store.py similarity index 90% rename from tests/mock_installation_store.py rename to tests/mock_jira_installation_store.py index 1d51e42..bd5198b 100644 --- a/tests/mock_installation_store.py +++ b/tests/mock_jira_installation_store.py @@ -2,11 +2,11 @@ from logging import Logger from typing import Dict, Optional -from oauth.installation_store.installation_store import InstallationStore -from oauth.installation_store.models import JiraInstallation +from jira.oauth.installation_store.installation_store import JiraInstallationStore +from jira.oauth.installation_store.models import JiraInstallation -class MockInstallationStore(InstallationStore): +class MockJiraInstallationStore(JiraInstallationStore): def __init__( self, *, From f209af8c8643471fddee7411aa4adb79ec35a852 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 11:59:28 -0400 Subject: [PATCH 33/45] Use a file state store instead of a memory one --- app.py | 9 ++-- jira/oauth/installation_store/file.py | 2 +- jira/oauth/state_store/file.py | 50 ++++++++++++++++++++++ jira/oauth/state_store/memory.py | 18 -------- jira/oauth/state_store/state_store.py | 11 +++++ listeners/events/app_home/app_home_open.py | 4 +- 6 files changed, 70 insertions(+), 24 deletions(-) create mode 100644 jira/oauth/state_store/file.py delete mode 100644 jira/oauth/state_store/memory.py create mode 100644 jira/oauth/state_store/state_store.py diff --git a/app.py b/app.py index c539e55..a99232e 100644 --- a/app.py +++ b/app.py @@ -2,7 +2,7 @@ import os from datetime import datetime -from flask import Flask, redirect, request +from flask import Flask, make_response, redirect, request from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler @@ -16,7 +16,7 @@ ) from jira.client import JiraClient from jira.oauth.installation_store.file import JiraFileInstallationStore -from jira.oauth.state_store.memory import JiraMemoryOAuthStateStore +from jira.oauth.state_store.file import JiraFileOAuthStateStore from listeners import register_listeners logging.basicConfig(level=logging.INFO) @@ -44,7 +44,10 @@ def oauth_redirect(): jira_resp.raise_for_status() jira_resp_json = jira_resp.json() - user_identity = JiraMemoryOAuthStateStore.consume(state) + user_identity = JiraFileOAuthStateStore().consume(state) + + if user_identity is None: + return make_response("State Not Found", 404) JiraFileInstallationStore().save( { diff --git a/jira/oauth/installation_store/file.py b/jira/oauth/installation_store/file.py index 7b8b48b..3036b63 100644 --- a/jira/oauth/installation_store/file.py +++ b/jira/oauth/installation_store/file.py @@ -14,7 +14,7 @@ class JiraFileInstallationStore(JiraInstallationStore): def __init__( self, *, - base_dir: str = "./data/installations", + base_dir: str = "./data/jira-installations", logger: Logger = logging.getLogger(__name__), ): self.base_dir = base_dir diff --git a/jira/oauth/state_store/file.py b/jira/oauth/state_store/file.py new file mode 100644 index 0000000..cbe5f1c --- /dev/null +++ b/jira/oauth/state_store/file.py @@ -0,0 +1,50 @@ +import json +import logging +import os +import uuid +from pathlib import Path +from typing import Optional, Union + +from jira.oauth.state_store.state_store import JiraOAuthStateStore + +from .models import JiraUserIdentity + + +class JiraFileOAuthStateStore(JiraOAuthStateStore): + def __init__( + self, + *, + base_dir: str = "./data/jira-oauth-state", + logger: logging.Logger = logging.getLogger(__name__), + ): + self.base_dir = base_dir + self.logger = logger + + def issue(self, user_identity: JiraUserIdentity) -> str: + state = uuid.uuid4().hex + self._mkdir(self.base_dir) + filepath = f"{self.base_dir}/{state}" + with open(filepath, "w") as f: + content = json.dumps(user_identity) + f.write(content) + return state + + def consume(self, state: str) -> Optional[JiraUserIdentity]: + filepath = f"{self.base_dir}/{state}" + try: + with open(filepath) as f: + user_identity: JiraUserIdentity = json.load(f) + + os.remove(filepath) # consume the file by deleting it + return user_identity + + except FileNotFoundError as e: + message = f"Failed to find any persistent data for state: {state} - {e}" + self.logger.warning(message) + return None + + @staticmethod + def _mkdir(path: Union[str, Path]): + if isinstance(path, str): + path = Path(path) + path.mkdir(parents=True, exist_ok=True) diff --git a/jira/oauth/state_store/memory.py b/jira/oauth/state_store/memory.py deleted file mode 100644 index c834a0b..0000000 --- a/jira/oauth/state_store/memory.py +++ /dev/null @@ -1,18 +0,0 @@ -import uuid -from typing import Dict - -from .models import JiraUserIdentity - -OAUTH_STATE_TABLE: Dict[str, JiraUserIdentity] = {} - - -class JiraMemoryOAuthStateStore: - @staticmethod - def issue(user_identity: JiraUserIdentity) -> str: - state = uuid.uuid4().hex - OAUTH_STATE_TABLE[state] = user_identity - return state - - @staticmethod - def consume(state: str) -> JiraUserIdentity: - return OAUTH_STATE_TABLE.pop(state) diff --git a/jira/oauth/state_store/state_store.py b/jira/oauth/state_store/state_store.py new file mode 100644 index 0000000..aeb70b5 --- /dev/null +++ b/jira/oauth/state_store/state_store.py @@ -0,0 +1,11 @@ +from .models import JiraUserIdentity + + +class JiraOAuthStateStore: + def issue(user_identity: JiraUserIdentity) -> str: + """Issues relevant state given an Identity""" + raise NotImplementedError() + + def consume(state: str) -> JiraUserIdentity: + """Consums a given state returns corresponding Identity""" + raise NotImplementedError() diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index d6121ab..9579a3d 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -6,7 +6,7 @@ from internals import JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI from jira.client import JiraClient from jira.oauth.installation_store.file import JiraFileInstallationStore -from jira.oauth.state_store.memory import JiraMemoryOAuthStateStore +from jira.oauth.state_store.file import JiraFileOAuthStateStore from jira.oauth.state_store.models import JiraUserIdentity from .builder import AppHomeBuilder @@ -23,7 +23,7 @@ def app_home_open_callback(client: WebClient, event: dict, logger: Logger, conte ) if installation is None: - state = JiraMemoryOAuthStateStore.issue( + state = JiraFileOAuthStateStore().issue( user_identity=JiraUserIdentity( user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) From 58e684503ffeee540c7317543dcd542def4b93bb Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 12:14:47 -0400 Subject: [PATCH 34/45] Add comments --- jira/oauth/installation_store/models.py | 1 + jira/oauth/state_store/models.py | 1 + 2 files changed, 2 insertions(+) diff --git a/jira/oauth/installation_store/models.py b/jira/oauth/installation_store/models.py index 312ed64..ce217ed 100644 --- a/jira/oauth/installation_store/models.py +++ b/jira/oauth/installation_store/models.py @@ -8,6 +8,7 @@ class JiraInstallation(TypedDict): expires_in: int refresh_token: str user_id: str + # Either team_id or enterprise_id must exist here team_id: Optional[str] enterprise_id: Optional[str] installed_at: float diff --git a/jira/oauth/state_store/models.py b/jira/oauth/state_store/models.py index 3ae1177..9250ed8 100644 --- a/jira/oauth/state_store/models.py +++ b/jira/oauth/state_store/models.py @@ -5,5 +5,6 @@ class JiraUserIdentity(TypedDict): """Class for keeping track of individual slack users""" user_id: str + # Either team_id or enterprise_id must exist here team_id: Optional[str] enterprise_id: Optional[str] From 027d36bb45a44bc7dfdf104c37bfcd2d3abf8aa6 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 12:21:37 -0400 Subject: [PATCH 35/45] create a utils folder --- app.py | 10 ++-------- listeners/events/app_home/app_home_open.py | 3 ++- utils/constants.py | 4 ++++ internals.py => utils/env_variables.py | 4 +--- 4 files changed, 9 insertions(+), 12 deletions(-) create mode 100644 utils/constants.py rename internals.py => utils/env_variables.py (71%) diff --git a/app.py b/app.py index a99232e..a41ad7d 100644 --- a/app.py +++ b/app.py @@ -6,18 +6,12 @@ from slack_bolt import App from slack_bolt.adapter.socket_mode import SocketModeHandler -from internals import ( - APP_HOME_PAGE_URL, - JIRA_CLIENT_ID, - JIRA_CLIENT_SECRET, - JIRA_CODE_VERIFIER, - JIRA_REDIRECT_URI, - OAUTH_REDIRECT_PATH, -) from jira.client import JiraClient from jira.oauth.installation_store.file import JiraFileInstallationStore from jira.oauth.state_store.file import JiraFileOAuthStateStore from listeners import register_listeners +from utils.constants import JIRA_CODE_VERIFIER, OAUTH_REDIRECT_PATH +from utils.env_variables import APP_HOME_PAGE_URL, JIRA_CLIENT_ID, JIRA_CLIENT_SECRET, JIRA_REDIRECT_URI logging.basicConfig(level=logging.INFO) diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index 9579a3d..8598b70 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -3,11 +3,12 @@ from slack_bolt import BoltContext from slack_sdk import WebClient -from internals import JIRA_CLIENT_ID, JIRA_CODE_VERIFIER, JIRA_REDIRECT_URI from jira.client import JiraClient from jira.oauth.installation_store.file import JiraFileInstallationStore from jira.oauth.state_store.file import JiraFileOAuthStateStore from jira.oauth.state_store.models import JiraUserIdentity +from utils.constants import JIRA_CODE_VERIFIER +from utils.env_variables import JIRA_CLIENT_ID, JIRA_REDIRECT_URI from .builder import AppHomeBuilder diff --git a/utils/constants.py b/utils/constants.py new file mode 100644 index 0000000..3dd6b6d --- /dev/null +++ b/utils/constants.py @@ -0,0 +1,4 @@ +import secrets + +OAUTH_REDIRECT_PATH = "/oauth/redirect" +JIRA_CODE_VERIFIER = secrets.token_urlsafe(96)[:128] diff --git a/internals.py b/utils/env_variables.py similarity index 71% rename from internals.py rename to utils/env_variables.py index 658dd07..fea8875 100644 --- a/internals.py +++ b/utils/env_variables.py @@ -1,9 +1,7 @@ import os -import secrets from urllib.parse import urljoin -OAUTH_REDIRECT_PATH = "/oauth/redirect" -JIRA_CODE_VERIFIER = secrets.token_urlsafe(96)[:128] +from utils.constants import OAUTH_REDIRECT_PATH JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") From 46710f650bf6c35d30b06936ab087d604a6381e9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 14:16:33 -0400 Subject: [PATCH 36/45] use a global context --- app.py | 21 ++++++------- jira/client.py | 4 ++- listeners/actions/disconnect_account.py | 4 +-- listeners/events/app_home/app_home_open.py | 15 ++++----- listeners/functions/create_issue.py | 23 +++++++++++--- manifest.json | 2 +- tests/functions/test_create_issue.py | 32 +++++++++---------- tests/mock_jira_oauth_state_store.py | 28 +++++++++++++++++ tests/utils.py | 16 ++++++++++ utils/constants.py | 5 ++- utils/context.py | 36 ++++++++++++++++++++++ utils/env_variables.py | 9 ------ 12 files changed, 136 insertions(+), 59 deletions(-) create mode 100644 tests/mock_jira_oauth_state_store.py create mode 100644 utils/context.py delete mode 100644 utils/env_variables.py diff --git a/app.py b/app.py index a41ad7d..549ab50 100644 --- a/app.py +++ b/app.py @@ -7,11 +7,8 @@ from slack_bolt.adapter.socket_mode import SocketModeHandler from jira.client import JiraClient -from jira.oauth.installation_store.file import JiraFileInstallationStore -from jira.oauth.state_store.file import JiraFileOAuthStateStore from listeners import register_listeners -from utils.constants import JIRA_CODE_VERIFIER, OAUTH_REDIRECT_PATH -from utils.env_variables import APP_HOME_PAGE_URL, JIRA_CLIENT_ID, JIRA_CLIENT_SECRET, JIRA_REDIRECT_URI +from utils.constants import CONTEXT logging.basicConfig(level=logging.INFO) @@ -22,7 +19,7 @@ register_listeners(app) -@flask_app.route(OAUTH_REDIRECT_PATH, methods=["GET"]) +@flask_app.route(CONTEXT.jira_oauth_redirect_path, methods=["GET"]) def oauth_redirect(): code = request.args["code"] state = request.args["state"] @@ -30,20 +27,20 @@ def oauth_redirect(): jira_client = JiraClient() jira_resp = jira_client.oauth2_token( code=code, - client_id=JIRA_CLIENT_ID, - client_secret=JIRA_CLIENT_SECRET, - code_verifier=JIRA_CODE_VERIFIER, - redirect_uri=JIRA_REDIRECT_URI, + client_id=CONTEXT.jira_client_id, + client_secret=CONTEXT.jira_client_secret, + code_verifier=CONTEXT.jira_code_verifier, + redirect_uri=CONTEXT.jira_redirect_uri, ) jira_resp.raise_for_status() jira_resp_json = jira_resp.json() - user_identity = JiraFileOAuthStateStore().consume(state) + user_identity = CONTEXT.jira_state_store.consume(state) if user_identity is None: return make_response("State Not Found", 404) - JiraFileInstallationStore().save( + CONTEXT.jira_installation_store.save( { "access_token": jira_resp_json["access_token"], "enterprise_id": user_identity["enterprise_id"], @@ -56,7 +53,7 @@ def oauth_redirect(): "user_id": user_identity["user_id"], } ) - return redirect(APP_HOME_PAGE_URL, code=302) + return redirect(CONTEXT.app_home_page_url, code=302) if __name__ == "__main__": diff --git a/jira/client.py b/jira/client.py index 300dc12..e588a1a 100644 --- a/jira/client.py +++ b/jira/client.py @@ -6,6 +6,8 @@ import requests from requests import Response +from utils.constants import CONTEXT + class JiraClient: def __init__( @@ -17,7 +19,7 @@ def __init__( proxies: Optional[Dict[str, str]] = None, ): self.token = token - self.base_url = base_url or os.environ.get("JIRA_BASE_URL") + self.base_url = base_url or CONTEXT.jira_base_url self.token_type = token_type self.headers = headers or {} self.headers[os.getenv("SECRET_HEADER_KEY")] = os.getenv("SECRET_HEADER_VALUE") diff --git a/listeners/actions/disconnect_account.py b/listeners/actions/disconnect_account.py index 33d6635..3f17291 100644 --- a/listeners/actions/disconnect_account.py +++ b/listeners/actions/disconnect_account.py @@ -1,10 +1,10 @@ from slack_bolt import Ack, BoltContext -from jira.oauth.installation_store.file import JiraFileInstallationStore +from utils.constants import CONTEXT def disconnect_account_callback(ack: Ack, context: BoltContext): ack() - JiraFileInstallationStore().delete_installation( + CONTEXT.jira_installation_store.delete_installation( enterprise_id=context.enterprise_id, team_id=context.team_id, user_id=context.user_id ) diff --git a/listeners/events/app_home/app_home_open.py b/listeners/events/app_home/app_home_open.py index 8598b70..977a6f9 100644 --- a/listeners/events/app_home/app_home_open.py +++ b/listeners/events/app_home/app_home_open.py @@ -4,11 +4,8 @@ from slack_sdk import WebClient from jira.client import JiraClient -from jira.oauth.installation_store.file import JiraFileInstallationStore -from jira.oauth.state_store.file import JiraFileOAuthStateStore from jira.oauth.state_store.models import JiraUserIdentity -from utils.constants import JIRA_CODE_VERIFIER -from utils.env_variables import JIRA_CLIENT_ID, JIRA_REDIRECT_URI +from utils.constants import CONTEXT from .builder import AppHomeBuilder @@ -19,22 +16,22 @@ def app_home_open_callback(client: WebClient, event: dict, logger: Logger, conte return try: home = AppHomeBuilder() - installation = JiraFileInstallationStore().find_installation( + installation = CONTEXT.jira_installation_store.find_installation( user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) if installation is None: - state = JiraFileOAuthStateStore().issue( + state = CONTEXT.jira_state_store.issue( user_identity=JiraUserIdentity( user_id=context.user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) ) jira_client = JiraClient() authorization_url = jira_client.build_authorization_url( - client_id=JIRA_CLIENT_ID, - redirect_uri=JIRA_REDIRECT_URI, + client_id=CONTEXT.jira_client_id, + redirect_uri=CONTEXT.jira_redirect_uri, scope="WRITE", - code_challenge=JIRA_CODE_VERIFIER, + code_challenge=CONTEXT.jira_code_verifier, state=state, ) home.add_connect_account_button(authorization_url) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index 853db9f..c839fec 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -5,7 +5,7 @@ from slack_sdk import WebClient from jira.client import JiraClient -from jira.oauth.installation_store.file import JiraFileInstallationStore +from utils.constants import CONTEXT # https://developer.atlassian.com/server/jira/platform/jira-rest-api-examples/ @@ -16,15 +16,30 @@ def create_issue_callback( ack() user_id = inputs["user_context"]["id"] - installation = JiraFileInstallationStore().find_installation( + installation = CONTEXT.jira_installation_store.find_installation( user_id=user_id, team_id=context.team_id, enterprise_id=context.enterprise_id ) if installation is None: + mrkdwn: str = ( + ":no_entry: There is a problem with the `Create an issue` step, you did not connect a Jira Account, " + f"visit the <{CONTEXT.app_home_page_url}|App Home Page> to fix this!" + ) client.chat_postMessage( channel=user_id, - text="The function failed because the is no connected jira account, visit the app home to solve this", + text=mrkdwn, + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": mrkdwn, + }, + } + ], + ) + return fail( + f"User {user_id} has not connected their account properly, they must visit the App Home Page to fix this" ) - return fail(f"User {user_id} has not connected their account properly, visit the app home to solve this") try: project: str = inputs["project"] diff --git a/manifest.json b/manifest.json index dccd2a7..0042989 100644 --- a/manifest.json +++ b/manifest.json @@ -20,7 +20,7 @@ "display_name": "BoltPy Jira Functions" }, "app_home": { - "messages_tab_enabled": false, + "messages_tab_enabled": true, "home_tab_enabled": true } }, diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index 827b624..bc0058e 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -1,14 +1,13 @@ import json import logging -import os from datetime import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import requests +from slack_sdk import WebClient from listeners.functions.create_issue import create_issue_callback -from tests.mock_jira_installation_store import MockJiraInstallationStore -from tests.utils import remove_os_env_temporarily, restore_os_env +from tests.utils import build_mock_context, remove_os_env_temporarily, restore_os_env def mock_response(status=200, data: dict = None): @@ -24,9 +23,10 @@ class TestCreateIssue: team_id = "T1234" enterprise_id = "E1234" - def build_mock_installation_store(self): - installation_store = MockJiraInstallationStore() - installation_store.save( + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + self.mock_context = build_mock_context() + self.mock_context.jira_installation_store.save( { "scope": "WRITE", "access_token": "jira_access_token", @@ -39,18 +39,14 @@ def build_mock_installation_store(self): "installed_at": datetime.now().timestamp(), } ) - return installation_store - - def setup_method(self): - self.old_os_env = remove_os_env_temporarily() - os.environ["JIRA_BASE_URL"] = "https://jira-dev/" - self.mock_installation_store = patch( - "listeners.functions.create_issue.JiraFileInstallationStore", self.build_mock_installation_store - ) - self.mock_installation_store.start() + self.mock_create_issue_context = patch("listeners.functions.create_issue.CONTEXT", self.mock_context) + self.mock_jira_client_context = patch("jira.client.CONTEXT", self.mock_context) + self.mock_jira_client_context.start() + self.mock_create_issue_context.start() def teardown_method(self): - self.mock_installation_store.stop() + self.mock_create_issue_context.stop() + self.mock_jira_client_context.stop() restore_os_env(self.old_os_env) def test_create_issue(self): @@ -97,7 +93,7 @@ def test_create_issue_fail(self): mock_fail = MagicMock() mock_complete = MagicMock() mock_context = MagicMock(team_id=self.team_id, enterprise_id=self.enterprise_id) - mock_client = MagicMock(chat_postMessage=lambda channel, text: True) + mock_client = Mock(spec=WebClient) mock_inputs = { "user_context": {"id": "wrong_id"}, "project": "PROJ", diff --git a/tests/mock_jira_oauth_state_store.py b/tests/mock_jira_oauth_state_store.py new file mode 100644 index 0000000..061c3af --- /dev/null +++ b/tests/mock_jira_oauth_state_store.py @@ -0,0 +1,28 @@ +import logging +import uuid +from logging import Logger +from typing import Dict, Optional + +from jira.oauth.state_store.models import JiraUserIdentity +from jira.oauth.state_store.state_store import JiraOAuthStateStore + + +class MockJiraOAuthStateStore(JiraOAuthStateStore): + def __init__( + self, + *, + logger: Logger = logging.getLogger(__name__), + ): + self.state_table: Dict[str, JiraUserIdentity] = {} + self.logger = logger + + def issue(self, user_identity: JiraUserIdentity) -> str: + state = uuid.uuid4().hex + self.state_table[state] = user_identity + return state + + def consume(self, state: str) -> Optional[JiraUserIdentity]: + if state not in self.state_table: + message = f"Failed to find any persistent data for state: {state} - Key Error" + self.logger.warning(message) + return self.state_table.pop(state, None) diff --git a/tests/utils.py b/tests/utils.py index 185e41b..a84a44e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,9 @@ import os +from tests.mock_jira_installation_store import MockJiraInstallationStore +from tests.mock_jira_oauth_state_store import MockJiraOAuthStateStore +from utils.context import Context + def remove_os_env_temporarily() -> dict: old_env = os.environ.copy() @@ -9,3 +13,15 @@ def remove_os_env_temporarily() -> dict: def restore_os_env(old_env: dict) -> None: os.environ.update(old_env) + + +def build_mock_context() -> Context: + return Context( + jira_base_url="https://jira-dev/", + jira_client_id="abc123_id", + jira_client_secret="abc123_secret", + app_base_url="http://127.0.0.1:3000", + app_home_page_url="slack://app?team=T123&id=A123&tab=home", + jira_installation_store=MockJiraInstallationStore(), + jira_state_store=MockJiraOAuthStateStore(), + ) diff --git a/utils/constants.py b/utils/constants.py index 3dd6b6d..fa54b3f 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -1,4 +1,3 @@ -import secrets +from utils.context import Context -OAUTH_REDIRECT_PATH = "/oauth/redirect" -JIRA_CODE_VERIFIER = secrets.token_urlsafe(96)[:128] +CONTEXT = Context() diff --git a/utils/context.py b/utils/context.py new file mode 100644 index 0000000..06bf9a4 --- /dev/null +++ b/utils/context.py @@ -0,0 +1,36 @@ +import os +import secrets +from typing import Optional +from urllib.parse import urljoin + +from jira.oauth.installation_store.file import JiraFileInstallationStore +from jira.oauth.installation_store.installation_store import JiraInstallationStore +from jira.oauth.state_store.file import JiraFileOAuthStateStore +from jira.oauth.state_store.state_store import JiraOAuthStateStore + + +class Context: + def __init__( + self, + jira_base_url: Optional[str] = None, + jira_client_id: Optional[str] = None, + jira_client_secret: Optional[str] = None, + jira_code_verifier: Optional[str] = None, + app_base_url: Optional[str] = None, + jira_oauth_redirect_path: str = "/oauth/redirect", + app_home_page_url: Optional[str] = None, + jira_state_store: JiraOAuthStateStore = JiraFileOAuthStateStore(), + jira_installation_store: JiraInstallationStore = JiraFileInstallationStore(), + ): + self.jira_base_url = jira_base_url or os.getenv("JIRA_BASE_URL") + self.jira_client_id = jira_client_id or os.getenv("JIRA_CLIENT_ID") + self.jira_client_secret = jira_client_secret or os.getenv("JIRA_CLIENT_SECRET") + self.app_base_url = app_base_url or os.getenv("APP_BASE_URL") + self.app_home_page_url = app_home_page_url or os.getenv("APP_HOME_PAGE_URL") + + self.jira_code_verifier = jira_code_verifier or secrets.token_urlsafe(96)[:128] + self.jira_oauth_redirect_path = jira_oauth_redirect_path + self.jira_redirect_uri = urljoin(self.app_base_url, self.jira_oauth_redirect_path) + + self.jira_state_store = jira_state_store + self.jira_installation_store = jira_installation_store diff --git a/utils/env_variables.py b/utils/env_variables.py deleted file mode 100644 index fea8875..0000000 --- a/utils/env_variables.py +++ /dev/null @@ -1,9 +0,0 @@ -import os -from urllib.parse import urljoin - -from utils.constants import OAUTH_REDIRECT_PATH - -JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") -JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") -JIRA_REDIRECT_URI = urljoin(os.getenv("APP_BASE_URL"), OAUTH_REDIRECT_PATH) -APP_HOME_PAGE_URL = os.getenv("APP_HOME_PAGE_URL") From ad02d21f5a61858d20ab55795f52709662abdfdb Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 14:23:38 -0400 Subject: [PATCH 37/45] Improve tests --- tests/functions/test_create_issue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index bc0058e..f7ad3d1 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -54,7 +54,7 @@ def test_create_issue(self): mock_fail = MagicMock() mock_complete = MagicMock() mock_context = MagicMock(team_id=self.team_id, enterprise_id=self.enterprise_id) - mock_client = MagicMock() + mock_client = Mock(spec=WebClient) mock_inputs = { "user_context": {"id": self.user_id}, "project": "PROJ", From 82550e2f08ddba42c0ae34139da32b7de4ce9306 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 15:43:25 -0400 Subject: [PATCH 38/45] Improve based on feedback --- .github/dependabot.yml | 2 ++ .gitignore | 1 + README.md | 11 +++++++++++ jira/oauth/installation_store/file.py | 4 ++-- listeners/__init__.py | 4 ++-- listeners/functions/create_issue.py | 4 ++-- 6 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 82ba215..e9aed0c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,3 +11,5 @@ updates: directory: "/" schedule: interval: "monthly" + labels: + - "dependencies" diff --git a/.gitignore b/.gitignore index 5477389..7f4d09f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ logs/ #tmp .slack data +.ruff_cache diff --git a/README.md b/README.md index 600fdba..a7aa0ba 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,17 @@ group each listener based on the Slack Platform feature used, so [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. +### `/jira` + +Every request that needs to authenticate or interact with Jira can use the modules inside this directory. +We've grouped these resources by what actions they do +group each listener based on the Slack Platform feature used, so +`/listeners/shortcuts` handles incoming +[Shortcuts](https://api.slack.com/interactivity/shortcuts) requests, +`/listeners/views` handles +[View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) +and so on. + ### `slack.json` Used by the CLI to interact with the project's SDK dependencies. It contains diff --git a/jira/oauth/installation_store/file.py b/jira/oauth/installation_store/file.py index 3036b63..ca98f19 100644 --- a/jira/oauth/installation_store/file.py +++ b/jira/oauth/installation_store/file.py @@ -27,8 +27,8 @@ def save(self, installation: JiraInstallation): team_installation_dir = f"{self.base_dir}/{e_id}-{t_id}" self._mkdir(team_installation_dir) - u_id = installation["user_id"] - installer_filepath = f"{team_installation_dir}/installer-{u_id}-latest" + user_id = installation["user_id"] + installer_filepath = f"{team_installation_dir}/installer-{user_id}-latest" with open(installer_filepath, "w") as f: entity: str = json.dumps(installation) f.write(entity) diff --git a/listeners/__init__.py b/listeners/__init__.py index 5c31e10..83b4155 100644 --- a/listeners/__init__.py +++ b/listeners/__init__.py @@ -2,6 +2,6 @@ def register_listeners(app): - functions.register(app) - events.register(app) actions.register(app) + events.register(app) + functions.register(app) diff --git a/listeners/functions/create_issue.py b/listeners/functions/create_issue.py index c839fec..30dc794 100644 --- a/listeners/functions/create_issue.py +++ b/listeners/functions/create_issue.py @@ -58,10 +58,10 @@ def create_issue_callback( ) response.raise_for_status() - jason_data = json.loads(response.text) + json_data = json.loads(response.text) complete( outputs={ - "issue_url": jira_client.build_issue_url(key=jason_data["key"]), + "issue_url": jira_client.build_issue_url(key=json_data["key"]), } ) except Exception as e: From bb1ec0c95067c6ccd1c34eda7b123da0f79d5fec Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 15:44:02 -0400 Subject: [PATCH 39/45] Update requirements.txt Co-authored-by: Ethan Zimbelman --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fe6311d..a9fee7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -slack-cli-hooks +slack-cli-hooks==0.0.1 slack-bolt==1.19.0rc1 requests==2.32 Flask==3.0 From c69b1c3564cc963edaccabc5184a4b7c33481b59 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 15:47:11 -0400 Subject: [PATCH 40/45] Improve tests --- tests/functions/test_create_issue.py | 6 +++--- tests/utils.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/functions/test_create_issue.py b/tests/functions/test_create_issue.py index f7ad3d1..9d20411 100644 --- a/tests/functions/test_create_issue.py +++ b/tests/functions/test_create_issue.py @@ -25,7 +25,7 @@ class TestCreateIssue: def setup_method(self): self.old_os_env = remove_os_env_temporarily() - self.mock_context = build_mock_context() + self.mock_context = build_mock_context(team_id=self.team_id) self.mock_context.jira_installation_store.save( { "scope": "WRITE", @@ -34,8 +34,8 @@ def setup_method(self): "expires_in": 1000, "refresh_token": "jira_refresh_token", "user_id": self.user_id, - "team_id": "T1234", - "enterprise_id": "E1234", + "team_id": self.team_id, + "enterprise_id": self.enterprise_id, "installed_at": datetime.now().timestamp(), } ) diff --git a/tests/utils.py b/tests/utils.py index a84a44e..8799a79 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -15,7 +15,7 @@ def restore_os_env(old_env: dict) -> None: os.environ.update(old_env) -def build_mock_context() -> Context: +def build_mock_context(team_id: str = "T123") -> Context: return Context( jira_base_url="https://jira-dev/", jira_client_id="abc123_id", From 9a839c0420613367d93caac034b0983d74f89185 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 15:47:53 -0400 Subject: [PATCH 41/45] Update README.md Co-authored-by: Ethan Zimbelman --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a7aa0ba..3cd35f4 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Step-by-step instructions can be found in our ```zsh # Clone this project onto your machine -slack create my-app -t https://github.com/slack-samples/bolt-python-jira-functions.git +slack create my-app -t slack-samples/bolt-python-jira-functions # Change into the project directory cd my-app From 18f32571700ee9ddc41ab9b47ff0c2b479a10cd2 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 16:25:13 -0400 Subject: [PATCH 42/45] update based on feedback --- .sample.env => .example.env | 0 README.md | 17 +++-------------- 2 files changed, 3 insertions(+), 14 deletions(-) rename .sample.env => .example.env (100%) diff --git a/.sample.env b/.example.env similarity index 100% rename from .sample.env rename to .example.env diff --git a/README.md b/README.md index 3cd35f4..32e8734 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,11 @@ pip install -r requirements.txt Before you can run the app, you'll need to store some environment variables. -1. Rename `.env.sample` to `.env` +1. Rename `.example.env` to `.env` 2. Follow these [Jira Instruction](https://confluence.atlassian.com/adminjiraserver0909/configure-an-incoming-link-1251415519.html) - to get the `Client ID` (`JIRA_CLIENT_ID`) and `Client secret` - (`JIRA_CLIENT_SECRET`) values. + to create an external application and get the `Client ID` (`JIRA_CLIENT_ID`) + and `Client secret` (`JIRA_CLIENT_SECRET`) values. 3. Populate the other environment variable value with proper values. ### Running Your Project Locally @@ -107,17 +107,6 @@ group each listener based on the Slack Platform feature used, so [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. -### `/jira` - -Every request that needs to authenticate or interact with Jira can use the modules inside this directory. -We've grouped these resources by what actions they do -group each listener based on the Slack Platform feature used, so -`/listeners/shortcuts` handles incoming -[Shortcuts](https://api.slack.com/interactivity/shortcuts) requests, -`/listeners/views` handles -[View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) -and so on. - ### `slack.json` Used by the CLI to interact with the project's SDK dependencies. It contains From 34670e046a7beaca4abdeaa80881f0aea775b8e7 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 30 May 2024 17:14:15 -0400 Subject: [PATCH 43/45] update gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7f4d09f..b44acbc 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,6 @@ logs/ .pytype/ #tmp -.slack +.slack/apps.dev.json data .ruff_cache From 24bd6e167978f718522c2cfdb7227b3357253932 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 31 May 2024 09:39:19 -0400 Subject: [PATCH 44/45] small improvements --- tests/utils.py | 2 +- utils/constants.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/utils.py b/tests/utils.py index 8799a79..ea25bf1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -21,7 +21,7 @@ def build_mock_context(team_id: str = "T123") -> Context: jira_client_id="abc123_id", jira_client_secret="abc123_secret", app_base_url="http://127.0.0.1:3000", - app_home_page_url="slack://app?team=T123&id=A123&tab=home", + app_home_page_url=f"slack://app?team={team_id}&id=A123&tab=home", jira_installation_store=MockJiraInstallationStore(), jira_state_store=MockJiraOAuthStateStore(), ) diff --git a/utils/constants.py b/utils/constants.py index fa54b3f..1478737 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -1,3 +1,4 @@ from utils.context import Context +# TODO: Make this not a singleton CONTEXT = Context() From 62a7545205774886b4a30c152e93c082fc8363b6 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 31 May 2024 09:40:21 -0400 Subject: [PATCH 45/45] Fix comment --- utils/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/constants.py b/utils/constants.py index 1478737..b127f60 100644 --- a/utils/constants.py +++ b/utils/constants.py @@ -1,4 +1,4 @@ from utils.context import Context -# TODO: Make this not a singleton +# TODO: Make this not a singleton, maybe add it to the Bolt Context CONTEXT = Context()