From 5480596c60e07795f90da2c70ecae9ff7e51ae90 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Thu, 8 Jan 2026 13:37:32 +0100 Subject: [PATCH] ci: use shared composit action for issue sync --- .../scripts/pr_issue_sync/pr_issue_sync.py | 342 ------------------ .../scripts/pr_issue_sync/requirements.txt | 2 - .github/workflows/sync-issues-pr.yml | 43 +-- 3 files changed, 19 insertions(+), 368 deletions(-) delete mode 100644 .github/scripts/pr_issue_sync/pr_issue_sync.py delete mode 100644 .github/scripts/pr_issue_sync/requirements.txt diff --git a/.github/scripts/pr_issue_sync/pr_issue_sync.py b/.github/scripts/pr_issue_sync/pr_issue_sync.py deleted file mode 100644 index 3b90b26..0000000 --- a/.github/scripts/pr_issue_sync/pr_issue_sync.py +++ /dev/null @@ -1,342 +0,0 @@ -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 deleted file mode 100644 index 9f191a9..0000000 --- a/.github/scripts/pr_issue_sync/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pydantic -pygithub \ No newline at end of file diff --git a/.github/workflows/sync-issues-pr.yml b/.github/workflows/sync-issues-pr.yml index a35cb9a..f16a82c 100644 --- a/.github/workflows/sync-issues-pr.yml +++ b/.github/workflows/sync-issues-pr.yml @@ -2,7 +2,18 @@ name: Sync PR to Project on: pull_request: - types: [opened, edited, ready_for_review, converted_to_draft, reopened, synchronize] + types: + [ + opened, + assigned, + unassigned, + edited, + ready_for_review, + converted_to_draft, + reopened, + synchronize, + closed, + ] jobs: sync-project: @@ -13,28 +24,12 @@ jobs: pull-requests: read contents: read - env: - PROJECT_NUMBER: 3 # BEC Project - ORG: 'bec-project' - REPO: 'ophyd_devices' - 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 + uses: bec-project/action-issue-sync-pr@v1 + with: + token: ${{ secrets.ADD_ISSUE_TO_PROJECT }} + org: "bec-project" + repo: "ophyd_devices" + project-number: 3 + pr-number: ${{ github.event.pull_request.number }}