From 53377d26e2767b3df7c788330c4d592fc12051ed Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Fri, 23 May 2025 16:55:22 +0200 Subject: [PATCH] ci: add pr issue sync --- .../scripts/pr_issue_sync/pr_issue_sync.py | 342 ++++++++++++++++++ .../scripts/pr_issue_sync/requirements.txt | 2 + .github/workflows/sync-issues-pr.yml | 40 ++ 3 files changed, 384 insertions(+) create mode 100644 .github/scripts/pr_issue_sync/pr_issue_sync.py create mode 100644 .github/scripts/pr_issue_sync/requirements.txt create mode 100644 .github/workflows/sync-issues-pr.yml diff --git a/.github/scripts/pr_issue_sync/pr_issue_sync.py b/.github/scripts/pr_issue_sync/pr_issue_sync.py new file mode 100644 index 00000000..3b90b262 --- /dev/null +++ b/.github/scripts/pr_issue_sync/pr_issue_sync.py @@ -0,0 +1,342 @@ +import functools +import os +from typing import Literal + +import requests +from github import Github +from pydantic import BaseModel + + +class GHConfig(BaseModel): + token: str + organization: str + repository: str + project_number: int + graphql_url: str + rest_url: str + headers: dict + + +class ProjectItemHandler: + """ + A class to handle GitHub project items. + """ + + def __init__(self, gh_config: GHConfig): + self.gh_config = gh_config + self.gh = Github(gh_config.token) + self.repo = self.gh.get_repo(f"{gh_config.organization}/{gh_config.repository}") + self.project_node_id = self.get_project_node_id() + + def set_issue_status( + self, + status: Literal[ + "Selected for Development", + "Weekly Backlog", + "In Development", + "Ready For Review", + "On Hold", + "Done", + ], + issue_number: int | None = None, + issue_node_id: str | None = None, + ): + """ + Set the status field of a GitHub issue in the project. + + Args: + status (str): The status to set. Must be one of the predefined statuses. + issue_number (int, optional): The issue number. If not provided, issue_node_id must be provided. + issue_node_id (str, optional): The issue node ID. If not provided, issue_number must be provided. + """ + if not issue_number and not issue_node_id: + raise ValueError("Either issue_number or issue_node_id must be provided.") + if issue_number and issue_node_id: + raise ValueError("Only one of issue_number or issue_node_id must be provided.") + if issue_number is not None: + issue = self.repo.get_issue(issue_number) + issue_id = self.get_issue_info(issue.node_id)[0]["id"] + else: + issue_id = issue_node_id + field_id, option_id = self.get_status_field_id(field_name=status) + self.set_field_option(issue_id, field_id, option_id) + + def run_graphql(self, query: str, variables: dict) -> dict: + """ + Execute a GraphQL query against the GitHub API. + + Args: + query (str): The GraphQL query to execute. + variables (dict): The variables to pass to the query. + + Returns: + dict: The response from the GitHub API. + """ + response = requests.post( + self.gh_config.graphql_url, + json={"query": query, "variables": variables}, + headers=self.gh_config.headers, + timeout=10, + ) + if response.status_code != 200: + raise Exception( + f"Query failed with status code {response.status_code}: {response.text}" + ) + return response.json() + + def get_project_node_id(self): + """ + Retrieve the project node ID from the GitHub API. + """ + query = """ + query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + id + } + } + } + """ + variables = {"owner": self.gh_config.organization, "number": self.gh_config.project_number} + resp = self.run_graphql(query, variables) + return resp["data"]["organization"]["projectV2"]["id"] + + def get_issue_info(self, issue_node_id: str): + """ + Get the project-related information for a given issue node ID. + + Args: + issue_node_id (str): The node ID of the issue. Please note that this is not the issue number and typically starts with "I". + + Returns: + list[dict]: A list of project items associated with the issue. + """ + query = """ + query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { + projectItems(first: 10) { + nodes { + project { + id + title + } + id + fieldValues(first: 20) { + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { + ... on ProjectV2SingleSelectField { + name + } + } + } + } + } + } + } + } + } + } + """ + variables = {"issueId": issue_node_id} + resp = self.run_graphql(query, variables) + return resp["data"]["node"]["projectItems"]["nodes"] + + def get_status_field_id( + self, + field_name: Literal[ + "Selected for Development", + "Weekly Backlog", + "In Development", + "Ready For Review", + "On Hold", + "Done", + ], + ) -> tuple[str, str]: + """ + Get the status field ID and option ID for the given field name in the project. + + Args: + field_name (str): The name of the field to retrieve. + Must be one of the predefined statuses. + + Returns: + tuple[str, str]: A tuple containing the field ID and option ID. + """ + field_id = None + option_id = None + project_fields = self.get_project_fields() + for field in project_fields: + if field["name"] != "Status": + continue + field_id = field["id"] + for option in field["options"]: + if option["name"] == field_name: + option_id = option["id"] + break + if not field_id or not option_id: + raise ValueError(f"Field '{field_name}' not found in project fields.") + + return field_id, option_id + + def set_field_option(self, item_id, field_id, option_id): + """ + Set the option of a project item for a single-select field. + + Args: + item_id (str): The ID of the project item to update. + field_id (str): The ID of the field to update. + option_id (str): The ID of the option to set. + """ + + mutation = """ + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + } + ) { + projectV2Item { + id + } + } + } + """ + variables = { + "projectId": self.project_node_id, + "itemId": item_id, + "fieldId": field_id, + "optionId": option_id, + } + return self.run_graphql(mutation, variables) + + @functools.lru_cache(maxsize=1) + def get_project_fields(self) -> list[dict]: + """ + Get the available fields in the project. + This method caches the result to avoid multiple API calls. + + Returns: + list[dict]: A list of fields in the project. + """ + + query = """ + query($projectId: ID!) { + node(id: $projectId) { + ... on ProjectV2 { + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { + id + name + options { + id + name + } + } + } + } + } + } + } + """ + variables = {"projectId": self.project_node_id} + resp = self.run_graphql(query, variables) + return list(filter(bool, resp["data"]["node"]["fields"]["nodes"])) + + def get_pull_request_linked_issues(self, pr_number: int) -> list[dict]: + """ + Get the linked issues of a pull request. + + Args: + pr_number (int): The pull request number. + + Returns: + list[dict]: A list of linked issues. + """ + query = """ + query($number: Int!, $owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + id + closingIssuesReferences(first: 50) { + edges { + node { + id + body + number + title + } + } + } + } + } + } + """ + variables = { + "number": pr_number, + "owner": self.gh_config.organization, + "repo": self.gh_config.repository, + } + resp = self.run_graphql(query, variables) + edges = resp["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["edges"] + return [edge["node"] for edge in edges if edge.get("node")] + + +def main(): + # GitHub settings + token = os.getenv("TOKEN") + org = os.getenv("ORG") + repo = os.getenv("REPO") + project_number = os.getenv("PROJECT_NUMBER") + pr_number = os.getenv("PR_NUMBER") + + if not token: + raise ValueError("GitHub token is not set. Please set the TOKEN environment variable.") + if not org: + raise ValueError("GitHub organization is not set. Please set the ORG environment variable.") + if not repo: + raise ValueError("GitHub repository is not set. Please set the REPO environment variable.") + if not project_number: + raise ValueError( + "GitHub project number is not set. Please set the PROJECT_NUMBER environment variable." + ) + if not pr_number: + raise ValueError( + "Pull request number is not set. Please set the PR_NUMBER environment variable." + ) + + project_number = int(project_number) + pr_number = int(pr_number) + + gh_config = GHConfig( + token=token, + organization=org, + repository=repo, + project_number=project_number, + graphql_url="https://api.github.com/graphql", + rest_url=f"https://api.github.com/repos/{org}/{repo}/issues", + headers={"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}, + ) + project_item_handler = ProjectItemHandler(gh_config=gh_config) + + # Get PR info + pr = project_item_handler.repo.get_pull(pr_number) + + # Get the linked issues of the pull request + linked_issues = project_item_handler.get_pull_request_linked_issues(pr_number=pr_number) + print(f"Linked issues: {linked_issues}") + + target_status = "In Development" if pr.draft else "Ready For Review" + print(f"Target status: {target_status}") + for issue in linked_issues: + project_item_handler.set_issue_status(issue_number=issue["number"], status=target_status) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/pr_issue_sync/requirements.txt b/.github/scripts/pr_issue_sync/requirements.txt new file mode 100644 index 00000000..9f191a9e --- /dev/null +++ b/.github/scripts/pr_issue_sync/requirements.txt @@ -0,0 +1,2 @@ +pydantic +pygithub \ No newline at end of file diff --git a/.github/workflows/sync-issues-pr.yml b/.github/workflows/sync-issues-pr.yml new file mode 100644 index 00000000..f40facc8 --- /dev/null +++ b/.github/workflows/sync-issues-pr.yml @@ -0,0 +1,40 @@ +name: Sync PR to Project + +on: + pull_request: + types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize] + +jobs: + sync-project: + runs-on: ubuntu-latest + + permissions: + issues: write + pull-requests: read + contents: read + + env: + PROJECT_NUMBER: 3 # BEC Project + ORG: 'bec-project' + REPO: 'bec_widgets' + TOKEN: ${{ secrets.ADD_ISSUE_TO_PROJECT }} + PR_NUMBER: ${{ github.event.pull_request.number }} + + steps: + - name: Set up python environment + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Checkout repo + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ref: ${{ github.event.pull_request.head.ref }} + + - name: Install dependencies + run: | + pip install -r ./.github/scripts/pr_issue_sync/requirements.txt + - name: Sync PR to Project + run: | + python ./.github/scripts/pr_issue_sync/pr_issue_sync.py \ No newline at end of file