mirror of
https://github.com/bec-project/ophyd_devices.git
synced 2026-01-30 20:54:55 +01:00
ci: use shared composit action for issue sync
This commit is contained in:
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
342
.github/scripts/pr_issue_sync/pr_issue_sync.py
vendored
@@ -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()
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pydantic
|
|
||||||
pygithub
|
|
||||||
43
.github/workflows/sync-issues-pr.yml
vendored
43
.github/workflows/sync-issues-pr.yml
vendored
@@ -2,7 +2,18 @@ name: Sync PR to Project
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
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:
|
jobs:
|
||||||
sync-project:
|
sync-project:
|
||||||
@@ -13,28 +24,12 @@ jobs:
|
|||||||
pull-requests: read
|
pull-requests: read
|
||||||
contents: 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:
|
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
|
- name: Sync PR to Project
|
||||||
run: |
|
uses: bec-project/action-issue-sync-pr@v1
|
||||||
python ./.github/scripts/pr_issue_sync/pr_issue_sync.py
|
with:
|
||||||
|
token: ${{ secrets.ADD_ISSUE_TO_PROJECT }}
|
||||||
|
org: "bec-project"
|
||||||
|
repo: "ophyd_devices"
|
||||||
|
project-number: 3
|
||||||
|
pr-number: ${{ github.event.pull_request.number }}
|
||||||
|
|||||||
Reference in New Issue
Block a user