-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #11 from port-labs/add-github-action
Add GitHub action
- Loading branch information
Showing
10 changed files
with
324 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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/) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |