Repository navigation
feat(github): add travnie activity source #471
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
15028ed
e3016de
f0dff18
53c1bb1
91c2e77
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,10 @@ | |
| * The wider Git/GitHub tooling ecosystem: Mergify, GitGuardian, | ||
| GitKraken, Tower, Shields.io, git-annex, Jekyll, Travis CI and HelloGitHub. | ||
| * Star History's blog (scraped index) and newsletter (native Beehiiv RSS). | ||
| The newsletter is the requested rss.beehiiv.com/feeds/BbNzf9ozGZ.xml. | ||
| * travnie organization activity via GitHub's public organization Events API. | ||
| GitHub's private organization dashboard feed requires authentication and is | ||
| deliberately not mirrored into this public Feedseek feed. | ||
| * The GitHubTrendingRSS streams. Daily, weekly and monthly list the same | ||
| repositories over different windows, so they share one source label: the | ||
| URL dedupe collapses the overlap and the per-source quota treats trending | ||
|
|
@@ -33,10 +37,11 @@ | |
| import argparse | ||
| import re | ||
| import sys | ||
| from urllib.parse import urljoin, urlparse | ||
| from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlparse, urlsplit, urlunsplit | ||
|
|
||
| import requests | ||
| from bs4 import BeautifulSoup | ||
| from multi_rss import get_html, parse_date, run | ||
| from multi_rss import get_html, logger, parse_date, run | ||
| from utils import sanitize_xml, stable_fallback_date | ||
|
|
||
| FEED_NAME = "github" | ||
|
|
@@ -57,6 +62,13 @@ | |
| STAR_HISTORY_NEWSLETTER_RSS_URL = "https://rss.beehiiv.com/feeds/BbNzf9ozGZ.xml" | ||
| _STAR_HISTORY_ARTICLE_RE = re.compile(r"^/blog/[^/?#]+/?$") | ||
|
|
||
| TRAVNIE_ORG = "travnie" | ||
| TRAVNIE_ACTIVITY_LABEL = "travnie GitHub Activity" | ||
| TRAVNIE_ACTIVITY_MAX = 80 | ||
| TRAVNIE_PUBLIC_EVENTS_URL = ( | ||
| f"https://api.github.com/orgs/{TRAVNIE_ORG}/events?per_page=100" | ||
| ) | ||
|
|
||
| SOURCES = [ | ||
| ("GitHub Changelog", "https://github.blog/changelog/feed/", 40), | ||
| ("GitHub Engineering", "https://github.blog/engineering/feed/", 30), | ||
|
|
@@ -93,6 +105,7 @@ | |
| AWESOME_LISTS: 30, | ||
| STAR_HISTORY_BLOG_LABEL: 20, | ||
| STAR_HISTORY_NEWSLETTER_LABEL: 15, | ||
| TRAVNIE_ACTIVITY_LABEL: 40, | ||
| } | ||
|
|
||
|
|
||
|
|
@@ -209,7 +222,289 @@ def scrape_star_history_blog(known_links): | |
| return _parse_star_history_blog(html, known_links) | ||
|
|
||
|
|
||
| EXTRA_SCRAPERS = (scrape_beeware_news, scrape_star_history_blog) | ||
|
|
||
| _EVENT_TITLE_ACTIONS = { | ||
| "BranchProtectionRuleEvent": "updated branch protection in", | ||
| "CheckRunEvent": "updated a check run in", | ||
| "CheckSuiteEvent": "updated a check suite in", | ||
| "CommitCommentEvent": "commented on a commit in", | ||
| "DeleteEvent": "deleted a ref from", | ||
| "DiscussionCommentEvent": "commented on a discussion in", | ||
| "ForkEvent": "forked", | ||
| "GollumEvent": "updated wiki pages in", | ||
| "MemberEvent": "updated repository membership in", | ||
| "MembershipEvent": "updated team membership in", | ||
| "PublicEvent": "made public", | ||
| "RepositoryEvent": "updated repository settings in", | ||
| "SponsorshipEvent": "updated sponsorship in", | ||
| "StatusEvent": "updated commit status in", | ||
| "WatchEvent": "starred", | ||
| } | ||
|
|
||
|
|
||
| def _travnie_api_headers(): | ||
| """Build headers for GitHub's public organization Events API.""" | ||
| return { | ||
| "Accept": "application/vnd.github+json", | ||
| "User-Agent": "feedseek/github", | ||
| "X-GitHub-Api-Version": "2026-03-10", | ||
| } | ||
|
|
||
|
|
||
| def _fetch_travnie_events(): | ||
| """Fetch public travnie organization events without authentication.""" | ||
| try: | ||
| response = requests.get( | ||
| TRAVNIE_PUBLIC_EVENTS_URL, | ||
| headers=_travnie_api_headers(), | ||
| timeout=30, | ||
| ) | ||
| except requests.RequestException as exc: | ||
| logger.warning("travnie public events fetch failed: %s", exc) | ||
| return [] | ||
|
|
||
| if response.status_code != 200: | ||
| logger.warning( | ||
| "travnie public events returned HTTP %s", | ||
| response.status_code, | ||
| ) | ||
| return [] | ||
|
|
||
| try: | ||
| payload = response.json() | ||
| except ValueError as exc: | ||
| logger.warning("travnie public events returned invalid JSON: %s", exc) | ||
| return [] | ||
| if isinstance(payload, list): | ||
| return payload | ||
|
|
||
| logger.warning("travnie public events returned an unexpected payload") | ||
| return [] | ||
|
|
||
|
|
||
| def _event_payload_url(event): | ||
| """Prefer the page touched by an event over the repository homepage.""" | ||
| payload = event.get("payload") or {} | ||
| if event.get("type") == "GollumEvent": | ||
| pages = payload.get("pages") | ||
| if isinstance(pages, list): | ||
| for page in pages: | ||
| if not isinstance(page, dict): | ||
| continue | ||
| html_url = page.get("html_url") | ||
| if ( | ||
| isinstance(html_url, str) | ||
| and html_url.startswith("https://github.com/") | ||
| ): | ||
| return html_url | ||
|
|
||
| for key in ( | ||
| "comment", | ||
| "review", | ||
| "discussion", | ||
| "pull_request", | ||
| "issue", | ||
| "release", | ||
| "forkee", | ||
|
trvny marked this conversation as resolved.
|
||
| ): | ||
| value = payload.get(key) | ||
| if not isinstance(value, dict): | ||
| continue | ||
| html_url = value.get("html_url") | ||
| if isinstance(html_url, str) and html_url.startswith("https://github.com/"): | ||
| return html_url | ||
|
|
||
| repo = event.get("repo") or {} | ||
| repo_name = repo.get("name") if isinstance(repo, dict) else None | ||
| if not isinstance(repo_name, str) or "/" not in repo_name: | ||
| return "" | ||
|
|
||
| base = f"https://github.com/{repo_name}" | ||
| if event.get("type") == "PushEvent": | ||
| head = payload.get("head") | ||
| if isinstance(head, str) and re.fullmatch(r"[0-9a-fA-F]{7,40}", head): | ||
| return f"{base}/commit/{head}" | ||
|
|
||
| if event.get("type") == "CreateEvent": | ||
| ref = payload.get("ref") | ||
| ref_type = payload.get("ref_type") | ||
| if ( | ||
| isinstance(ref, str) | ||
| and ref | ||
| and ref_type in {"tag", "branch"} | ||
| ): | ||
| return f"{base}/tree/{quote(ref, safe='')}" | ||
|
|
||
| if event.get("type") == "GollumEvent": | ||
| return f"{base}/wiki" | ||
|
|
||
| return base | ||
|
|
||
|
|
||
| def _event_title(event): | ||
| """Create a compact human title from a GitHub Events API object.""" | ||
| event_type = str(event.get("type") or "GitHubEvent") | ||
| actor_obj = event.get("actor") or {} | ||
| repo_obj = event.get("repo") or {} | ||
| payload = event.get("payload") or {} | ||
|
|
||
| actor = "Someone" | ||
| if isinstance(actor_obj, dict): | ||
| actor = ( | ||
| actor_obj.get("display_login") | ||
| or actor_obj.get("login") | ||
| or actor | ||
| ) | ||
| repo = TRAVNIE_ORG | ||
| if isinstance(repo_obj, dict): | ||
| repo_name = repo_obj.get("name") | ||
| if isinstance(repo_name, str) and repo_name: | ||
| repo = repo_name | ||
|
|
||
| if event_type == "PullRequestEvent": | ||
| number = payload.get("number") | ||
| action = payload.get("action") or "updated" | ||
| return f"{actor} {action} PR #{number} in {repo}" | ||
|
|
||
| if event_type in {"IssuesEvent", "IssueCommentEvent"}: | ||
| issue = payload.get("issue") or {} | ||
| number = issue.get("number") if isinstance(issue, dict) else None | ||
| if event_type == "IssueCommentEvent": | ||
| comment = payload.get("comment") or {} | ||
| comment_id = comment.get("id") if isinstance(comment, dict) else None | ||
| suffix = f" · comment {comment_id}" if comment_id else "" | ||
| is_pull_request = ( | ||
| isinstance(issue, dict) | ||
| and isinstance(issue.get("pull_request"), dict) | ||
| ) | ||
| target = "PR" if is_pull_request else "issue" | ||
| return f"{actor} commented on {target} #{number} in {repo}{suffix}" | ||
| verb = payload.get("action") or "updated" | ||
| return f"{actor} {verb} issue #{number} in {repo}" | ||
|
|
||
| if event_type == "PullRequestReviewCommentEvent": | ||
| pull_request = payload.get("pull_request") or {} | ||
| number = ( | ||
| pull_request.get("number") | ||
| if isinstance(pull_request, dict) | ||
| else None | ||
| ) | ||
| comment = payload.get("comment") or {} | ||
| comment_id = comment.get("id") if isinstance(comment, dict) else None | ||
| suffix = f" · comment {comment_id}" if comment_id else "" | ||
| return f"{actor} commented on PR #{number} in {repo}{suffix}" | ||
|
|
||
| if event_type == "PullRequestReviewEvent": | ||
| pull_request = payload.get("pull_request") or {} | ||
| number = ( | ||
| pull_request.get("number") | ||
| if isinstance(pull_request, dict) | ||
| else None | ||
| ) | ||
| review = payload.get("review") or {} | ||
| review_id = review.get("id") if isinstance(review, dict) else None | ||
| suffix = f" {review_id}" if review_id else "" | ||
| action = payload.get("action") or "created" | ||
| return f"{actor} {action} review{suffix} on PR #{number} in {repo}" | ||
|
|
||
| if event_type == "DiscussionEvent": | ||
| action = payload.get("action") or "created" | ||
| return f"{actor} {action} a discussion in {repo}" | ||
|
|
||
| if event_type == "PushEvent": | ||
| ref = str(payload.get("ref") or "").removeprefix("refs/heads/") | ||
| head = payload.get("head") | ||
| ref_suffix = f" ({ref})" if ref else "" | ||
| head_suffix = ( | ||
| f" · {head[:7]}" | ||
| if isinstance(head, str) and re.fullmatch(r"[0-9a-fA-F]{7,40}", head) | ||
| else "" | ||
| ) | ||
| return f"{actor} pushed to {repo}{ref_suffix}{head_suffix}" | ||
|
|
||
| if event_type == "ReleaseEvent": | ||
| release = payload.get("release") or {} | ||
| tag = release.get("tag_name") if isinstance(release, dict) else None | ||
| action = payload.get("action") or "published" | ||
| suffix = f" {tag}" if tag else "" | ||
| return f"{actor} {action} release{suffix} in {repo}" | ||
|
|
||
| if event_type == "CreateEvent": | ||
| ref_type = payload.get("ref_type") or "ref" | ||
| ref = payload.get("ref") | ||
| suffix = f" {ref}" if ref else "" | ||
| return f"{actor} created {ref_type}{suffix} in {repo}" | ||
|
|
||
| verb = _EVENT_TITLE_ACTIONS.get(event_type) | ||
| if verb: | ||
| return f"{actor} {verb} {repo}" | ||
|
|
||
| event_name = re.sub(r"Event$", "", event_type) | ||
| event_name = re.sub(r"(?<!^)(?=[A-Z])", " ", event_name).lower() | ||
| return f"{actor} triggered {event_name} in {repo}" | ||
|
|
||
|
|
||
| def _event_identity_url(base_link, event_id): | ||
| """Keep canonical anchors while giving every GitHub event a durable identity.""" | ||
| parsed = urlsplit(base_link) | ||
| if parsed.fragment: | ||
| query = parse_qsl(parsed.query, keep_blank_values=True) | ||
| query.append(("feedseek_event", event_id)) | ||
| return urlunsplit( | ||
| ( | ||
| parsed.scheme, | ||
| parsed.netloc, | ||
| parsed.path, | ||
| urlencode(query), | ||
| parsed.fragment, | ||
| ) | ||
| ) | ||
| return f"{base_link}#feedseek-event-{quote(event_id, safe='')}" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an event target lacks a real fragment—such as a push commit, pull request, release, or wiki page—this appends a fabricated AGENTS.md reference: AGENTS.md:L13-L13 Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| def _normalize_travnie_event(event): | ||
| """Convert one GitHub Events API object into a Feedseek entry.""" | ||
| if not isinstance(event, dict): | ||
| return None | ||
| event_id = str(event.get("id") or "").strip() | ||
| created_at = event.get("created_at") | ||
| if not event_id or not isinstance(created_at, str): | ||
| return None | ||
|
|
||
| base_link = _event_payload_url(event) | ||
| if not base_link: | ||
| return None | ||
| link = _event_identity_url(base_link, event_id) | ||
| title = sanitize_xml(_event_title(event)) | ||
| return { | ||
| "title": title, | ||
| "link": link, | ||
| "date": parse_date(created_at), | ||
| "description": title, | ||
| "source": TRAVNIE_ACTIVITY_LABEL, | ||
| } | ||
|
|
||
|
|
||
| def scrape_travnie_activity(known_links): | ||
| """Collect only public travnie activity from GitHub's tokenless API.""" | ||
| entries = [] | ||
| for event in _fetch_travnie_events(): | ||
| if event.get("public") is False: | ||
| continue | ||
| entry = _normalize_travnie_event(event) | ||
| if not entry or entry["link"] in known_links: | ||
| continue | ||
| entries.append(entry) | ||
| if len(entries) >= TRAVNIE_ACTIVITY_MAX: | ||
| break | ||
| return entries | ||
|
|
||
|
|
||
| EXTRA_SCRAPERS = ( | ||
| scrape_beeware_news, | ||
| scrape_star_history_blog, | ||
| scrape_travnie_activity, | ||
|
trvny marked this conversation as resolved.
|
||
| ) | ||
|
|
||
|
|
||
| def doc_sources(): | ||
|
|
@@ -218,6 +513,7 @@ def doc_sources(): | |
| (BEEWARE_LABEL, BEEWARE_NEWS_URL), | ||
| ("Star History", STAR_HISTORY_URL), | ||
| (STAR_HISTORY_BLOG_LABEL, STAR_HISTORY_BLOG_URL), | ||
| (TRAVNIE_ACTIVITY_LABEL, f"https://github.com/orgs/{TRAVNIE_ORG}"), | ||
| ] | ||
|
|
||
|
|
||
|
|
@@ -234,15 +530,16 @@ def main(full=False): | |
| title="GitHub", | ||
| subtitle="Combined GitHub feed: GitHub blogs and changelogs, GitHub " | ||
| "Status, Mergify, BeeWare News, Star History, Komi Store, the Git " | ||
| "tooling ecosystem, Track Awesome List and deduplicated GitHub trending " | ||
| "streams.", | ||
| "tooling ecosystem, Track Awesome List, travnie organization activity " | ||
| "and deduplicated GitHub trending streams.", | ||
| blog_url="https://github.blog/", | ||
| author="GitHub", | ||
| sources=SOURCES, | ||
| extra_scrapers=EXTRA_SCRAPERS, | ||
| max_entries=400, | ||
| per_source_cap=PER_SOURCE_QUOTA, | ||
| cache_filter=_active_cache_entry, | ||
| dedupe_title_field=None, | ||
| full=full, | ||
| ) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a
GollumEventcontains a page whose action iscreated, this hardcoded title says the actor merely “updated wiki pages,” silently losing the creation fact. GitHub's event schema explicitly distinguishescreatedfromeditedinpayload.pages[].action, so derive the title from those page actions instead of assigning every wiki event the same verb.AGENTS.md reference: AGENTS.md:L14-L14
Useful? React with 👍 / 👎.