Skip to content
Merged
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
307 changes: 302 additions & 5 deletions feed_generators/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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),
Expand Down Expand Up @@ -93,6 +105,7 @@
AWESOME_LISTS: 30,
STAR_HISTORY_BLOG_LABEL: 20,
STAR_HISTORY_NEWSLETTER_LABEL: 15,
TRAVNIE_ACTIVITY_LABEL: 40,
}


Expand Down Expand Up @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve wiki creation actions

When a GollumEvent contains a page whose action is created, this hardcoded title says the actor merely “updated wiki pages,” silently losing the creation fact. GitHub's event schema explicitly distinguishes created from edited in payload.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 👍 / 👎.

"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",
Comment thread
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='')}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep event IDs out of canonical links

When an event target lacks a real fragment—such as a push commit, pull request, release, or wiki page—this appends a fabricated #feedseek-event-* anchor and publishes it as the Atom/JSON Feed alternate link, so consumers receive a noncanonical URL and browsers attempt to navigate to a nonexistent page anchor. Fresh evidence after the earlier event-identity comments is that the finalized helper stores the ID in link itself; keep base_link as the canonical link and use the event ID as a separate persisted identity and merge key.

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,
Comment thread
trvny marked this conversation as resolved.
)


def doc_sources():
Expand All @@ -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}"),
]


Expand All @@ -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,
)

Expand Down
Loading