Skip to content

Commit

Permalink
Merge pull request #11 from port-labs/add-github-action
Browse files Browse the repository at this point in the history
Add GitHub action
  • Loading branch information
matan84 authored May 19, 2024
2 parents 2adec96 + 5e31ba3 commit 26e74ef
Show file tree
Hide file tree
Showing 10 changed files with 324 additions and 1 deletion.
25 changes: 25 additions & 0 deletions .github/workflows/sync-github-issues.yaml
Original file line number Diff line number Diff line change
@@ -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/[email protected]
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
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]
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/)
9 changes: 9 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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"
Expand Down
101 changes: 101 additions & 0 deletions core/github_handler.py
Original file line number Diff line number Diff line change
@@ -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")
Binary file added docs/assets/github-sync-issue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions generators/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
31 changes: 31 additions & 0 deletions generators/github.py
Original file line number Diff line number Diff line change
@@ -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}"
4 changes: 3 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__':
Expand Down
72 changes: 72 additions & 0 deletions targets/github.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 26e74ef

Please sign in to comment.