import re from argparse import ArgumentParser, RawTextHelpFormatter from typing import Any import requests from attr import dataclass from tqdm import tqdm def get_author(commit: dict[str, Any]) -> str: """Gets the author of a commit. If the author is not present, the committer is used instead and an asterisk appended to the name.""" return commit["author"]["login"] if commit["author"] else f"{commit['commit']['author']['name']}*" @dataclass class CommitInfo: sha: str url: str author: str is_username: bool message: str data: dict[str, Any] def __str__(self) -> str: return f"{self.sha}: {self.author}{'*' if not self.is_username else ''} - {self.message} ({self.url})" @classmethod def from_data(cls, commit: dict[str, Any]) -> "CommitInfo": return CommitInfo( sha=commit["sha"], url=commit["url"], author=commit["author"]["login"] if commit["author"] else commit["commit"]["author"]["name"], is_username=bool(commit["author"]), message=commit["commit"]["message"].split("\n")[0], data=commit, ) def fetch_commits_between_tags( org_name: str, repo_name: str, from_ref: str, to_ref: str, token: str ) -> list[CommitInfo]: """Fetches all commits between two tags in a GitHub repository.""" commit_info: list[CommitInfo] = [] headers = {"Authorization": f"token {token}"} if token else None # Get the total number of pages w/ an intial request - a bit hacky but it works... response = requests.get( f"https://api.github.com/repos/{org_name}/{repo_name}/compare/{from_ref}...{to_ref}?page=1&per_page=100", headers=headers, ) last_page_match = re.search(r'page=(\d+)&per_page=\d+>; rel="last"', response.headers["Link"]) last_page = int(last_page_match.group(1)) if last_page_match else 1 pbar = tqdm(range(1, last_page + 1), desc="Fetching commits", unit="page", leave=False) for page in pbar: compare_url = f"https://api.github.com/repos/{org_name}/{repo_name}/compare/{from_ref}...{to_ref}?page={page}&per_page=100" response = requests.get(compare_url, headers=headers) commits = response.json()["commits"] commit_info.extend([CommitInfo.from_data(c) for c in commits]) return commit_info def main(): description = """Fetch external contributions between two tags in the InvokeAI GitHub repository. Useful for generating a list of contributors to include in release notes. When the GitHub username for a commit is not available, the committer name is used instead and an asterisk appended to the name. Example output (note the second commit has an asterisk appended to the name): 171f2aa20ddfefa23c5edbeb2849c4bd601fe104: rohinish404 - fix(ui): image not getting selected (https://api.github.com/repos/invoke-ai/InvokeAI/commits/171f2aa20ddfefa23c5edbeb2849c4bd601fe104) 0bb0e226dcec8a17e843444ad27c29b4821dad7c: Mark E. Shoulson* - Flip default ordering of workflow library; #5477 (https://api.github.com/repos/invoke-ai/InvokeAI/commits/0bb0e226dcec8a17e843444ad27c29b4821dad7c) """ parser = ArgumentParser(description=description, formatter_class=RawTextHelpFormatter) parser.add_argument("--token", dest="token", type=str, default=None, help="The GitHub token to use") parser.add_argument("--from", dest="from_ref", type=str, help="The start reference (commit, tag, etc)") parser.add_argument("--to", dest="to_ref", type=str, help="The end reference (commit, tag, etc)") args = parser.parse_args() org_name = "invoke-ai" repo_name = "InvokeAI" # List of members of the organization, including usernames and known display names, # any of which may be used in the commit data. Used to filter out commits. org_members = [ "blessedcoolant", "brandonrising", "chainchompa", "ebr", "Eugene Brodsky", "hipsterusername", "Kent Keirsey", "lstein", "Lincoln Stein", "maryhipp", "Mary Hipp Rogers", "Mary Hipp", "psychedelicious", "RyanJDick", "Ryan Dick", ] all_commits = fetch_commits_between_tags( org_name=org_name, repo_name=repo_name, from_ref=args.from_ref, to_ref=args.to_ref, token=args.token, ) filtered_commits = filter(lambda x: x.author not in org_members, all_commits) for commit in filtered_commits: print(commit) if __name__ == "__main__": main()