diff --git a/src/components/admin/AdminApp.py b/src/components/admin/AdminApp.py index 272b853..fb307dc 100644 --- a/src/components/admin/AdminApp.py +++ b/src/components/admin/AdminApp.py @@ -48,4 +48,22 @@ def post(session, _id: str, content: str): def post(session, _id: str): logger.debug(f"Entering {Routes.ImportHolidays} with args {debug_session(session)}, {_id=}") instance = InstanceManager.get(session, _id) - return instance.import_holidays() \ No newline at end of file + return instance.import_holidays() + +@rt(Routes.ConfigureJira) +def get(session, _id: str, boundaries: str): + logger.debug(f"Entering {Routes.ConfigureJira} - GET with args {debug_session(session)}, {_id=}, {boundaries=}") + instance = InstanceManager.get(session, _id) + return instance.show_configure_jira(json.loads(boundaries) if boundaries else None) + +@rt(Routes.ConfigureJira) +def post(session, _id: str, args: dict): + logger.debug(f"Entering {Routes.ConfigureJira} - POST with args {debug_session(session)}, {_id=}, {args=}") + instance = InstanceManager.get(session, _id) + return instance.update_jira_settings(args) + +@rt(Routes.ConfigureJiraCancel) +def post(session, _id: str): + logger.debug(f"Entering {Routes.ConfigureJiraCancel} with args {debug_session(session)}, {_id=}") + instance = InstanceManager.get(session, _id) + return instance.cancel_jira_settings() \ No newline at end of file diff --git a/src/components/admin/admin_db_manager.py b/src/components/admin/admin_db_manager.py index 6c00b5f..d1f9645 100644 --- a/src/components/admin/admin_db_manager.py +++ b/src/components/admin/admin_db_manager.py @@ -23,9 +23,16 @@ class AiBuddySettingsEntry: self.ollama_port = port +@dataclass() +class JiraSettingsEntry: + user_name: str = "" + api_token: str = "" + + @dataclass class AdminSettings: ai_buddy: AiBuddySettingsEntry = field(default_factory=AiBuddySettingsEntry) + jira: JiraSettingsEntry = field(default_factory=JiraSettingsEntry) class AdminDbManager: @@ -37,3 +44,8 @@ class AdminDbManager: ADMIN_SETTINGS_ENTRY, AdminSettings, "ai_buddy") + self.jira = NestedSettingsManager(session, + settings_manager, + ADMIN_SETTINGS_ENTRY, + AdminSettings, + "jira") diff --git a/src/components/admin/assets/icons.py b/src/components/admin/assets/icons.py new file mode 100644 index 0000000..6caaea7 --- /dev/null +++ b/src/components/admin/assets/icons.py @@ -0,0 +1,10 @@ +from fastcore.basics import NotStr + +icon_jira = NotStr(""" + + + + + + +""") \ No newline at end of file diff --git a/src/components/admin/commands.py b/src/components/admin/commands.py index 1173059..4b6e0df 100644 --- a/src/components/admin/commands.py +++ b/src/components/admin/commands.py @@ -38,7 +38,31 @@ class AdminCommandManager(BaseCommandManager): "hx-swap": "outerHTML", "hx-vals": f'js:{{"_id": "{self._id}", boundaries: getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}', } - + + def show_configure_jira(self): + return { + "hx-get": f"{ROUTE_ROOT}{Routes.ConfigureJira}", + "hx-target": f"#{self._owner.tabs_manager.get_id()}", + "hx-swap": "outerHTML", + "hx-vals": f'js:{{"_id": "{self._id}", boundaries: getTabContentBoundaries("{self._owner.tabs_manager.get_id()}")}}', + } + + def save_configure_jira(self): + return { + "hx-post": f"{ROUTE_ROOT}{Routes.ConfigureJira}", + "hx-target": f"#{self._owner.tabs_manager.get_id()}", + "hx-swap": "outerHTML", + "hx-vals": f'js:{{"_id": "{self._id}"}}', + # The form adds the rest + } + + def cancel_configure_jira(self): + return { + "hx-post": f"{ROUTE_ROOT}{Routes.ConfigureJiraCancel}", + "hx-target": f"#{self._owner.tabs_manager.get_id()}", + "hx-swap": "outerHTML", + "hx-vals": f'js:{{"_id": "{self._id}"}}', + } class ImportHolidaysCommandManager(BaseCommandManager): def __init__(self, owner): diff --git a/src/components/admin/components/Admin.py b/src/components/admin/components/Admin.py index 2e3fee3..c7623df 100644 --- a/src/components/admin/components/Admin.py +++ b/src/components/admin/components/Admin.py @@ -4,10 +4,11 @@ from ai.mcp_client import MPC_CLIENTS_IDS from ai.mcp_tools import MCPServerTools from components.BaseComponent import BaseComponent from components.admin.admin_db_manager import AdminDbManager +from components.admin.assets.icons import icon_jira from components.admin.commands import AdminCommandManager from components.admin.components.AdminForm import AdminFormItem, AdminFormType, AdminForm from components.admin.components.ImportHolidays import ImportHolidays -from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID +from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_JIRA_INSTANCE_ID from components.aibuddy.assets.icons import icon_brain_ok from components.hoildays.assets.icons import icon_holidays from components.tabs.components.MyTabs import MyTabs @@ -59,6 +60,30 @@ class Admin(BaseComponent): return self._add_tab(ADMIN_AI_BUDDY_INSTANCE_ID, "Admin - Import Holidays", form) + def show_configure_jira(self, boundaries): + fields = [ + AdminFormItem('user_name', "Email", "Email used to connect to JIRA.", AdminFormType.TEXT), + AdminFormItem("api_token", "API Key", "API Key to connect to JIRA.", AdminFormType.TEXT), + ] + hooks = { + "on_ok": self.commands.save_configure_jira(), + "on_cancel": self.commands.cancel_configure_jira(), + "ok_title": "Apply" + } + + form = InstanceManager.get(self._session, + AdminForm.create_component_id(self._session, prefix=self._id), + AdminForm, + owner=self, + title="Jira Configuration Page", + obj=self.db.jira, + form_fields=fields, + hooks=hooks, + key=ADMIN_JIRA_INSTANCE_ID, + boundaries=boundaries + ) + return self._add_tab(ADMIN_JIRA_INSTANCE_ID, "Admin - Jira Configuration", form) + def update_ai_buddy_settings(self, values: dict): values = self.manage_lists(values) self.db.ai_buddy.update(values, ignore_missing=True) @@ -69,6 +94,17 @@ class Admin(BaseComponent): self.tabs_manager.remove_tab(tab_id) return self.tabs_manager.render() + def update_jira_settings(self, values: dict): + values = self.manage_lists(values) + self.db.jira.update(values, ignore_missing=True) + return self.tabs_manager.render() + + def cancel_jira_settings(self): + tab_id = self.tabs_manager.get_tab_id(ADMIN_JIRA_INSTANCE_ID) + self.tabs_manager.remove_tab(tab_id) + return self.tabs_manager.render() + + def __ft__(self): return Div( Div(cls="divider"), @@ -84,6 +120,11 @@ class Admin(BaseComponent): mk_ellipsis("holidays", cls="text-sm", **self.commands.show_import_holidays()), cls="flex p-0 min-h-0 truncate", ), + Div( + mk_icon(icon_jira, can_select=False), + mk_ellipsis("jira", cls="text-sm", **self.commands.show_configure_jira()), + cls="flex p-0 min-h-0 truncate", + ), # # cls=""), # Script(f"bindAdmin('{self._id}')"), diff --git a/src/components/admin/constants.py b/src/components/admin/constants.py index 397b15b..cbde2ba 100644 --- a/src/components/admin/constants.py +++ b/src/components/admin/constants.py @@ -1,6 +1,7 @@ ADMIN_INSTANCE_ID = "__Admin__" ADMIN_AI_BUDDY_INSTANCE_ID = "__AdminAIBuddy__" ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID = "__AdminImportHolidays__" +ADMIN_JIRA_INSTANCE_ID = "__AdminJira__" ROUTE_ROOT = "/admin" ADMIN_SETTINGS_ENTRY = "Admin" @@ -8,4 +9,6 @@ class Routes: AiBuddy = "/ai-buddy" AiBuddyCancel = "/ai-buddy-cancel" ImportHolidays = "/import-holidays" - PasteHolidays = "/paste-holidays" \ No newline at end of file + PasteHolidays = "/paste-holidays" + ConfigureJira = "/configure-jira" + ConfigureJiraCancel = "/configure-jira-cancel" diff --git a/src/components/workflows/components/WorkflowPlayer.py b/src/components/workflows/components/WorkflowPlayer.py index 289b0dc..8e6aebe 100644 --- a/src/components/workflows/components/WorkflowPlayer.py +++ b/src/components/workflows/components/WorkflowPlayer.py @@ -13,7 +13,7 @@ from components.workflows.db_management import WorkflowComponentRuntimeState, \ WorkflowComponent, ComponentState from core.instance_manager import InstanceManager from core.utils import get_unique_id, make_safe_id -from workflow.engine import WorkflowEngine, TableDataProducer, DefaultDataPresenter, DefaultDataFilter +from workflow.engine import WorkflowEngine, TableDataProducer, DefaultDataPresenter, DefaultDataFilter, JiraDataProducer grid_settings = DataGridSettings( header_visible=True, @@ -180,19 +180,25 @@ class WorkflowPlayer(BaseComponent): # first reorder the component, according to the connection definitions engine = WorkflowEngine() for component in sorted_components: + key = (component.type, component.properties["processor_name"]) try: - if component.type == ProcessorTypes.Producer and component.properties["processor_name"] == "Repository": + if key == (ProcessorTypes.Producer, "Repository"): engine.add_processor( TableDataProducer(self._session, self._settings_manager, component.id, component.properties["repository"], component.properties["table"])) - - elif component.type == ProcessorTypes.Filter and component.properties["processor_name"] == "Default": + elif key == (ProcessorTypes.Producer, "Jira"): + engine.add_processor( + JiraDataProducer(self._session, + self._settings_manager, + component.id, + 'issues', + component.properties["jira_jql"])) + elif key == (ProcessorTypes.Filter, "Default"): engine.add_processor(DefaultDataFilter(component.id, component.properties["filter"])) - - elif component.type == ProcessorTypes.Presenter and component.properties["processor_name"] == "Default": + elif key == (ProcessorTypes.Presenter, "Default"): engine.add_processor(DefaultDataPresenter(component.id, component.properties["columns"])) else: raise ValueError( diff --git a/src/core/jira.py b/src/core/jira.py new file mode 100644 index 0000000..12df45f --- /dev/null +++ b/src/core/jira.py @@ -0,0 +1,225 @@ +import json +import logging + +import requests +from requests.auth import HTTPBasicAuth + +from core.Expando import Expando + +JIRA_ROOT = "https://altares.atlassian.net/rest/api/2" +DEFAULT_HEADERS = {"Accept": "application/json"} + +logger = logging.getLogger("jql") + + +class NotFound(Exception): + pass + + +class Jira: + """Manage default operation to JIRA""" + + def __init__(self, user_name: str, api_token: str): + """ + Prepare a connection to JIRA + The initialisation do not to anything, + It only stores the user_name and the api_token + Note that user_name and api_token is the recommended way to connect, + therefore, the only supported here + :param user_name: + :param api_token: + """ + self.user_name = user_name + self.api_token = api_token + self.auth = HTTPBasicAuth(self.user_name, self.api_token) + + def issue(self, issue_id: str) -> Expando: + """ + Retrieve an issue + :param issue_id: + :return: + """ + url = f"{JIRA_ROOT}/issue/{issue_id}" + + response = requests.request( + "GET", + url, + headers=DEFAULT_HEADERS, + auth=self.auth + ) + + return Expando(json.loads(response.text)) + + def fields(self) -> list[Expando]: + """ + Retrieve the list of all fields for an issue + :return: + """ + url = f"{JIRA_ROOT}/field" + + response = requests.request( + "GET", + url, + headers=DEFAULT_HEADERS, + auth=self.auth + ) + + as_dict = json.loads(response.text) + return [Expando(field) for field in as_dict] + + def jql(self, jql: str, fields="summary,status,assignee") -> list[Expando]: + """ + Executes a JQL and returns the list of issues + :param jql: + :param fields: list of fields to retrieve + :return: + """ + logger.debug(f"Processing jql '{jql}'") + url = f"{JIRA_ROOT}/search" + + headers = DEFAULT_HEADERS.copy() + headers["Content-Type"] = "application/json" + + payload = { + "fields": [f.strip() for f in fields.split(",")], + "fieldsByKeys": False, + "jql": jql, + "maxResults": 500, # Does not seem to be used. It's always 100 ! + "startAt": 0 + } + + result = [] + while True: + logger.debug(f"Request startAt '{payload['startAt']}'") + response = requests.request( + "POST", + url, + data=json.dumps(payload), + headers=headers, + auth=self.auth + ) + + if response.status_code != 200: + raise Exception(self._format_error(response)) + + as_dict = json.loads(response.text) + result += as_dict["issues"] + + if as_dict["startAt"] + as_dict["maxResults"] >= as_dict["total"]: + # We retrieve more than the total nuber of items + break + + payload["startAt"] += as_dict["maxResults"] + + return [Expando(issue) for issue in result] + + def extract(self, jql, mappings, updates=None) -> list[dict]: + """ + Executes a jql and returns list of dict + The issue object, returned by the jql methods + contains all the fields for Jira. They are not all necessary + This method selects the required fields + :param jql: + :param mappings: + :param updates: List of updates (lambda on issue) to perform + :return: + """ + logger.debug(f"Processing extract using mapping {mappings}") + + def _get_field(mapping): + """Returns the meaningful jira field, for the mapping description path""" + fields = mapping.split(".") + return fields[1] if len(fields) > 1 and fields[0] == "fields" else fields[0] + + # retrieve the list of requested fields from what was asked in the mapping + jira_fields = [_get_field(mapping) for mapping in mappings] + as_string = ", ".join(jira_fields) + issues = self.jql(jql, as_string) + + for issue in issues: + # apply updates if needed + if updates: + for update in updates: + update(issue) + + row = {cvs_col: issue.get(jira_path) for jira_path, cvs_col in mappings.items() if cvs_col is not None} + yield row + + def get_versions(self, project_key): + """ + Given a project name and a version name + returns fixVersion number in JIRA + :param project_key: + :param version_name: + :return: + """ + + url = f"{JIRA_ROOT}/project/{project_key}/versions" + + response = requests.request( + "GET", + url, + headers=DEFAULT_HEADERS, + auth=self.auth + ) + + if response.status_code != 200: + raise NotFound() + + as_list = json.loads(response.text) + return [Expando(version) for version in as_list] + + def get_version(self, project_key, version_name): + """ + Given a project name and a version name + returns fixVersion number in JIRA + :param project_key: + :param version_name: + :return: + """ + + for version in self.get_versions(project_key): + if version.name == version_name: + return version + + raise NotFound() + + def get_all_fields(self): + """ + Helper function that returns the list of all field that can be requested in an issue + :return: + """ + url = f"{JIRA_ROOT}/field" + response = requests.request( + "GET", + url, + headers=DEFAULT_HEADERS, + auth=self.auth + ) + + as_dict = json.loads(response.text) + return [Expando(issue) for issue in as_dict] + + @staticmethod + def update_customer_refs(issue: Expando, bug_only=True, link_name=None): + issue["ticket_customer_refs"] = [] + if bug_only and issue.fields.issuetype.name != "Bug": + return + + for issue_link in issue.fields.issuelinks: # [i_link for i_link in issue.fields.issuelinks if i_link["type"]["name"] == "Relates"]: + if link_name and issue_link["type"]["name"] not in link_name: + continue + + direction = "inwardIssue" if "inwardIssue" in issue_link else "outwardIssue" + related_issue_key = issue_link[direction]["key"] + if related_issue_key.startswith("ITSUP"): + issue.ticket_customer_refs.append(related_issue_key) + continue + + @staticmethod + def _format_error(response): + if "errorMessages" in response.text: + error_messages = json.loads(response.text)["errorMessages"] + else: + error_messages = response.text + return f"Error {response.status_code} : {response.reason} : {error_messages}" \ No newline at end of file diff --git a/src/workflow/engine.py b/src/workflow/engine.py index 0d57005..71b79ae 100644 --- a/src/workflow/engine.py +++ b/src/workflow/engine.py @@ -2,7 +2,9 @@ import ast from abc import ABC, abstractmethod from typing import Any, Generator +from components.admin.admin_db_manager import AdminDbManager from core.Expando import Expando +from core.jira import Jira from core.utils import UnreferencedNamesVisitor from utils.Datahelper import DataHelper @@ -87,6 +89,22 @@ class TableDataProducer(DataProducer): yield from DataHelper.get(self._session, self.settings_manager, self.repository_name, self.table_name, Expando) +class JiraDataProducer(DataProducer): + """Base class for data producers that emit data from Jira.""" + + def __init__(self, session, settings_manager, component_id, jira_object='issues', jira_query=''): + super().__init__(component_id) + self._session = session + self.settings_manager = settings_manager + self.jira_object = jira_object + self.jira_query = jira_query + self.db = AdminDbManager(session, settings_manager).jira + + def emit(self, data: Any = None) -> Generator[Any, None, None]: + jira = Jira(self.db.user_name, self.db.api_token) + yield from jira.jql(self.jira_query) + + class DefaultDataPresenter(DataPresenter): """Default data presenter that returns the input data unchanged.""" @@ -155,6 +173,7 @@ class WorkflowEngine: for processed_item in processor.process(item): # Recursively process through remaining processors yield from self._process_single_item(processed_item, processor_index + 1) + def run(self) -> Generator[Any, None, None]: """ @@ -173,7 +192,7 @@ class WorkflowEngine: self.global_error = "First processor must be a DataProducer" raise ValueError(self.global_error) - for item in first_processor.emit(): + for item in first_processor.process(None): yield from self._process_single_item(item, 1) def run_to_list(self) -> list[Any]: