diff --git a/.github/workflows/sync-github-issues.yaml b/.github/workflows/sync-github-issues.yaml new file mode 100644 index 0000000..4f5bf07 --- /dev/null +++ b/.github/workflows/sync-github-issues.yaml @@ -0,0 +1,25 @@ +name: Sync Github issues based on scorecards + + +on: + workflow_dispatch: + +jobs: + sync-github-issues: + permissions: + issues: write + runs-on: ubuntu-latest + steps: + - name: Sync Github Issues + uses: port-labs/port-sender@v0.2.4 + with: + operation_kind: issue_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"]}' + github_api_url: ${{ github.api_url }} # https://docs.github.com/en/actions/learn-github-actions/variables#using-contexts-to-access-variable-values + github_token: ${{ github.token }} # https://docs.github.com/en/actions/learn-github-actions/variables#using-contexts-to-access-variable-values + github_repository: ${{ github.repository }} # Example: octocat/Hello-World, https://docs.github.com/en/actions/learn-github-actions/variables#using-contexts-to-access-variable-values + target_kind: github \ No newline at end of file diff --git a/README.md b/README.md index eb3c62b..e4bdb5c 100644 --- a/README.md +++ b/README.md @@ -204,3 +204,73 @@ jobs: ``` You can find more examples in the [examples folder](docs/examples/) + + +## Manage scorecards with Github issues + +A call to action to sync Github issues (create/reopen/close) with scorecards and rules. + +For every scorecard level that is not completed in an entity, a Github Issue will be created and a task list will be created for the level rules (both complete and incomplete). + +### Output example + +Generated Scorecard issue for the bronze level: + ![Jira Task](docs/assets/github-sync-issue.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 | +| `blueprint` | Blueprint identifier | true | | +| `scorecard` | Scorecard identifier | true | | +| `opeation_kind` | What operation should the sender do, leave at - `issue_handler` | true | | +| `filter_rule` | The [rule filter](https://docs.getport.io/search-and-query/#rules) to apply on the data queried from Port | false | | +| `github_api_url` | Github API URL. We recommend using [Github's context variables](https://docs.github.com/en/actions/learn-github-actions/variables#using-contexts-to-access-variable-values) URL | true | | +| `github_repository` | The Github Repository. For example: octo-org/octo-repo. We recommend using [Github's context variables](https://docs.github.com/en/actions/learn-github-actions/variables#using-contexts-to-access-variable-values) | true | | +| `github_token` | The Github's Token used for create/get/update operations on issues. We recommend using [Github's context variables](https://docs.github.com/en/actions/learn-github-actions/variables#using-contexts-to-access-variable-values) | true | | + +This example will create a Github issue for every service in every level that are not completed in the `productionReadiness` scorecard for the Backend Team. +For every rule in the scorecard, both complete or incomplete, a task will be added to the issue's task list (will be marked as done if rule is fulfilled, open otherwise). +Once the scorecard is completed, the issues and tasks in the task list will be resolved (the issue state will change to `closed`). + +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 Github Issues with Scorecard Initiatives + +on: + schedule: + ## run every day at 9am + - cron: '0 9 * * *' + workflow_dispatch: + +jobs: + sync-github-issues: + permissions: + issues: write + runs-on: ubuntu-latest + steps: + - name: Sync Github Issues + uses: port-labs/port-sender@v0.2.5 + with: + operation_kind: issue_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"]}' + github_api_url: ${{ github.api_url }} + github_token: ${{ secrets.MY_SECRET }} + github_repository: ${{ github.repository }} + target_kind: github + +``` + +You can find more examples in the [examples folder](docs/examples/) \ No newline at end of file diff --git a/action.yml b/action.yml index 0e2cf4f..826fa53 100644 --- a/action.yml +++ b/action.yml @@ -50,6 +50,15 @@ inputs: jira_reopen_transition_id: description: 'The Jira transition ID used for reopening issues. If not inserted will use the default transition for the "To Do" status.' required: false + github_api_url: + description: 'The Github API url. For example: https://api.github.com' + required: false + github_token: + description: 'The Github Token' + required: false + github_repository: + description: 'The Github Repository. For example: octo-org/octo-repo' + required: false runs: using: docker diff --git a/config.py b/config.py index 9b40497..711f84e 100644 --- a/config.py +++ b/config.py @@ -9,11 +9,13 @@ class OperationKind(str, Enum): scorecard_reminder = "scorecard_reminder" scorecard_report = "scorecard_report" ticket_creator = "ticket_handler" + issue_handler = "issue_handler" class TargetKind(str, Enum): slack = "slack" jira = "jira" + github = "github" class FilterRule(BaseModel): @@ -25,6 +27,9 @@ class FilterRule(BaseModel): class Settings(BaseSettings): port_client_id: str port_client_secret: str + github_api_url: str = "" + github_token: str = "" + github_repository: str = "" slack_webhook_url: str = "" jira_project_id: str = "" jira_api_endpoint: str = "https://jira.com" diff --git a/core/github_handler.py b/core/github_handler.py new file mode 100644 index 0000000..d312d72 --- /dev/null +++ b/core/github_handler.py @@ -0,0 +1,101 @@ +import logging + +from config import settings +from core.base_handler import BaseHandler +from generators.github import GithubIssueGenerator +from targets.github import Github + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class GithubHandler(BaseHandler): + def issue_handler(self): + logger.info("Started syncing scorecard statuses as issues for Github") + if not self.entities: + logger.info("No entities found, looking for left over open issues") + issue_search_result = Github().search_issue_by_labels( + ["Port", self.scorecard.get("title", ""), settings.blueprint], + settings.github_repository, + state="open", + ) + if len(issue_search_result) > 0: + logger.info("No open issues found") + else: + logger.info(f"Found {len(issue_search_result)} open issues") + for issue in issue_search_result: + Github().close_issue( + issue["number"], issue, settings.github_repository + ) + return + + logger.info("Searching for Github issues to create / update") + + for entity in self.entities: + entity_scorecard = entity.get("scorecards", {}).get( + self.scorecard.get("identifier"), {} + ) + rules_by_level = {} + + # Grouping rules by levels + for rule in entity_scorecard.get("rules", []): + level = rule.get("level") + if level not in rules_by_level.keys(): + rules_by_level[level] = [] + rules_by_level[level].append(rule) + + for level in rules_by_level: + scorecard_level_completed = all( + rule.get("status", "") == "SUCCESS" + for rule in rules_by_level[level] + ) + tasks = [] + # Iterating rules of scorecard level + 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] + task = GithubIssueGenerator().generate_task(full_rule_object) + rule_successful = rule.get("status", "") == "SUCCESS" + if rule_successful: + tasks.append(f"- [x] {task}") + elif not rule_successful: + tasks.append(f"- [ ] {task}") + + generated_issue = GithubIssueGenerator().generate_issue( + self.scorecard, entity, settings.blueprint, level, tasks + ) + issue_search_result = Github().search_issue_by_labels( + generated_issue["labels"], settings.github_repository + ) + issue_search_result + issue_exists = len(issue_search_result) > 0 + + if not issue_exists: + if not scorecard_level_completed: + Github().create_issue( + generated_issue, settings.github_repository + ) + else: + issue = issue_search_result[0] + issue_number = issue["number"] + if issue["state"] == "closed" and not scorecard_level_completed: + Github().reopen_issue( + issue_number, generated_issue, settings.github_repository + ) + else: + Github().update_issue( + issue_number, generated_issue, settings.github_repository + ) + + if ( + scorecard_level_completed + and issue_exists + and not issue["state"] == "closed" + ): + Github().close_issue( + issue["number"], generated_issue, settings.github_repository + ) + logger.info("Done syncing scorecard statuses as issues for Github") diff --git a/docs/assets/github-sync-issue.png b/docs/assets/github-sync-issue.png new file mode 100644 index 0000000..a812d29 Binary files /dev/null and b/docs/assets/github-sync-issue.png differ diff --git a/generators/base.py b/generators/base.py index d43f30f..e31a442 100644 --- a/generators/base.py +++ b/generators/base.py @@ -1,6 +1,14 @@ import abc from typing import Any, Dict +class BaseIssueGenerator(abc.ABC): + @abc.abstractmethod + def generate_issue(self, scorecard: Dict[str, Any], entity: Dict[str, Any], blueprint: str, level: str, tasks: list[str]): + pass + + @abc.abstractmethod + def generate_task(self, rule: Dict[str, Any]) -> str: + pass class BaseMessageGenerator(abc.ABC): @abc.abstractmethod diff --git a/generators/github.py b/generators/github.py new file mode 100644 index 0000000..9519b8c --- /dev/null +++ b/generators/github.py @@ -0,0 +1,31 @@ +from typing import Any, Dict +import generators.base +from port.utils import get_port_url +from config import settings + + +class GithubIssueGenerator(generators.base.BaseIssueGenerator): + def generate_issue( + self, + scorecard: Dict[str, Any], + entity: Dict[str, Any], + blueprint: str, + level: str, + tasks: list[str], + ): + scorecard_title = scorecard.get("title", "") + entity_title = entity.get("title", "") + return { + "title": f"{scorecard_title} tasks to reach the {level} level for the {blueprint}: {entity.get('identifier', '')}", + "body": f"⭐️ {scorecard_title} tasks for the {blueprint}: {entity_title} \n" + f"This issue contains all sub-tasks needed to be completed for [{entity_title}](https://app.getport.io/appEntity?identifier={entity.get('identifier')}) to reach the {level} level in the {scorecard_title} scorecard.\n" + f"\n> :bulb: **Tip:** Scorecards 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. For more information about your scorecards, go to [Port]({get_port_url(settings.port_region)})" + "\n# Sub-Tasks" + "\n" + "\n".join(tasks) + "\n", + "labels": ["Port", scorecard_title, level, blueprint, entity["identifier"]], + } + + def generate_task(self, rule: dict[str, Any]): + title = rule.get("title", "") + description = rule.get("description", "") + return f"{title} ({rule.get('identifier', '')})" + "\n" + f"> {description}" diff --git a/main.py b/main.py index 96b2367..eae1467 100644 --- a/main.py +++ b/main.py @@ -3,11 +3,13 @@ from config import settings from core.base_handler import BaseHandler from core.jira_handler import JiraHandler +from core.github_handler import GithubHandler from core.slack_handler import SlackHandler HANDLERS: Dict[str, Type[BaseHandler]] = { "jira": JiraHandler, - "slack": SlackHandler + "slack": SlackHandler, + "github": GithubHandler } if __name__ == '__main__': diff --git a/targets/github.py b/targets/github.py new file mode 100644 index 0000000..2dd1d20 --- /dev/null +++ b/targets/github.py @@ -0,0 +1,72 @@ +import logging +import json +from typing import Any +import time +import requests +from config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class Github: + def __init__(self) -> None: + self.api_url = f"{settings.github_api_url}" + self.auth_value = f"Bearer {settings.github_token}" + self.headers = { + "Accept": "application/vnd.github+json", + "Authorization": self.auth_value, + "X-GitHub-Api-Version": "2022-11-28", + } + + def create_issue(self, issue: dict[str, Any], repository: str) -> dict[str, Any]: + time.sleep(1) # To avoid rate limits by github's api + logger.info(f"Creating new issue at {repository}") + create_issue_response = requests.request( + "POST", + f"{self.api_url}/repos/{repository}/issues", + json=issue, + headers=self.headers, + ) + + create_issue_response.raise_for_status() + + return create_issue_response.json() + + def search_issue_by_labels( + self, labels: list[str], repository: str, state: str = "all" + ) -> bool: + logger.info(f"Searching issue with labels {labels}") + + issue_response = requests.request( + "GET", + f"{self.api_url}/repos/{repository}/issues", + headers=self.headers, + params={"labels": ",".join(labels), "state": state}, + ) + + issue_response.raise_for_status() + return issue_response.json() + + def close_issue(self, issue_number: int, issue: dict[str, Any], repository: str): + issue["state"] = "closed" + logger.info(f"Closing issue id {issue_number}") + return self.update_issue(issue_number, issue, repository) + + def reopen_issue(self, issue_number: int, issue: dict[str, Any], repository: str): + issue["state"] = "open" + logger.info(f"Reopening issue id {issue_number}") + return self.update_issue(issue_number, issue, repository) + + def update_issue( + self, issue_number: int, updated_issue: dict[str, Any], repository: str + ): + logger.info(f"Updating issue id {issue_number}") + issue_response = requests.request( + "PATCH", + f"{self.api_url}/repos/{repository}/issues/{issue_number}", + headers=self.headers, + data=json.dumps(updated_issue), + ) + issue_response.raise_for_status() + return issue_response.json()