Skip to content
Open
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
127 changes: 126 additions & 1 deletion did/plugins/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,21 @@
pull requests authored by the user that were merged (merged_at
timestamp falls within the reporting period)

releases-created
releases published by the user

Note that ``releases-created`` requires one or more repositories to be
specified using the ``repo`` config option, as the GitHub search API does
not support querying releases::

repo = psss/did, teemtee/tmt

""" # noqa: W505,E501 # pylint:disable=line-too-long

import json
import re
import time
from datetime import datetime
from datetime import datetime, timedelta

import requests
from tenacity import (RetryError, Retrying, retry_if_exception_type,
Expand Down Expand Up @@ -114,6 +123,10 @@ def __init__(self, *, url, token=None, user=None,
else:
self.headers = {}

# Keep the explicit repositories around for endpoints which do not
# support the search API (e.g. releases).
self.repos = re.split(r"\s*,\s*", repo) if repo else []

# Prepare the org, user, repo filter
def condition(key: str, names: str) -> list[str]:
""" Prepare one or more conditions for given key & names """
Expand Down Expand Up @@ -248,6 +261,56 @@ def search(self, query):
log.data(pretty(result))
return result

def release_list(self, repo, login, since, until):
""" Fetch releases published in the given repo and date range

The GitHub search API does not support releases, so they have to be
fetched per repository and filtered locally by the published date and
the release author.
"""
result = []
url = f"{self.url}/repos/{repo}/releases?per_page={PER_PAGE}"
# Include the whole 'until' day (the stored datetime is its midnight)
until_end = until.datetime + timedelta(days=1)

while True:
log.debug("GitHub query: %s", url)
response = self.request(url)
if not response.ok:
raise ReportError(
f"Failed to fetch GitHub releases at '{url}'. "
f"The reason was '{response.reason}'.")
log.data(pretty(response.text))
try:
releases = json.loads(response.text)
except requests.exceptions.JSONDecodeError as error:
log.debug(error)
raise ReportError(f"GitHub JSON failed: {response.text}.") from error

for release in releases:
# Skip drafts, they have no published date
published = release.get("published_at")
if not published:
continue
published_at = datetime.strptime(published, r"%Y-%m-%dT%H:%M:%SZ")
if not since.datetime <= published_at < until_end:
continue
author = (release.get("author") or {}).get("login")
if login and author != login:
continue
release["repo"] = repo
result.append(release)

# Update url to the next page, break if no next page provided
if 'next' in response.links:
url = response.links['next']['url']
else:
break

log.debug("Result: %s fetched", listed(len(result), "release"))
log.data(pretty(result))
return result


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Issue
Expand Down Expand Up @@ -302,6 +365,40 @@ def __hash__(self):
return hash((self.owner, self.project, self.id))


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Release
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

class Release():
""" GitHub Release """

def __init__(self, data, parent):
self.data = data
self.repo = data["repo"]
# Fall back to the tag name when the release has no title
self.title = data.get("name") or data.get("tag_name")
self.tag = data.get("tag_name")
self.options = parent.options

def __str__(self):
""" String representation """
label = f"{self.repo} {self.tag}"
title = self.title.strip() if self.title else ""
if self.options.format == "markdown":
return f'[{label}]({self.data["html_url"]}) - {title}'
return f'{label} - {title}'

def __eq__(self, other):
""" Equality comparison """
if isinstance(other, Release):
return self.repo == other.repo and self.tag == other.tag
return False

def __hash__(self):
""" Hash function """
return hash((self.repo, self.tag))


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Stats
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -429,6 +526,31 @@ def fetch(self):
Issue(issue, self.parent) for issue in self.parent.github.search(query)]


class ReleasesCreated(Stats):
""" Releases created """

def fetch(self):
login = self.user.login
since = self.options.since
until = self.options.until
repos = self.parent.github.repos
if not repos:
log.warning(
"Skipping releases for %s, no 'repo' configured "
"(the GitHub search API does not support releases).",
self.parent.option)
self.stats = []
return
releases = []
for repo in repos:
log.info("Searching for releases published by %s in %s",
self.user, repo)
releases.extend(
Release(release, self.parent) for release in
self.parent.github.release_list(repo, login, since, until))
self.stats = releases


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Stats Group
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down Expand Up @@ -487,4 +609,7 @@ def __init__(self, option, name=None, parent=None, user=None):
PullRequestsMerged(
option=f"{option}-pull-requests-merged", parent=self,
name=f"Pull requests merged on {option}"),
ReleasesCreated(
option=f"{option}-releases-created", parent=self,
name=f"Releases created on {option}"),
]
21 changes: 21 additions & 0 deletions tests/unit/plugins/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,27 @@ def test_github_pull_requests_commented():
in str(stat) for stat in stats)


def test_github_releases_created():
""" Created releases """
did.base.Config(f"{CONFIG}\nrepo = psss/did")
option = "--gh-releases-created --since 2023-03-10 --until 2023-03-10"
# Note: The stats list index is 8 for ReleasesCreated
stats = did.cli.main(option)[0][0].stats[0].stats[8].stats
assert any(
"psss/did 0.20 - New koji & phabricator plugins, custom separator"
in str(stat) for stat in stats)


def test_github_releases_created_no_repo(caplog: LogCaptureFixture):
""" Releases require a repo to be configured """
did.base.Config(CONFIG)
option = "--gh-releases-created --since 2023-03-10 --until 2023-03-10"
with caplog.at_level(logging.WARNING):
stats = did.cli.main(option)[0][0].stats[0].stats[8].stats
assert not stats
assert "no 'repo' configured" in caplog.text


def test_github_invalid_token(caplog: LogCaptureFixture):
""" Invalid token """
did.base.Config(f"{CONFIG}\ntoken = bad-token")
Expand Down