snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
This commit is contained in:
597
scripts/build_changelog.py
Executable file
597
scripts/build_changelog.py
Executable file
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script that generates a changelog for the repository and its submodules, and outputs it in the current directory.
|
||||
|
||||
NOTE: This script can be downloaded as-is and run from your repository.
|
||||
|
||||
Repos using this script:
|
||||
- ActivityWatch/activitywatch
|
||||
- ErikBjare/gptme
|
||||
|
||||
Manual actions needed to clean up for changelog:
|
||||
- Reorder modules in a logical order (aw-webui, aw-server, aw-server-rust, aw-watcher-window, aw-watcher-afk, ...)
|
||||
- Remove duplicate aw-webui entries
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from collections import defaultdict
|
||||
from collections.abc import Collection
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE, STDOUT
|
||||
from subprocess import run as _run
|
||||
from time import sleep
|
||||
from typing import (
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
import requests
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
script_dir = Path(__file__).parent.resolve()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate changelog from git history")
|
||||
|
||||
# repo info
|
||||
parser.add_argument("--org", default="ActivityWatch", help="GitHub organization")
|
||||
parser.add_argument("--repo", default="activitywatch", help="GitHub repository")
|
||||
parser.add_argument(
|
||||
"--project-title", default="ActivityWatch", help="Project title"
|
||||
)
|
||||
|
||||
# settings
|
||||
last_tag = run("git describe --tags --abbrev=0").strip() # get latest tag
|
||||
branch = run("git rev-parse --abbrev-ref HEAD").strip() # get current branch name
|
||||
parser.add_argument(
|
||||
"--range", default=f"{last_tag}...{branch}", help="Git commit range"
|
||||
)
|
||||
parser.add_argument("--path", default=".", help="Path to git repo")
|
||||
|
||||
# output
|
||||
parser.add_argument(
|
||||
"--output", default="changelog.md", help="Path to output changelog"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--add-version-header",
|
||||
action="store_true",
|
||||
help="Add version header and adjust heading levels for docs",
|
||||
)
|
||||
|
||||
# parse args
|
||||
args = parser.parse_args()
|
||||
since, until = args.range.split("...", 1)
|
||||
|
||||
# preferred output order for submodules
|
||||
repo_order = [
|
||||
"activitywatch",
|
||||
"aw-server",
|
||||
"aw-server-rust",
|
||||
"aw-webui",
|
||||
"aw-watcher-afk",
|
||||
"aw-watcher-window",
|
||||
"aw-qt",
|
||||
"aw-core",
|
||||
"aw-client",
|
||||
]
|
||||
|
||||
build(
|
||||
args.org,
|
||||
args.repo,
|
||||
args.project_title,
|
||||
commit_range=(since, until),
|
||||
output_path=args.output,
|
||||
repo_order=repo_order,
|
||||
add_version_header=args.add_version_header,
|
||||
)
|
||||
|
||||
|
||||
class CommitMsg:
|
||||
type: str
|
||||
subtype: str
|
||||
msg: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Commit:
|
||||
id: str
|
||||
msg: str
|
||||
org: str
|
||||
repo: str
|
||||
|
||||
@property
|
||||
def msg_processed(self) -> str:
|
||||
"""Generates links from commit and issue references (like 0c14d77, #123) to correct repo and such"""
|
||||
s = self.msg
|
||||
s = re.sub(
|
||||
rf"[^(-]https://github.com/{self.org}/([\-\w\d]+)/(issues|pulls)/(\d+)",
|
||||
rf"[#\3](https://github.com/{self.org}/\1/issues/\3)",
|
||||
s,
|
||||
)
|
||||
s = re.sub(
|
||||
r"#(\d+)",
|
||||
rf"[#\1](https://github.com/{self.org}/{self.repo}/issues/\1)",
|
||||
s,
|
||||
)
|
||||
s = re.sub(
|
||||
r"[\s\(][0-9a-f]{7}[\s\)]",
|
||||
rf"[`\0`](https://github.com/{self.org}/{self.repo}/issues/\0)",
|
||||
s,
|
||||
)
|
||||
# wrap html elements in backticks, if not already wrapped
|
||||
s = re.sub(r"(?<!`)<([^>]+)>(?!`)", r"`<\1>`", s)
|
||||
return s
|
||||
|
||||
def parse_type(self) -> Optional[Tuple[str, str]]:
|
||||
# Needs to handle '!' indicating breaking change
|
||||
match = re.search(r"^(\w+)(\((.+)\))?[!]?:", self.msg)
|
||||
if match:
|
||||
type = match.group(1)
|
||||
subtype = match.group(3)
|
||||
if type in ["build", "ci", "fix", "feat"]:
|
||||
return type, subtype
|
||||
return None
|
||||
|
||||
@property
|
||||
def type(self) -> Optional[str]:
|
||||
_type, _ = self.parse_type() or (None, None)
|
||||
return _type
|
||||
|
||||
@property
|
||||
def subtype(self) -> Optional[str]:
|
||||
_, subtype = self.parse_type() or (None, None)
|
||||
return subtype
|
||||
|
||||
def type_str(self) -> str:
|
||||
_type, subtype = self.parse_type() or (None, None)
|
||||
return f"{_type}" + (f"({subtype})" if subtype else "")
|
||||
|
||||
def format(self) -> str:
|
||||
commit_link = commit_linkify(self.id, self.org, self.repo) if self.id else ""
|
||||
|
||||
return f"{self.msg_processed}" + (f" ({commit_link})" if commit_link else "")
|
||||
|
||||
|
||||
def run(cmd, cwd=".") -> str:
|
||||
logger.debug(f"Running in {cwd}: {cmd}")
|
||||
p = _run(shlex.split(cmd), stdout=PIPE, stderr=STDOUT, encoding="utf8", cwd=cwd)
|
||||
if p.returncode != 0:
|
||||
print(p.stdout)
|
||||
print(p.stderr)
|
||||
raise Exception
|
||||
return p.stdout
|
||||
|
||||
|
||||
def pr_linkify(prid: str, org: str, repo: str) -> str:
|
||||
return f"[#{prid}](https://github.com/{org}/{repo}/pulls/{prid})"
|
||||
|
||||
|
||||
def commit_linkify(commitid: str, org: str, repo: str) -> str:
|
||||
return f"[`{commitid}`](https://github.com/{org}/{repo}/commit/{commitid})"
|
||||
|
||||
|
||||
def wrap_details(title, body, wraplines=5):
|
||||
"""Wrap lines into a <details> element if body is longer than `wraplines`"""
|
||||
out = f"\n\n### {title}"
|
||||
wrap = body.strip().count("\n") > wraplines
|
||||
if wrap:
|
||||
out += "\n<details><summary>Click to expand</summary>\n<p>"
|
||||
out += f"\n{body.rstrip()}"
|
||||
if wrap:
|
||||
out += "\n\n</p>\n</details>"
|
||||
return out
|
||||
|
||||
|
||||
contributor_emails = set()
|
||||
|
||||
|
||||
def summary_repo(
|
||||
org: str,
|
||||
repo: str,
|
||||
path: str,
|
||||
commit_range: Tuple[str, str],
|
||||
filter_types: List[str],
|
||||
repo_order: List[str],
|
||||
) -> str:
|
||||
if commit_range[1] == "0000000":
|
||||
# Happens when a submodule has been removed
|
||||
return ""
|
||||
if commit_range[0] == "0000000":
|
||||
# Happens when a submodule has been added
|
||||
commit_range = ("", "") # no range = all commits for new submodule
|
||||
|
||||
out = f"\n## 📦 {repo}"
|
||||
|
||||
feats = ""
|
||||
fixes = ""
|
||||
misc = ""
|
||||
hidden = 0
|
||||
|
||||
# pretty format is modified version of: https://stackoverflow.com/a/1441062/965332
|
||||
summary_bundle = run(
|
||||
f"git log {'...'.join(commit_range) if any(commit_range) else ''} --no-decorate --pretty=format:'%h%x09%an%x09%ae%x09%s'",
|
||||
cwd=path,
|
||||
)
|
||||
print(f"Found {len(summary_bundle.splitlines())} commits in {repo}")
|
||||
for line in summary_bundle.split("\n"):
|
||||
if line:
|
||||
_id, _author, email, msg = line.split("\t")
|
||||
# will add author email to contributor list
|
||||
# the `contributor_emails` is global and collected later
|
||||
contributor_emails.add(email)
|
||||
commit = Commit(id=_id, msg=msg, org=org, repo=repo)
|
||||
|
||||
entry = f"\n - {commit.format()}"
|
||||
if commit.type == "feat":
|
||||
feats += entry
|
||||
elif commit.type == "fix":
|
||||
fixes += entry
|
||||
elif commit.type not in filter_types:
|
||||
misc += entry
|
||||
else:
|
||||
hidden += 1
|
||||
|
||||
for name, entries in (
|
||||
("✨ Features", feats),
|
||||
("🐛 Fixes", fixes),
|
||||
("🔨 Misc", misc),
|
||||
):
|
||||
if entries:
|
||||
_count = len(entries.strip().split("\n"))
|
||||
title = f"{name} ({_count})"
|
||||
if "Misc" in name or "Fixes" in name:
|
||||
out += wrap_details(title, entries)
|
||||
else:
|
||||
out += f"\n\n### {title}\n"
|
||||
out += entries
|
||||
if hidden > 1:
|
||||
full_history_url = f"https://github.com/{org}/{repo}/compare/{commit_range[0]}...{commit_range[1]}"
|
||||
out += f"\n\n*(excluded {hidden} less relevant [commits]({full_history_url}))*"
|
||||
|
||||
# NOTE: For now, these TODOs can be manually fixed for each changelog.
|
||||
# TODO: Fix issue where subsubmodules can appear twice (like aw-webui)
|
||||
# TODO: Use specific order (aw-webui should be one of the first, for example)
|
||||
summary_subrepos = run(
|
||||
f"git submodule summary --cached {commit_range[0]}", cwd=path
|
||||
)
|
||||
subrepos = {}
|
||||
for header, *_ in [s.split("\n") for s in summary_subrepos.split("\n\n")]:
|
||||
if header.startswith("fatal: not a git repository"):
|
||||
# Happens when a submodule has been removed
|
||||
continue
|
||||
if header.strip():
|
||||
if len(header.split(" ")) < 4:
|
||||
# Submodule may have been deleted
|
||||
continue
|
||||
|
||||
_, name, crange, count = header.split(" ")
|
||||
commit_range = tuple(crange.split("...", 1)) # type: ignore
|
||||
count = count.strip().lstrip("(").rstrip("):")
|
||||
logger.info(
|
||||
f"Found {name}, looking up range: {commit_range} ({count} commits)"
|
||||
)
|
||||
name = name.strip(".").strip("/")
|
||||
|
||||
subrepos[name] = summary_repo(
|
||||
org,
|
||||
name,
|
||||
f"{path}/{name}",
|
||||
commit_range,
|
||||
filter_types=filter_types,
|
||||
repo_order=repo_order,
|
||||
)
|
||||
|
||||
# filter out subrepos with no commits (single line after stripping whitespace)
|
||||
subrepos = {
|
||||
name: output
|
||||
for name, output in subrepos.items()
|
||||
if len(output.strip().splitlines()) > 1
|
||||
}
|
||||
|
||||
# pick subrepos in repo_order, and remove from dict
|
||||
for name in repo_order:
|
||||
if name in subrepos:
|
||||
out += "\n"
|
||||
out += subrepos[name]
|
||||
logger.info(f"{name:12} length: \t{len(subrepos[name])}")
|
||||
del subrepos[name]
|
||||
|
||||
# add remaining repos
|
||||
for output in subrepos.values():
|
||||
out += "\n"
|
||||
out += output
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# FIXME: Doesn't work, messy af, just gonna have to remove the aw-webui section by hand
|
||||
def remove_duplicates(s: List[str], minlen=10, only_sections=True) -> List[str]:
|
||||
"""
|
||||
Removes the longest sequence of repeated elements (they don't have to be adjacent), if sequence if longer than `minlen`.
|
||||
Preserves order of elements.
|
||||
"""
|
||||
if len(s) < minlen:
|
||||
return s
|
||||
out = []
|
||||
longest: List[str] = []
|
||||
for i in range(len(s)):
|
||||
if i == 0 or s[i] not in out:
|
||||
# Not matching any previous line,
|
||||
# so add longest and new line to output, and reset longest
|
||||
if len(longest) < minlen:
|
||||
out.extend(longest)
|
||||
else:
|
||||
duplicate = "\n".join(longest)
|
||||
print(f"Removing duplicate '{duplicate[:80]}...'")
|
||||
out.append(s[i])
|
||||
longest = []
|
||||
else:
|
||||
# Matches a previous line, so add to longest
|
||||
# If longest is empty and only_sections is True, check that the line is a section start
|
||||
if only_sections:
|
||||
if not longest and s[i].startswith("#"):
|
||||
longest.append(s[i])
|
||||
else:
|
||||
out.append(s[i])
|
||||
else:
|
||||
longest.append(s[i])
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def build(
|
||||
org: str,
|
||||
repo: str,
|
||||
project_name: str,
|
||||
commit_range: Tuple[str, str],
|
||||
output_path: str,
|
||||
repo_order: List[str],
|
||||
filter_types: Optional[List[str]] = None,
|
||||
add_version_header: bool = False,
|
||||
):
|
||||
# provides a commit summary for the repo and subrepos, recursively looking up subrepos
|
||||
# NOTE: this must be done *before* `get_all_contributors` is called,
|
||||
# as the latter relies on summary_repo looking up all users and storing in a global.
|
||||
if not filter_types:
|
||||
filter_types = ["build", "ci", "tests", "test"]
|
||||
|
||||
logger.info("Generating commit summary")
|
||||
since, tag = commit_range
|
||||
output_changelog = summary_repo(
|
||||
org,
|
||||
repo,
|
||||
".",
|
||||
commit_range=commit_range,
|
||||
filter_types=filter_types,
|
||||
repo_order=repo_order,
|
||||
)
|
||||
|
||||
output_changelog = f"""
|
||||
# Changelog
|
||||
|
||||
Changes since [{since}](https://github.com/{org}/{repo}/releases/tag/{since}):
|
||||
|
||||
{output_changelog}
|
||||
""".strip()
|
||||
|
||||
# Would ideally sort by number of commits or something, but that's tricky
|
||||
usernames = sorted(get_all_contributors(), key=str.casefold)
|
||||
usernames = [u for u in usernames if not u.endswith("[bot]")]
|
||||
twitter_handles = get_twitter_of_ghusers(usernames)
|
||||
print(
|
||||
"Twitter handles: "
|
||||
+ ", ".join("@" + handle for handle in twitter_handles.values() if handle),
|
||||
)
|
||||
|
||||
output_contributors = f"""# Contributors
|
||||
|
||||
Thanks to everyone who contributed to this release:
|
||||
|
||||
{', '.join(('@' + username for username in usernames))}"""
|
||||
|
||||
# Header starts here
|
||||
logger.info("Building final output")
|
||||
output = f"These are the release notes for {project_name} version {tag}.".strip()
|
||||
output += "\n\n"
|
||||
|
||||
# hardcoded for now
|
||||
if repo == "activitywatch":
|
||||
output += "**New to ActivityWatch?** Check out the [website](https://activitywatch.net) and the [README](https://github.com/ActivityWatch/activitywatch/blob/master/README.md)."
|
||||
output += "\n\n"
|
||||
output += """# Installation
|
||||
|
||||
See the [getting started guide in the documentation](https://docs.activitywatch.net/en/latest/getting-started.html).
|
||||
""".strip()
|
||||
output += "\n\n"
|
||||
output += f"""# Downloads
|
||||
|
||||
- [**Windows**](https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/activitywatch-{tag}-windows-x86_64-setup.exe) (.exe, installer)
|
||||
- [**macOS**](https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/activitywatch-{tag}-macos-x86_64.dmg) (.dmg)
|
||||
- [**Linux**](https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/activitywatch-{tag}-linux-x86_64.zip) (.zip)
|
||||
""".strip()
|
||||
output += "\n\n"
|
||||
|
||||
output += output_contributors.strip() + "\n\n"
|
||||
output += output_changelog.strip() + "\n\n"
|
||||
output += (
|
||||
f"**Full Changelog**: https://github.com/{org}/{repo}/compare/{since}...{tag}"
|
||||
)
|
||||
|
||||
if repo == "activitywatch":
|
||||
output = output.replace("# activitywatch", "# activitywatch (bundle repo)")
|
||||
|
||||
if add_version_header:
|
||||
output = f"# {tag}\n\n" + output
|
||||
output = output.replace("\n# Contributors\n", "\n## Contributors\n")
|
||||
output = output.replace("\n# Changelog\n", "\n## Changelog\n")
|
||||
|
||||
with open(output_path, "w") as f:
|
||||
f.write(output)
|
||||
print(f"Wrote {len(output.splitlines())} lines to {output_path}")
|
||||
|
||||
|
||||
def _resolve_email(email: str) -> Optional[str]:
|
||||
if "users.noreply.github.com" in email:
|
||||
username = email.split("@")[0]
|
||||
if "+" in username:
|
||||
username = username.split("+")[1]
|
||||
# TODO: Verify username is valid using the GitHub API
|
||||
print(f"Contributor: @{username}")
|
||||
return username
|
||||
else:
|
||||
resp = None
|
||||
backoff = 0
|
||||
max_backoff = 2
|
||||
while resp is None:
|
||||
if backoff >= max_backoff:
|
||||
logger.warning(f"Backed off {max_backoff} times, giving up")
|
||||
break
|
||||
try:
|
||||
logger.info(f"Sending request for {email}")
|
||||
_resp = requests.get(
|
||||
f"https://api.github.com/search/users?q={email}+in%3Aemail"
|
||||
)
|
||||
_resp.raise_for_status()
|
||||
resp = _resp
|
||||
backoff = 0
|
||||
# if rate limit exceeded, back off
|
||||
except requests.exceptions.RequestException as e:
|
||||
if isinstance(e, requests.exceptions.HTTPError):
|
||||
if e.response.status_code == 403:
|
||||
logger.warning("Rate limit exceeded, backing off...")
|
||||
backoff += 1
|
||||
sleep(3)
|
||||
continue
|
||||
else:
|
||||
raise e
|
||||
finally:
|
||||
# Just to respect API limits...
|
||||
sleep(1)
|
||||
|
||||
if resp:
|
||||
data = resp.json()
|
||||
if data["total_count"] == 0:
|
||||
logger.info(f"No match for email: {email}")
|
||||
if data["total_count"] > 1:
|
||||
logger.warning(f"Multiple matches for email: {email}")
|
||||
if data["total_count"] >= 1:
|
||||
username = data["items"][0]["login"]
|
||||
logger.info(f"Contributor: @{username} (by email: {email})")
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
def get_all_contributors() -> set[str]:
|
||||
# TODO: Merge with contributor-stats?
|
||||
logger.info("Getting all contributors")
|
||||
|
||||
# We will commit this file, to act as a cache (preventing us from querying GitHub API every time)
|
||||
filename = script_dir / "changelog_contributors.csv"
|
||||
|
||||
# mapping from username to one or more emails
|
||||
usernames: Dict[str, set] = defaultdict(set)
|
||||
|
||||
# some hardcoded ones, some that don't resolve...
|
||||
usernames["erikbjare"] |= {"erik.bjareholt@gmail.com", "erik@bjareho.lt"}
|
||||
usernames["iloveitaly"] |= {"iloveitaly@gmail.com"}
|
||||
usernames["kewde"] |= {"kewde@particl.io"}
|
||||
usernames["victorwinberg"] |= {"victor.m.winberg@gmail.com"}
|
||||
usernames["NicoWeio"] |= {"nico.weio@gmail.com"}
|
||||
usernames["2e3s"] |= {"2e3s19@gmail.com"}
|
||||
usernames["alwinator"] |= {"accounts@alwinschuster.at"}
|
||||
|
||||
# read existing contributors, to avoid extra calls to the GitHub API
|
||||
if os.path.exists(filename):
|
||||
with open(filename, "r") as f:
|
||||
s = f.read()
|
||||
for line in s.split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
username, *emails = line.split("\t")
|
||||
for email in emails:
|
||||
usernames[username].add(email)
|
||||
logger.info(f"Read {len(usernames)} contributors from {filename}")
|
||||
|
||||
resolved_emails = set(
|
||||
email for email_set in usernames.values() for email in email_set
|
||||
)
|
||||
unresolved_emails = contributor_emails - resolved_emails
|
||||
for email in unresolved_emails:
|
||||
username_opt = _resolve_email(email)
|
||||
if username_opt:
|
||||
usernames[username_opt].add(email)
|
||||
|
||||
with open(filename, "w") as f:
|
||||
for username, email_set in sorted(usernames.items()):
|
||||
emails_str = "\t".join(sorted(email_set))
|
||||
f.write(f"{username}\t{emails_str}")
|
||||
f.write("\n")
|
||||
|
||||
logger.info(f"Wrote {len(usernames)} contributors to {filename}")
|
||||
|
||||
email_to_username = {
|
||||
email: username for username, emails in usernames.items() for email in emails
|
||||
}
|
||||
|
||||
return set(
|
||||
email_to_username[email]
|
||||
for email in contributor_emails
|
||||
if email in email_to_username
|
||||
)
|
||||
|
||||
|
||||
def get_twitter_of_ghusers(ghusers: Collection[str]):
|
||||
logger.info("Getting twitter of GitHub usernames")
|
||||
|
||||
# We will commit this file, to act as a cache (preventing us from querying GitHub API every time)
|
||||
filename = script_dir / "changelog_contributors_twitter.csv"
|
||||
|
||||
twitter = {}
|
||||
|
||||
# read existing contributors, to avoid extra calls to the GitHub API
|
||||
if os.path.exists(filename):
|
||||
with open(filename, "r") as f:
|
||||
s = f.read()
|
||||
for line in s.split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
gh_username, twitter_username = line.split("\t")
|
||||
twitter[gh_username] = twitter_username
|
||||
logger.info(f"Read {len(twitter)} Twitter handles from {filename}")
|
||||
|
||||
for username in ghusers:
|
||||
if username in twitter:
|
||||
continue
|
||||
try:
|
||||
resp = requests.get(f"https://api.github.com/users/{username}")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get twitter of {username}: {e}")
|
||||
continue
|
||||
|
||||
twitter_username = data["twitter_username"]
|
||||
if twitter_username:
|
||||
twitter[username] = twitter_username
|
||||
|
||||
with open(filename, "w") as f:
|
||||
for username, twitter_username in sorted(twitter.items()):
|
||||
f.write(f"{username}\t{twitter_username}")
|
||||
f.write("\n")
|
||||
|
||||
return twitter
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user