From 423865aa16146c48b26091480028f10eb061f28a Mon Sep 17 00:00:00 2001 From: "Simon L." Date: Wed, 24 Jun 2026 21:18:02 +0200 Subject: [PATCH] Add github releases-created stat (#310) The GitHub search API does not support releases, so they are fetched per repository via the /repos/{owner}/{repo}/releases endpoint and filtered locally by published date and author. This requires one or more repositories to be configured via the `repo` option. Adds a Release class, a GitHub.release_list helper, the ReleasesCreated stat (--gh-releases-created) and unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Simon L. --- did/plugins/github.py | 127 +++++++++++++++++++++++++++++- tests/unit/plugins/test_github.py | 21 +++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/did/plugins/github.py b/did/plugins/github.py index 754dea63..09728cba 100644 --- a/did/plugins/github.py +++ b/did/plugins/github.py @@ -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, @@ -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 """ @@ -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 @@ -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 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -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 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -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}"), ] diff --git a/tests/unit/plugins/test_github.py b/tests/unit/plugins/test_github.py index 4d987a60..a02468e9 100644 --- a/tests/unit/plugins/test_github.py +++ b/tests/unit/plugins/test_github.py @@ -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")