Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/check_milestone.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Check Milestone

on:
pull_request:
types:
- opened
- synchronize
- reopened

jobs:
checkmilestone:
runs-on: ubuntu-latest
if: ${{ !startsWith(github.head_ref, 'latest-dependency-update-') }}
steps:
- uses: actions/checkout@v4
- name: Set up latest Python
uses: actions/setup-python@v5
with:
python-version-file: 'pyproject.toml'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install requests
python -m pip install .[dev]

- name: Check milestone
env:
GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
run: >
python -m scripts.check_milestone -p ${{ github.event.number }}
4 changes: 2 additions & 2 deletions .github/workflows/prepare_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ jobs:
python -m pip install .[test]

- name: Check for prerelease dependencies
run: python scripts/check_for_prereleases.py
run: python -m scripts.check_for_prereleases

- name: Generate release notes
env:
GH_ACCESS_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
run: >
python scripts/release_notes_generator.py
python -m scripts.release_notes_generator
-v ${{ inputs.version }}
-d ${{ inputs.date }}

Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/test_scripts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Test scripts

on:
pull_request:
types:
- opened
- synchronize
- reopened

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up latest Python
uses: actions/setup-python@v5
with:
python-version-file: 'pyproject.toml'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install invoke .[dev]
- name: Run script tests
run: pytest tests/scripts
4 changes: 2 additions & 2 deletions scripts/check_for_prereleases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from pathlib import Path

import tomllib
import tomli
from packaging.requirements import Requirement


Expand All @@ -22,7 +22,7 @@ def get_dev_dependencies(dependency_list):
toml_path = folder.joinpath('..', 'pyproject.toml')

with open(toml_path, 'rb') as f:
pyproject = tomllib.load(f)
pyproject = tomli.load(f)

dependencies = pyproject['project']['dependencies']
optional_dependencies = pyproject['project'].get('optional-dependencies', {})
Expand Down
83 changes: 83 additions & 0 deletions scripts/check_milestone.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Script for checking milestones are attached to issues linked to PRs."""

import argparse

from scripts.github_client import GithubClient, GithubGraphQLClient

CLOSING_ISSUES_QUERY = """
query($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 10) {
nodes {
number
}
}
}
}
}
"""
OWNER = 'sdv-dev'
REPO = 'sdv'


def _get_linked_issues(
github_client: GithubClient, graph_client: GithubGraphQLClient, pr_number: int
):
pr_number = pr_number
results = graph_client.query(
query=CLOSING_ISSUES_QUERY, owner=OWNER, repo=REPO, prNumber=pr_number
)
pr = results.json().get('data', {}).get('repository', {}).get('pullRequest', {})
issues = pr.get('closingIssuesReferences', {}).get('nodes', [])
issue_numbers = [issue['number'] for issue in issues]
linked_issues = []
for number in issue_numbers:
issue = github_client.get(github_org=OWNER, repo=REPO, endpoint=f'issues/{number}')
linked_issues.append(issue.json())

return linked_issues


def _post_comment(github_client: GithubClient, pr_number: int):
comment = (
'This Pull Request is not linked to an issue. To ensure our community is able to '
'accurately track resolved issues, please link any issue that will be closed by this PR!'
)
github_client.post(
github_org=OWNER,
repo=REPO,
endpoint=f'issues/{pr_number}/comments',
payload={'body': comment},
)


def check_for_milestone(pr_number: int):
"""Check that the pull request is linked to an issue and that the issue has a milestone.

Args:
pr_number (int): The string representation of the Pull Request number.
"""
github_client = GithubClient()
graphql_client = GithubGraphQLClient()
linked_issues = _get_linked_issues(github_client, graphql_client, pr_number)
if not linked_issues:
_post_comment(github_client, pr_number)

milestones = set()
for issue in linked_issues:
milestone = issue.get('milestone')
if not milestone:
raise Exception(f'No milestone attached to issue number {issue.get("number")}')

milestones.add(milestone.get('title'))

if len(milestones) > 1:
raise Exception('This PR resolves issues with multiple different milestones.')


if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('-p', '--pr-number', type=int, help='The number of the pull request')
args = parser.parse_args()
check_for_milestone(args.pr_number)
101 changes: 101 additions & 0 deletions scripts/github_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Clients for making requests to Github APIs."""

import os

import requests


class BaseClient:
"""Base GitHub client."""

def __init__(self):
token = os.getenv('GH_ACCESS_TOKEN')
self.headers = {'Authorization': f'Bearer {token}'}


class GithubGraphQLClient(BaseClient):
"""Client for GitHub GraphQL."""

def __init__(self):
super().__init__()
self.base_url = 'https://api.github.com/graphql'

def query(self, query: str, **kwargs):
"""Run a query on Github GraphQL.

Args:
query (str):
The query to run.
kwargs (dict):
Any key-word arguments needed for the query.

Returns:
requests.models.Response
"""
response = requests.post(
self.base_url, json={'query': query, 'variables': kwargs}, headers=self.headers
)
return response


class GithubClient(BaseClient):
"""Client for GitHub API."""

def __init__(self):
super().__init__()
self.base_url = 'https://api.github.com/repos'

def _construct_url(self, github_org: str, repo: str, resource: str, id: str | None = None):
url = f'{self.base_url}/{github_org}/{repo}/{resource}'
if id:
url += f'/{id}'
return url

def get(
self,
github_org: str,
repo: str,
endpoint: str,
query_params: dict | None = None,
timeout: int | None = None,
):
"""Get a specific value of a resource from an endpoint in the GitHub API.

Args:
github_org (str):
The name of the GitHub organization to search.
repo (str):
The name of the repository to search.
endpoint (str):
The endpoint for the resource. For example, issues/{issue_number}. This means we'd
be making a request to https://api.github.com/repos/{github_org}/{repo}/issues/{issue_number}.
query_params (dict):
A dictionary mapping any query parameters to the desired value. Defaults to None.
timeout (int):
How long to wait before the request times out. Defaults to None.

Returns:
requests.models.Response
"""
url = self._construct_url(github_org, repo, endpoint)
return requests.get(url, headers=self.headers, params=query_params, timeout=timeout)

def post(self, github_org: str, repo: str, endpoint: str, payload: dict):
"""Post to an endpooint in the GitHub API.

Args:
github_org (str):
The name of the GitHub organization to search.
repo (str):
The name of the repository to search.
endpoint (str):
The endpoint for the resource. For example, issues. This means we'd be
making a request to https://api.github.com/repos/{github_org}/{repo}/issues.
payload (dict):
The payload to post.

Returns:
requests.models.Response
"""
url = self._construct_url(github_org, repo, endpoint)
return requests.post(url, headers=self.headers, json=payload)
28 changes: 16 additions & 12 deletions scripts/release_notes_generator.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"""Script to generate release notes."""

import argparse
import os
from collections import defaultdict

import requests
from scripts.github_client import GithubClient

LABEL_TO_HEADER = {
'feature request': 'New Features',
Expand Down Expand Up @@ -32,15 +31,15 @@
'maintenance',
]
NEW_LINE = '\n'
GITHUB_URL = 'https://api.github.com/repos/sdv-dev/sdv'
GITHUB_TOKEN = os.getenv('GH_ACCESS_TOKEN')
GITHUB_ORG = 'sdv-dev'
REPO = 'sdv'


def _get_milestone_number(milestone_title):
url = f'{GITHUB_URL}/milestones'
headers = {'Authorization': f'Bearer {GITHUB_TOKEN}'}
def _get_milestone_number(client, milestone_title):
query_params = {'milestone': milestone_title, 'state': 'all', 'per_page': 100}
response = requests.get(url, headers=headers, params=query_params, timeout=10)
response = client.get(
github_org=GITHUB_ORG, repo=REPO, endpoint='milestones', query_params=query_params
)
body = response.json()
if response.status_code != 200:
raise Exception(str(body))
Expand All @@ -54,16 +53,21 @@ def _get_milestone_number(milestone_title):


def _get_issues_by_milestone(milestone):
headers = {'Authorization': f'Bearer {GITHUB_TOKEN}'}
# get milestone number
milestone_number = _get_milestone_number(milestone)
url = f'{GITHUB_URL}/issues'
client = GithubClient()
milestone_number = _get_milestone_number(client, milestone)
page = 1
query_params = {'milestone': milestone_number, 'state': 'all'}
issues = []
while True:
query_params['page'] = page
response = requests.get(url, headers=headers, params=query_params, timeout=10)
response = client.get(
github_org=GITHUB_ORG,
repo=REPO,
endpoint='issues',
query_params=query_params,
timeout=10,
)
body = response.json()
if response.status_code != 200:
raise Exception(str(body))
Expand Down
Empty file added tests/scripts/__init__.py
Empty file.
File renamed without changes.
Loading
Loading