diff --git a/.github/workflows/generate-scorecard-reminder.yaml b/.github/workflows/generate-scorecard-reminder.yaml index bb9097a..7a23e53 100644 --- a/.github/workflows/generate-scorecard-reminder.yaml +++ b/.github/workflows/generate-scorecard-reminder.yaml @@ -9,12 +9,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Generate Scorecards Reminders - uses: port-labs/port-sender@v0.1.15 + uses: port-labs/port-sender@v0.2.0 with: - message_kind: scorecard_reminder + operation_kind: scorecard_reminder port_client_id: ${{ secrets.PORT_CLIENT_ID }} port_client_secret: ${{ secrets.PORT_CLIENT_SECRET }} slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} blueprint: app scorecard: productionReadiness - filter_rule: '{"property": "$team","operator": "containsAny","value": ["Backend Team"]}' \ No newline at end of file + filter_rule: '{"property": "$team","operator": "containsAny","value": ["Backend Team"]}' diff --git a/.github/workflows/generate-scorecard-report.yaml b/.github/workflows/generate-scorecard-report.yaml index 73c3c4e..074c307 100644 --- a/.github/workflows/generate-scorecard-report.yaml +++ b/.github/workflows/generate-scorecard-report.yaml @@ -8,11 +8,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Generate Scorecard Report - uses: port-labs/port-sender@v0.1.15 + uses: port-labs/port-sender@v0.2.0 with: - message_kind: scorecard_report + operation_kind: scorecard_report port_client_id: ${{ secrets.PORT_CLIENT_ID }} port_client_secret: ${{ secrets.PORT_CLIENT_SECRET }} slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} blueprint: app - scorecard: productionReadiness \ No newline at end of file + scorecard: productionReadiness diff --git a/.github/workflows/sync-jira-issues.yaml b/.github/workflows/sync-jira-issues.yaml new file mode 100644 index 0000000..2298a79 --- /dev/null +++ b/.github/workflows/sync-jira-issues.yaml @@ -0,0 +1,25 @@ +name: Sync Jira issues based on scorecards + + +on: + workflow_dispatch: + +jobs: + sync-jira-issues: + runs-on: ubuntu-latest + steps: + - name: Sync Jira Issues + uses: port-labs/port-sender@v0.2.0 + with: + operation_kind: ticket_handler + port_client_id: ${{ secrets.PORT_CLIENT_ID }} + port_client_secret: ${{ secrets.PORT_CLIENT_SECRET }} + blueprint: app + scorecard: productionReadiness + filter_rule: '{"property": "$team","operator": "containsAny","value": ["AAA"]}' + jira_api_endpoint: https://getport.atlassian.net + jira_email: matar@getport.io + jira_project_id: DEMO + jira_token: {{ secrets.JIRA_TOKEN }} + + target_kind: jira diff --git a/.gitignore b/.gitignore index 4101782..bfd5de3 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,8 @@ Thumbs.db # IDE files .idea .vscode -*.code-workspace \ No newline at end of file +*.code-workspace + +#Pycache +*__pycache__* +.pyc diff --git a/README.md b/README.md index 88923c9..22fa166 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Port Message Sender GitHub Action +# Port Initiatives Sender GitHub Action [![Slack](https://img.shields.io/badge/Slack-4A154B?style=for-the-badge&logo=slack&logoColor=white)](https://join.slack.com/t/devex-community/shared_invite/zt-1bmf5621e-GGfuJdMPK2D8UN58qL4E_g) @@ -44,7 +44,7 @@ Action to send a scorecard report to a Slack channel about the current state and | `slack_webhook_url` | Slack Webhook URL | true | | | `blueprint` | Blueprint identifier | true | | | `scorecard` | Scorecard identifier | true | | -| `message_kind` | Message kind to send, to send Scorecard Report, pass - `scorecard_report` | true | | +| `operation_kind` | Message kind to send, to send Scorecard Report, pass - `scorecard_report` | true | | | `filter_rule` | The [rule filter](https://docs.getport.io/search-and-query/#rules) to apply on the data queried from Port | false | | This action will send a scorecard report to a Slack channel about the current state and progress in a scorecard. @@ -86,15 +86,15 @@ A call to action to remind the team that some of their services didn't reach Gol ### Usage -| Input | Description | Required | Default | -|----------------------|------------------------------------------------------------------------------|----------|---------| -| `port_client_id` | Port Client ID | true | | -| `port_client_secret` | Port Client Secret | true | | -| `port_region` | Port Region to use, if not provided will use the default region of Port | false | eu | -| `slack_webhook_url` | Slack Webhook URL | true | | -| `blueprint` | Blueprint identifier | true | | -| `scorecard` | Scorecard identifier | true | | -| `message_kind` | Message kind to send, to send Scorecard Reminder, pass - `scorecard_reminder` | true | | +| Input | Description | Required | Default | +|----------------------|-----------------------------------------------------------------------------------------------------------|----------|---------| +| `port_client_id` | Port Client ID | true | | +| `port_client_secret` | Port Client Secret | true | | +| `port_region` | Port Region to use, if not provided will use the default region of Port | false | eu | +| `slack_webhook_url` | Slack Webhook URL | true | | +| `blueprint` | Blueprint identifier | true | | +| `scorecard` | Scorecard identifier | true | | +| `operation_kind` | Opetation kind to perform, to send Scorecard Reminder, pass - `scorecard_reminder` | true | | | `filter_rule` | The [rule filter](https://docs.getport.io/search-and-query/#rules) to apply on the data queried from Port | false | | This example will send a scheduled reminder to a Slack channel about all the services that didn't reach the Gold level in the `productionReadiness` scorecard for the Backend Team. @@ -127,4 +127,78 @@ jobs: filter_rule: '{"property": "$team","operator": "containsAny","value": ["Backend Team"]}' ``` + +## Manage scorecards with Jira issues +A call to action to sync Jira issues (create/reopen/resolve) with scorecards and rules. + +For every scorecard level that is not completed, a Jira task will be created and Subtasks for the level rules. + +### Output example + +Generated Scorecard task for the bronze level: + ![Jira Task](docs/assets/jira-sync-task.png) + +Generated subtasks for the task: +![Jira Subtask](docs/assets/jira-sync-subtask.png) + + + +### Usage + +| Input | Description | Required | Default | +|------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------| +| `port_client_id` | Port Client ID | true | | +| `port_client_secret` | Port Client Secret | true | | +| `port_region` | Port Region to use, if not provided will use the default region of Port | false | eu | +| `slack_webhook_url` | Slack Webhook URL | true | | +| `blueprint` | Blueprint identifier | true | | +| `scorecard` | Scorecard identifier | true | | +| `opeation_kind` | Message kind to send, to send Scorecard Reminder, pass - `scorecard_reminder` | true | | +| `filter_rule` | The [rule filter](https://docs.getport.io/search-and-query/#rules) to apply on the data queried from Port | false | | +| `jira_project_id` | The [project id](https://confluence.atlassian.com/jirakb/how-to-get-project-id-from-the-jira-user-interface-827341414.html) in Jira for tasks updating | true | | +| `jira_api_endpoint` | The URL of your Jira organization | true | | +| `jira_token` | The [Jira API token ](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/)for using Jira's REST API. | true | | +| `jira_email` | The Jira email of the user account for using Jira's REST API. | true | | +| `jira_resolve_transition_id` | The Jira [transition](https://support.atlassian.com/jira-software-cloud/docs/transition-an-issue/) ID used for resolving issues. If not inserted will use the default transition for the "Done" status. | false | | +| `jira_reopen_transition_id` | The Jira [transition](https://support.atlassian.com/jira-software-cloud/docs/transition-an-issue/) ID used for resolving issues. If not inserted will use the default transition for the "To Do" status. | false | | + +This example will create a Jira task for every service in every level that are not completed in the `productionReadiness` scorecard for the Backend Team. +For every rule in the scorecard that is not completed, a subtask under the relevant task in Jira will be created. +Once the scorecard is completed, the tasks and subtasks will be resolved (passed to Done status). + +You can modify the schedule to run the reminder on a daily/weekly/monthly basis. For more information about scheduling, refer to the [GitHub Actions documentation](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule). + +You can also modify the filter rule to filter the services, ideally you would want to filter by team, so that each team will get a reminder about their services. + +```yaml +name: Sync Jira Issues with Scorecard Initiatives + +on: + schedule: + ## run every day at 9am + - cron: '0 9 * * *' + workflow_dispatch: + +jobs: + sync-jira-issues: + runs-on: ubuntu-latest + steps: + - name: Sync Jira Issues + uses: port-labs/port-sender@v0.2.0 + with: + operation_kind: ticket_handler + port_client_id: ${{ secrets.PORT_CLIENT_ID }} + port_client_secret: ${{ secrets.PORT_CLIENT_SECRET }} + blueprint: app + scorecard: productionReadiness + filter_rule: '{"property": "$team","operator": "containsAny","value": ["Backend Team"]}' + jira_api_endpoint: https://example.atlassian.net + jira_email: matar@getport.io + jira_project_id: EXAMPLE + jira_token: MY-JIRA-TOKEN + + target_kind: jira + +``` + You can find more examples in the [examples folder](docs/examples/) diff --git a/config.py b/config.py index b8af1d5..9b40497 100644 --- a/config.py +++ b/config.py @@ -1,17 +1,19 @@ from enum import Enum -from typing import List, Union, Optional +from typing import List, Union -from pydantic import Field, BaseModel +from pydantic import BaseModel, Field from pydantic_settings import BaseSettings -class MessageKind(str, Enum): +class OperationKind(str, Enum): scorecard_reminder = "scorecard_reminder" scorecard_report = "scorecard_report" + ticket_creator = "ticket_handler" class TargetKind(str, Enum): slack = "slack" + jira = "jira" class FilterRule(BaseModel): @@ -23,12 +25,18 @@ class FilterRule(BaseModel): class Settings(BaseSettings): port_client_id: str port_client_secret: str - slack_webhook_url: str + slack_webhook_url: str = "" + jira_project_id: str = "" + jira_api_endpoint: str = "https://jira.com" + jira_email: str = "" + jira_resolve_transition_id: str = "" + jira_reopen_transition_id: str = "" + jira_token: str = "" port_region: str = "eu" blueprint: str scorecard: str filter_rule: Union[FilterRule, str, None] = Field(default=None) - message_kind: MessageKind = MessageKind.scorecard_reminder + operation_kind: OperationKind = OperationKind.scorecard_reminder target_kind: TargetKind = TargetKind.slack class Config: diff --git a/core/base_handler.py b/core/base_handler.py new file mode 100644 index 0000000..e3e138f --- /dev/null +++ b/core/base_handler.py @@ -0,0 +1,37 @@ +import logging + +from config import settings +from port.client import PortClient + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class BaseHandler: + def __init__(self): + logger.info("Initializing Port client") + port_client = PortClient( + settings.port_region, + settings.port_client_id, + settings.port_client_secret + ) + logger.info( + f"Fetching entities for query:" + f" {settings.filter_rule}," + f" blueprint {settings.blueprint}," + f" scorecard {settings.scorecard}" + ) + search_query = { + "combinator": "and", + "rules": [ + {"property": "$blueprint", + "operator": "=", "value": settings.blueprint} + ], + } + if settings.filter_rule: + search_query["rules"].append(settings.filter_rule.dict()) + + self.entities = port_client.search_entities(search_query) + self.scorecard = port_client.get_scorecard( + settings.blueprint, settings.scorecard + ).get("scorecard") diff --git a/core/handler.py b/core/handler.py deleted file mode 100644 index fcf0388..0000000 --- a/core/handler.py +++ /dev/null @@ -1,84 +0,0 @@ -import logging - -from config import settings, TargetKind -from port.client import PortClient -from targets import Slack -from generators import SlackMessageGenerator - - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class Handler: - @classmethod - def scorecard_reminder(cls): - logger.info("Initializing Port client") - port_client = PortClient(settings.port_region, settings.port_client_id, settings.port_client_secret) - logger.info( - f"Fetching entities for query: {settings.filter_rule}, blueprint {settings.blueprint}, scorecard {settings.scorecard}") - search_query = { - "combinator": "and", - "rules": [ - { - "property": "$blueprint", - "operator": "=", - "value": settings.blueprint - } - ], - } - if settings.filter_rule: - search_query["rules"].append(settings.filter_rule.dict()) - - entities = port_client.search_entities( - search_query - ) - scorecard = port_client.get_scorecard(settings.blueprint, settings.scorecard).get("scorecard") - if not entities: - logger.info("No entities found") - return - if settings.target_kind == TargetKind.slack: - logger.info(f"Generating scorecards reminders for {len(entities)} entities") - blocks = SlackMessageGenerator().scorecard_reminder(settings.blueprint, - scorecard, - entities) - logger.info("Sending scorecards reminders to slack channel") - Slack().send_message(blocks) - - @classmethod - def scorecard_report(cls): - logger.info("Initializing Port client") - port_client = PortClient(settings.port_region, settings.port_client_id, settings.port_client_secret) - logger.info( - f"Fetching entities for query: {settings.filter_rule}, blueprint {settings.blueprint}, scorecard {settings.scorecard}") - search_query = { - "combinator": "and", - "rules": [ - { - "property": "$blueprint", - "operator": "=", - "value": settings.blueprint - } - ], - } - if settings.filter_rule: - search_query["rules"].append(settings.filter_rule.dict()) - - entities = port_client.search_entities( - search_query - ) - scorecard = port_client.get_scorecard(settings.blueprint, settings.scorecard).get("scorecard") - if not entities: - logger.info("No entities found") - return - if settings.target_kind == TargetKind.slack: - logger.info(f"Generating scorecard report for {len(entities)} entities") - blocks = SlackMessageGenerator().scorecard_report(settings.blueprint, - scorecard, - entities) - logger.info("Sending scorecard report to slack channel") - Slack().send_message(blocks) - - - - diff --git a/core/jira_handler.py b/core/jira_handler.py new file mode 100644 index 0000000..3c72c81 --- /dev/null +++ b/core/jira_handler.py @@ -0,0 +1,94 @@ +import logging + +from config import settings +from core.base_handler import BaseHandler +from generators.jira import JiraIssueGenerator +from targets.jira import Jira + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class JiraHandler(BaseHandler): + def ticket_handler(self): + if not self.entities: + logger.info("No entities found") + return + + logger.info("Searching for Jira issues to create / update") + + for entity in self.entities: + entity_scorecard = entity.get("scorecards", {}).get( + self.scorecard.get("identifier"), {} + ) + rules_by_level = {"Gold": [], "Silver": [], "Bronze": []} + + # Grouping rules by levels + for rule in entity_scorecard.get("rules", []): + rules_by_level[rule.get("level")].append(rule) + + for level in rules_by_level: + scorecard_level_completed = all( + rule.get("status", "") == "SUCCESS" + for rule in rules_by_level[level] + ) + + generated_task = JiraIssueGenerator().generate_task( + self.scorecard, entity, settings.blueprint, level + ) + task_summary = generated_task["fields"]["summary"] + task_search_query = ( + f"project={settings.jira_project_id} " + f"AND summary~'{task_summary}' " + f"AND issuetype = Task " + f"ORDER BY created DESC" + ) + task_search_result = Jira().search_issue(task_search_query) + + task_exists = task_search_result["total"] > 0 + + if not task_exists: + if scorecard_level_completed: + continue + parent_key = Jira().create_issue(generated_task)["key"] + else: + task = task_search_result["issues"][0] + parent_key = task["key"] + if (task["fields"]["resolution"] and + not scorecard_level_completed): + Jira().reopen_issue(task) + + for rule in rules_by_level[level]: + full_rule_object = [ + scorecard_rule + for scorecard_rule in self.scorecard.get("rules", []) + if scorecard_rule.get("identifier") == rule.get("identifier") + ][0] + generated_subtask = JiraIssueGenerator().generate_subtask( + full_rule_object, + self.scorecard.get("title", ""), + entity, + parent_key, + ) + subtask_search_query = ( + f"project={settings.jira_project_id} " + f"AND summary~'{generated_subtask['fields']['summary']}' " + f"AND issuetype = Subtask " + f"AND parent = '{parent_key}'" + ) + rule_search_result = Jira().search_issue(subtask_search_query) + rule_successful = rule.get("status", "") == "SUCCESS" + + if rule_search_result["total"] > 0: + subtask = rule_search_result.get("issues", [])[0] + if rule_successful and not subtask["fields"]["resolution"]: + Jira().resolve_issue(subtask) + elif not rule_successful and subtask["fields"]["resolution"]: + Jira().reopen_issue(subtask) + elif not rule_successful: + Jira().create_issue(generated_subtask) + + if (scorecard_level_completed and + task_exists and + not task["fields"]["resolution"]): + Jira().resolve_issue(task) diff --git a/core/slack_handler.py b/core/slack_handler.py new file mode 100644 index 0000000..37d2455 --- /dev/null +++ b/core/slack_handler.py @@ -0,0 +1,37 @@ +import logging + +from config import settings +from core.base_handler import BaseHandler +from generators.slack import SlackMessageGenerator +from targets.slack import Slack + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class SlackHandler(BaseHandler): + def scorecard_reminder(self): + if not self.entities: + logger.info("No entities found") + return + + logger.info( + f"Generating scorecards reminders for {len(self.entities)} entities" + ) + blocks = SlackMessageGenerator().scorecard_reminder( + settings.blueprint, self.scorecard, self.entities + ) + logger.info("Sending scorecards reminders to slack channel") + Slack().send_message(blocks) + + def scorecard_report(self): + if not self.entities: + logger.info("No entities found") + return + + logger.info(f"Generating scorecard report for {len(self.entities)} entities") + blocks = SlackMessageGenerator().scorecard_report( + settings.blueprint, self.scorecard, self.entities + ) + logger.info("Sending scorecard report to slack channel") + Slack().send_message(blocks) diff --git a/docs/assets/jira-sync-subtask.png b/docs/assets/jira-sync-subtask.png new file mode 100644 index 0000000..fdd0453 Binary files /dev/null and b/docs/assets/jira-sync-subtask.png differ diff --git a/docs/assets/jira-sync-task.png b/docs/assets/jira-sync-task.png new file mode 100644 index 0000000..de9aad0 Binary files /dev/null and b/docs/assets/jira-sync-task.png differ diff --git a/docs/examples/generate-scorecard-reminder.yaml b/docs/examples/generate-scorecard-reminder.yaml index 85727c4..ef7d8f0 100644 --- a/docs/examples/generate-scorecard-reminder.yaml +++ b/docs/examples/generate-scorecard-reminder.yaml @@ -11,9 +11,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Generate Scorecards Reminders - uses: port-labs/port-sender@v0.1.15 + uses: port-labs/port-sender@v0.2.0 with: - message_kind: scorecard_reminder + operation_kind: scorecard_reminder port_client_id: ${{ secrets.PORT_CLIENT_ID }} port_client_secret: ${{ secrets.PORT_CLIENT_SECRET }} slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/docs/examples/generate-scorecard-report.yaml b/docs/examples/generate-scorecard-report.yaml index dde62fe..b8b9080 100644 --- a/docs/examples/generate-scorecard-report.yaml +++ b/docs/examples/generate-scorecard-report.yaml @@ -11,9 +11,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Generate Scorecard Report - uses: port-labs/port-sender@v0.1.15 + uses: port-labs/port-sender@v0.2.0 with: - message_kind: scorecard_report + operation_kind: scorecard_report port_client_id: ${{ secrets.PORT_CLIENT_ID }} port_client_secret: ${{ secrets.PORT_CLIENT_SECRET }} slack_webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/docs/examples/sync-jira-issues.yaml b/docs/examples/sync-jira-issues.yaml new file mode 100644 index 0000000..1ecbf12 --- /dev/null +++ b/docs/examples/sync-jira-issues.yaml @@ -0,0 +1,31 @@ +name: Sync Jira Issues with Scorecard Initiatives + +on: + schedule: + ## run every day at 9am + - cron: '0 9 * * *' + workflow_dispatch: + +jobs: + sync-jira-issues: + runs-on: ubuntu-latest + steps: + - name: Sync Jira Issues + uses: port-labs/port-sender@v0.2.0 + with: + operation_kind: ticket_handler + port_client_id: ${{ secrets.PORT_CLIENT_ID }} + port_client_secret: ${{ secrets.PORT_CLIENT_SECRET }} + blueprint: app + scorecard: productionReadiness + filter_rule: '{"property": "$team","operator": "containsAny","value": ["Backend Team"]}' + jira_api_endpoint: https://example.atlassian.net + jira_email: matar@getport.io + jira_project_id: EXAMPLE + jira_token: MY-JIRA-TOKEN + + target_kind: jira + + + + diff --git a/generators/__init__.py b/generators/__init__.py index 5ad6311..e69de29 100644 --- a/generators/__init__.py +++ b/generators/__init__.py @@ -1 +0,0 @@ -from .slack import SlackMessageGenerator diff --git a/generators/base.py b/generators/base.py index 4452c5d..d43f30f 100644 --- a/generators/base.py +++ b/generators/base.py @@ -1,4 +1,5 @@ import abc +from typing import Any, Dict class BaseMessageGenerator(abc.ABC): @@ -9,3 +10,13 @@ def scorecard_reminder(self, blueprint: str, scorecard_name: str, entities: list @abc.abstractmethod def scorecard_report(self, blueprint: str, scorecard: str, entities: list): pass + + +class BaseTicketGenerator(abc.ABC): + @abc.abstractmethod + def generate_task(self, scorecard: Dict[str, Any], entity: Dict[str, Any], blueprint: str, level: str): + pass + + @abc.abstractmethod + def generate_subtask(self, rule: Dict[str, Any], scorecard_title: str, entity: Dict[str, Any], parent_key: str): + pass diff --git a/generators/jira.py b/generators/jira.py new file mode 100644 index 0000000..5c65b4d --- /dev/null +++ b/generators/jira.py @@ -0,0 +1,298 @@ +from typing import Any, Dict, List + +import generators.base +from config import settings + +scorecards_singular_operators = ["isEmpty", "isNotEmpty"] + + +class JiraIssueGenerator(generators.base.BaseTicketGenerator): + + def generate_task(self, scorecard: Dict[str, Any], entity: Dict[str, Any], blueprint: str, level: str): + scorecard_title = scorecard.get("title", "") + entity_title = entity.get("title", "") + + return { + "fields": { + "project": { + "key": settings.jira_project_id + }, + "summary": f"{scorecard_title} tasks to reach the {level} level " + f"for the {blueprint}: {entity.get('identifier', '')}", + "description": { + "version": 1, + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 2 + }, + "content": [ + { + "type": "text", + "text": f"⭐️ {scorecard_title} tasks for the {blueprint}: {entity_title} " + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "This task contains all sub-tasks needed to be completed for " + }, + { + "type": "text", + "text": entity_title, + "marks": [ + { + "type": "link", + "attrs": { + "href": f'https://app.getport.io/appEntity?identifier={entity.get("identifier")}' + } + } + ] + }, + { + "type": "text", + "text": " to reach the " + }, + { + "type": "text", + "text": level, + "marks": [ + { + "type": "strong" + } + ] + }, + { + "type": "text", + "text": f" level in the {scorecard_title} scorecard." + } + ] + }, + { + "type": "paragraph", + "content": [] + }, + { + "type": "panel", + "attrs": { + "panelType": "note" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Scorecards", + "marks": [ + { + "type": "strong" + } + ] + }, + { + "type": "text", + "text": " are a way for you and your team to define and track standards, metrics, and KPIs in different categories such as production readiness, quality, productivity, and more." + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "For more information about your scorecards, go to " + }, + { + "type": "text", + "text": "Port", + "marks": [ + { + "type": "link", + "attrs": { + "href": "http://app.getport.io" + } + } + ] + }, + { + "type": "text", + "text": "." + } + ] + } + ] + } + ] + }, + "issuetype": { + "name": "Task" + } + } + } + + def generate_subtask(self, rule: Dict[str, Any], scorecard_title: str, entity: Dict[str, Any], parent_key: str): + rule_title = rule.get("title", "") + query = rule.get("query", "") + conditions_for_display = JiraIssueGenerator._generate_conditions(query.get("conditions", []), + query.get("combinator", "")) + return { + "fields": { + "parent": {"key": parent_key}, + "project": { + "key": settings.jira_project_id + }, + "summary": f"{rule_title} ({rule.get('identifier', '')})", + "issuetype": { + "name": "Subtask" + }, + "description": { + "version": 1, + "type": "doc", + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": f"This {scorecard_title} {rule_title} rule is currently not passed for " + }, + { + "type": "text", + "text": entity.get("title"), + "marks": [ + { + "type": "link", + "attrs": { + "href": f'https://app.getport.io/appEntity?' + f'identifier={entity.get("identifier")}' + } + } + ] + }, + { + "type": "text", + "text": ". To pass it," + " you need to meet the following rule conditions:" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": " " + } + ] + }, + { + "type": "expand", + "attrs": { + "title": f"{rule_title} conditions" + }, + "content": conditions_for_display + }, + { + "type": "paragraph", + "content": [] + }, + { + "type": "panel", + "attrs": { + "panelType": "note" + }, + "content": [ + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Scorecards", + "marks": [ + { + "type": "strong" + } + ] + }, + { + "type": "text", + "text": " are a way for you and your team to define and " + "track standards, metrics, and KPIs in different" + " categories such as production readiness, " + "quality, productivity, and more." + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "For more information about your scorecards, go to " + }, + { + "type": "text", + "text": "Port", + "marks": [ + { + "type": "link", + "attrs": { + "href": "http://app.getport.io" + } + } + ] + }, + { + "type": "text", + "text": "." + } + ] + } + ] + } + ] + } + } + } + + @staticmethod + def _generate_conditions(conditions: List[Dict[str, Any]], combinator: str): + conditions_for_display = [] + for index, condition in enumerate(conditions): + port_property = condition.get("property", "") + operator = condition.get("operator", "") + rule_prefix = "When" if index == 0 else combinator + expression = "" + if operator in scorecards_singular_operators: + expression = f"Is {port_property} {operator}" + else: + expression = f"{port_property} {operator} {condition.get('value')}" + + condition_paragraph = { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": f"{rule_prefix} ", + "marks": [ + { + "type": "code" + } + ] + }, + { + "type": "text", + "text": expression + } + ] + } + + conditions_for_display.append(condition_paragraph) + + return conditions_for_display diff --git a/generators/slack.py b/generators/slack.py index 90fecdc..29aaaeb 100644 --- a/generators/slack.py +++ b/generators/slack.py @@ -1,8 +1,7 @@ -from typing import Dict, List, Any +from typing import Any, Dict, List import generators.base import utils - from config import settings from port.utils import get_port_url @@ -85,20 +84,20 @@ def scorecard_report(self, blueprint: str, scorecard: Dict[str, Any], entities: ] if entities_by_level_text: blocks += [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"*:vertical_traffic_light: {blueprint_plural} by level*" - } - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": entities_by_level_text - } - }] + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*:vertical_traffic_light: {blueprint_plural} by level*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": entities_by_level_text + } + }] else: blocks += [ { @@ -118,37 +117,37 @@ def scorecard_report(self, blueprint: str, scorecard: Dict[str, Any], entities: ] if top_teams_text: blocks += [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*:chart_with_upwards_trend: Top teams*" - } - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": top_teams_text - } - }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*:chart_with_upwards_trend: Top teams*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": top_teams_text + } + }, ] if top_highest_scored_rules_text: blocks += [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*:white_check_mark: Highest scoring rules*" - } - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": top_highest_scored_rules_text + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*:white_check_mark: Highest scoring rules*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": top_highest_scored_rules_text + } } - } ] if top_lowest_scored_rules_text: blocks += [ @@ -180,9 +179,9 @@ def scorecard_report(self, blueprint: str, scorecard: Dict[str, Any], entities: return blocks def scorecard_reminder(self, - blueprint: str, - scorecard: Dict[str, Any], - entities: list) -> List[Dict[str, Any]]: + blueprint: str, + scorecard: Dict[str, Any], + entities: list) -> List[Dict[str, Any]]: blueprint_plural = utils.convert_to_plural(blueprint).title() entities_didnt_pass_gold_level = { "Silver": [], diff --git a/main.py b/main.py index 3e77097..96b2367 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,17 @@ -from config import settings -from core.handler import Handler +from typing import Dict, Type -if __name__ == '__main__': - message_kind = settings.message_kind - message_kind_handler = getattr(Handler, message_kind) - message_kind_handler() +from config import settings +from core.base_handler import BaseHandler +from core.jira_handler import JiraHandler +from core.slack_handler import SlackHandler +HANDLERS: Dict[str, Type[BaseHandler]] = { + "jira": JiraHandler, + "slack": SlackHandler +} +if __name__ == '__main__': + operation_kind = settings.operation_kind + handler = HANDLERS.get(settings.target_kind, SlackHandler)() + operation_kind_handler = getattr(handler, settings.operation_kind) + operation_kind_handler() diff --git a/port/client.py b/port/client.py index 3b40429..0ea8061 100644 --- a/port/client.py +++ b/port/client.py @@ -8,7 +8,7 @@ class PortClient: - def __init__(self, region: str, client_id:str, client_secret:str): + def __init__(self, region: str, client_id: str, client_secret: str): self.api_url = get_port_url(region, "api") self.access_token = self.get_token(client_id, client_secret) self.headers = { @@ -41,4 +41,3 @@ def get_scorecard(self, blueprint_id: str, scorecard_id: str): ) scorecard_req.raise_for_status() return scorecard_req.json() - diff --git a/port/utils.py b/port/utils.py index 114c9e9..5243172 100644 --- a/port/utils.py +++ b/port/utils.py @@ -1,4 +1,3 @@ - def get_port_url(region: str, kind: str = "app"): if region and region != "eu": return f"https://{kind}.{region}.getport.io" diff --git a/requirements.txt b/requirements.txt index 6a2849d..23f0b24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ slack_sdk~=3.23.0 requests~=2.29.0 inflect~=7.0.0 pydantic-settings~=2.0.3 +httpx~=0.25.1 \ No newline at end of file diff --git a/scripts/format.sh b/scripts/format.sh new file mode 100755 index 0000000..3c791da --- /dev/null +++ b/scripts/format.sh @@ -0,0 +1,6 @@ +#!/bin/sh -e +set -x + +autoflake --remove-all-unused-imports --recursive --remove-unused-variables --in-place core port targets --exclude=__init__.py +black core port targets +isort --profile black core port targets \ No newline at end of file diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100755 index 0000000..aa9e640 --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -x + +mypy core port targets +black core port targets --check +isort --profile black --check-only core port targets +flake8 \ No newline at end of file diff --git a/targets/__init__.py b/targets/__init__.py index af15e78..e69de29 100644 --- a/targets/__init__.py +++ b/targets/__init__.py @@ -1 +0,0 @@ -from .slack import Slack diff --git a/targets/jira.py b/targets/jira.py new file mode 100644 index 0000000..80feb73 --- /dev/null +++ b/targets/jira.py @@ -0,0 +1,118 @@ +import base64 +import logging +from typing import Any +from urllib.parse import quote + +import requests +from requests.auth import HTTPBasicAuth + +from config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class Jira: + def __init__(self) -> None: + self.auth = HTTPBasicAuth(settings.jira_email, settings.jira_token) + self.api_url = f"{settings.jira_api_endpoint}/rest/api/3" + + auth_message = f"{settings.jira_email}:{settings.jira_token}" + auth_bytes = auth_message.encode("ascii") + b64_bytes = base64.b64encode(auth_bytes) + b64_message = b64_bytes.decode("ascii") + self.auth_value = f"Basic {b64_message}" + self.headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": self.auth_value, + } + + def create_issue(self, params: dict[str, Any]) -> dict[str, Any]: + logger.info(f"Creating new issue: {params['fields']['summary']}") + + create_issue_response = requests.request( + "POST", f"{self.api_url}/issue", json=params, headers=self.headers + ) + + create_issue_response.raise_for_status() + + return create_issue_response.json() + + def search_issue(self, jql_query: str) -> bool: + + issue_response = requests.request( + "GET", + f"{self.api_url}/search?jql={quote(jql_query, safe='')}", + headers=self.headers, + ) + + issue_response.raise_for_status() + return issue_response.json() + + def resolve_issue(self, issue: dict[str, Any]): + issue_fields = issue["fields"] + key = issue["key"] + logger.info(f"Resolving {issue_fields['issuetype']['name']}:" + f" {key} - " + f"{issue_fields['summary']}") + + if not settings.jira_resolve_transition_id: + # Looking for a default resolve transition id + logger.info("Jira transition id parameter was not inserted," + " getting the default from the Jira project") + + transitions_response = requests.request( + "GET", + f"{self.api_url}/issue/{key}/transitions", + headers=self.headers + ).json() + resolved_transition = next((t["id"] for t in transitions_response["transitions"] + if t['to']['name'] == 'Done'), None) + else: + resolved_transition = settings.jira_resolve_transition_id + + if not resolved_transition: + logger.info("Jira transition to done was not found," + " please enter the jira_resolve_transition_id parameter") + return + + return self.transition_issue(key, resolved_transition) + + def reopen_issue(self, issue: dict[str, Any]): + issue_fields = issue["fields"] + key = issue["key"] + logger.info(f"Reopening {issue_fields['issuetype']['name']}:" + f" {key} - " + f"{issue_fields['summary']}") + + if not settings.jira_reopen_transition_id: + + transitions_response = requests.request( + "GET", + f"{self.api_url}/issue/{key}/transitions", + headers=self.headers + ).json() + reopen_transition = next((t["id"] for t in transitions_response["transitions"] + if t['to']['name'] == 'To Do'), None) + else: + reopen_transition = settings.jira_reopen_transition_id + + if not reopen_transition: + logger.info("Jira transition to To Do was not found," + " please enter the jira_resolve_transition_id parameter") + return + + return self.transition_issue(key, reopen_transition) + + def transition_issue(self, issue_key: str, transition_id: str): + body = {"transition": {"id": transition_id}} + issue_response = requests.request( + "POST", + f"{self.api_url}/issue/{issue_key}/transitions", + headers=self.headers, + json=body, + ) + + issue_response.raise_for_status() + return issue_response diff --git a/targets/slack.py b/targets/slack.py index 4233ba4..5beebf8 100644 --- a/targets/slack.py +++ b/targets/slack.py @@ -1,5 +1,6 @@ import logging -from typing import Dict, List, Any +from typing import Any, Dict, List + from slack_sdk.webhook import WebhookClient from config import settings @@ -13,11 +14,10 @@ def __init__(self): self.webhook = WebhookClient(settings.slack_webhook_url) def send_message(self, blocks: List[Dict[str, Any]]): - logger.info(f"Sending message to slack channel") + logger.info("Sending message to slack channel") response = self.webhook.send(blocks=blocks) if response.status_code > 200: raise Exception(f"Failed to send Message to slack channel: {response.status_code}, {response.body}, " f"slack channel: {settings.slack_webhook_url}, blocks: {blocks}") logger.info(f"Message sent to slack channel: {response.status_code}, {response.body}") return response.status_code -