mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 12:25:29 +08:00
733 lines
27 KiB
Python
733 lines
27 KiB
Python
# GitHubKit - GitHub integration plugin for LangBot.
|
|
# Provides read-only GitHub queries/search plus repository event push to IM
|
|
# sessions via background polling of the GitHub events API.
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import asyncio
|
|
import datetime
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
|
|
from langbot_plugin.api.definition.plugin import BasePlugin
|
|
from langbot_plugin.api.proxies.invocation import (
|
|
bind_invocation,
|
|
current_binding,
|
|
current_config,
|
|
)
|
|
|
|
# Allow importing the plugin-level i18n module.
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from i18n import get_text # noqa: E402
|
|
|
|
API_BASE = "https://api.github.com"
|
|
SUBS_KEY = "subscriptions" # storage key for repo -> [subscribers] map
|
|
|
|
# Give the runtime a moment to settle before the first poll (seconds).
|
|
POLL_START_DELAY = 10
|
|
|
|
# Process-cache slot used when no installation binding is available (dedicated
|
|
# placement). It maps to the legacy storage key so existing dedicated installs
|
|
# keep reading their own subscriptions.
|
|
DEDICATED_SCOPE = "dedicated"
|
|
|
|
# Written by the SDK worker launcher. A shared worker must always carry a trusted
|
|
# invocation binding; without one it must refuse to touch tenant state instead of
|
|
# falling back to a binding-less scope shared by every installation.
|
|
RUNTIME_PROFILE_ENV = "LANGBOT_PLUGIN_RUNTIME_PROFILE"
|
|
|
|
|
|
def installation_scope(binding) -> str:
|
|
"""Return the stable scope token for one installation binding.
|
|
|
|
``runtime_revision`` changes on every worker upgrade, so only the stable
|
|
installation identity triple is used; the revoked binding and the resumed
|
|
poller must resolve to the same scope.
|
|
"""
|
|
|
|
return f"{binding.instance_uuid}:{binding.workspace_uuid}:{binding.installation_uuid}"
|
|
|
|
|
|
def storage_key(scope: str) -> str:
|
|
"""Return the plugin-storage key for one scope.
|
|
|
|
Host rows are keyed by ``[instance, workspace, owner_type, owner, key]`` with
|
|
no installation dimension, so the installation scope has to live in the
|
|
plugin's own key. The binding-less dedicated scope keeps the legacy key.
|
|
"""
|
|
|
|
if scope == DEDICATED_SCOPE:
|
|
return SUBS_KEY
|
|
return f"{SUBS_KEY}:{scope}"
|
|
|
|
|
|
class _Poller:
|
|
"""背景轮询 belongs to exactly one installation binding.
|
|
|
|
The registry entry carries the poller's subscription lock, its captured
|
|
invocation config (token/interval/cap/language), and the task handle so
|
|
``on_installation_revoked`` can stop it.
|
|
"""
|
|
|
|
__slots__ = ("scope", "binding", "lock", "config", "task")
|
|
|
|
def __init__(self, scope: str, binding, config: dict[str, Any]) -> None:
|
|
self.scope = scope
|
|
self.binding = binding
|
|
self.lock = asyncio.Lock()
|
|
self.config = config
|
|
self.task: Optional[asyncio.Task] = None
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
|
|
|
|
class GitHubKitPlugin(BasePlugin):
|
|
"""GitHub integration: queries, search, and event-push subscriptions.
|
|
|
|
Subscriptions are stored as JSON in plugin KV under ``subscriptions``::
|
|
|
|
{
|
|
"owner/repo": {
|
|
"subscribers": {
|
|
"<session_id>": {
|
|
"bot_uuid": "...",
|
|
"target_type": "group"|"person",
|
|
"target_id": "...",
|
|
"events": ["push","pull_request",...] | null, # null = all
|
|
"lang": "en_US"|"zh_Hans", # language for pushed messages
|
|
"added_at": "iso"
|
|
}
|
|
},
|
|
"last_event_id": "123456789"
|
|
}
|
|
}
|
|
"""
|
|
|
|
# ------------------------------------------------------------- lifecycle
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self._pollers: dict[str, _Poller] = {}
|
|
|
|
async def initialize(self) -> None:
|
|
"""Process-wide initialization only.
|
|
|
|
Under shared placement this runs once per worker with an empty config and
|
|
no installation context, so configuration is read per invocation and the
|
|
event poller is started lazily per installation binding instead.
|
|
"""
|
|
return None
|
|
|
|
async def on_installation_revoked(self, binding) -> None:
|
|
"""Stop the revoked installation's poller; only process-local state is available."""
|
|
poller = self._pollers.pop(installation_scope(binding), None)
|
|
if poller is None:
|
|
return
|
|
task = poller.task
|
|
if task is not None and not task.done():
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
async def destroy(self) -> None:
|
|
"""Stop every installation's poller (process end)."""
|
|
pending = [
|
|
poller.task
|
|
for poller in self._pollers.values()
|
|
if poller.task is not None and not poller.task.done()
|
|
]
|
|
self._pollers.clear()
|
|
for task in pending:
|
|
task.cancel()
|
|
if pending:
|
|
await asyncio.gather(*pending, return_exceptions=True)
|
|
|
|
# --------------------------------------------------------- scope & config
|
|
|
|
def _current_scope(self) -> str:
|
|
binding = self.get_installation_binding()
|
|
if binding is not None:
|
|
return installation_scope(binding)
|
|
if os.environ.get(RUNTIME_PROFILE_ENV) == "shared":
|
|
raise RuntimeError(
|
|
"shared invocation has no trusted installation binding; "
|
|
"refusing to read or write tenant state"
|
|
)
|
|
return DEDICATED_SCOPE
|
|
|
|
def _poller_config(self) -> dict[str, Any]:
|
|
"""Read the invoking installation's poll settings (never cached on the object)."""
|
|
cfg = self.get_config() or {}
|
|
try:
|
|
interval = max(60, int(cfg.get("poll_interval", 120) or 120))
|
|
except (TypeError, ValueError):
|
|
interval = 120
|
|
try:
|
|
max_events = max(1, int(cfg.get("max_events_per_push", 5) or 5))
|
|
except (TypeError, ValueError):
|
|
max_events = 5
|
|
return {
|
|
"token": (cfg.get("github_token") or "").strip(),
|
|
"poll_interval": interval,
|
|
"max_events": max_events,
|
|
"language": cfg.get("language", "en_US") or "en_US",
|
|
}
|
|
|
|
def get_language(self) -> str:
|
|
return self._poller_config()["language"]
|
|
|
|
def t(self, key: str, **kwargs) -> str:
|
|
return get_text(self.get_language(), key, **kwargs)
|
|
|
|
def _ensure_poller(self) -> _Poller:
|
|
"""Start (or refresh) the poller belonging to the invoking installation.
|
|
|
|
One installation's subscriptions are polled by exactly one task, keyed by
|
|
the full binding; a worker upgrade (same installation, new revision)
|
|
restarts it with the new binding so Host calls stay authorized.
|
|
"""
|
|
scope = self._current_scope()
|
|
binding = self.get_installation_binding()
|
|
handler = getattr(self, "plugin_runtime_handler", None)
|
|
inv_config = current_config(handler)
|
|
config = self._poller_config()
|
|
poller = self._pollers.get(scope)
|
|
if poller is not None and poller.binding == binding:
|
|
poller.config = config
|
|
return poller
|
|
if poller is not None and poller.task is not None and not poller.task.done():
|
|
poller.task.cancel()
|
|
poller = _Poller(scope, binding, config)
|
|
try:
|
|
poller.task = asyncio.create_task(
|
|
self._poll_loop(poller, handler, inv_config, binding)
|
|
)
|
|
except RuntimeError:
|
|
poller.task = None
|
|
self._pollers[scope] = poller
|
|
return poller
|
|
|
|
def __del__(self) -> None:
|
|
for poller in getattr(self, "_pollers", {}).values():
|
|
if poller.task is not None and not poller.task.done():
|
|
poller.task.cancel()
|
|
|
|
# --------------------------------------------------------------- http
|
|
|
|
def _headers(self, token: str) -> dict[str, str]:
|
|
h = {
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": "LangBot-GitHubKit",
|
|
}
|
|
if token:
|
|
h["Authorization"] = f"Bearer {token}"
|
|
return h
|
|
|
|
async def _get(
|
|
self,
|
|
path: str,
|
|
params: dict | None = None,
|
|
*,
|
|
token: Optional[str] = None,
|
|
) -> tuple[int, Any, dict]:
|
|
"""GET against the GitHub API. Returns (status, json_or_text, headers)."""
|
|
if token is None:
|
|
# Any invocation of this installation resumes its event poller.
|
|
self._ensure_poller()
|
|
token = self._poller_config()["token"]
|
|
url = path if path.startswith("http") else f"{API_BASE}{path}"
|
|
async with httpx.AsyncClient(timeout=20) as client:
|
|
r = await client.get(url, headers=self._headers(token), params=params or {})
|
|
try:
|
|
body = r.json()
|
|
except Exception:
|
|
body = r.text
|
|
return r.status_code, body, dict(r.headers)
|
|
|
|
def _err_msg(self, status: int, body: Any) -> str:
|
|
msg = ""
|
|
if isinstance(body, dict):
|
|
msg = body.get("message", "")
|
|
if status == 404:
|
|
return self.t("err.404")
|
|
if status in (401, 403):
|
|
if "rate limit" in str(msg).lower():
|
|
return self.t("err.rate")
|
|
return self.t("err.perm", status=status, msg=msg or self.t("err.perm_default"))
|
|
return self.t("err.generic", status=status, msg=msg or body)
|
|
|
|
# ----------------------------------------------------------- queries
|
|
|
|
async def repo_info(self, repo: str) -> str:
|
|
repo = _norm_repo(repo)
|
|
if not repo:
|
|
return self.t("repo.usage")
|
|
status, body, _ = await self._get(f"/repos/{repo}")
|
|
if status != 200:
|
|
return self._err_msg(status, body)
|
|
dash = self.t("repo.dash")
|
|
lang = body.get("language") or dash
|
|
lic = (body.get("license") or {}).get("spdx_id") or dash
|
|
desc = body.get("description") or self.t("repo.no_desc")
|
|
homepage = body.get("homepage")
|
|
out = self.t(
|
|
"repo.body",
|
|
full_name=body["full_name"],
|
|
desc=desc,
|
|
stars=body["stargazers_count"],
|
|
forks=body["forks_count"],
|
|
watchers=body["watchers_count"],
|
|
issues=body["open_issues_count"],
|
|
lang=lang,
|
|
lic=lic,
|
|
branch=body.get("default_branch", dash),
|
|
url=body["html_url"],
|
|
)
|
|
if homepage:
|
|
out += "\n" + self.t("repo.homepage", homepage=homepage)
|
|
return out
|
|
|
|
async def list_issues(self, repo: str, state: str = "open") -> str:
|
|
repo = _norm_repo(repo)
|
|
if not repo:
|
|
return self.t("issues.usage")
|
|
state = state if state in ("open", "closed", "all") else "open"
|
|
status, body, _ = await self._get(
|
|
f"/repos/{repo}/issues",
|
|
{"state": state, "per_page": 10, "sort": "updated"},
|
|
)
|
|
if status != 200:
|
|
return self._err_msg(status, body)
|
|
issues = [i for i in body if "pull_request" not in i]
|
|
if not issues:
|
|
return self.t("issues.none", repo=repo, state=state)
|
|
lines = [self.t("issues.header", repo=repo, state=state)]
|
|
for i in issues[:10]:
|
|
labels = ",".join(l["name"] for l in i.get("labels", [])[:3])
|
|
ltxt = f" [{labels}]" if labels else ""
|
|
lines.append(self.t("issues.item", number=i["number"], title=i["title"], labels=ltxt))
|
|
return "\n".join(lines)
|
|
|
|
async def list_prs(self, repo: str, state: str = "open") -> str:
|
|
repo = _norm_repo(repo)
|
|
if not repo:
|
|
return self.t("prs.usage")
|
|
state = state if state in ("open", "closed", "all") else "open"
|
|
status, body, _ = await self._get(
|
|
f"/repos/{repo}/pulls",
|
|
{"state": state, "per_page": 10, "sort": "updated", "direction": "desc"},
|
|
)
|
|
if status != 200:
|
|
return self._err_msg(status, body)
|
|
if not body:
|
|
return self.t("prs.none", repo=repo, state=state)
|
|
lines = [self.t("prs.header", repo=repo, state=state)]
|
|
for p in body[:10]:
|
|
draft = self.t("prs.draft") if p.get("draft") else ""
|
|
lines.append(
|
|
self.t("prs.item", number=p["number"], title=p["title"], draft=draft, user=p["user"]["login"])
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
async def issue_detail(self, repo: str, number: str) -> str:
|
|
repo = _norm_repo(repo)
|
|
if not repo or not number:
|
|
return self.t("issue.usage")
|
|
try:
|
|
n = int(str(number).lstrip("#"))
|
|
except ValueError:
|
|
return self.t("issue.bad_number")
|
|
status, body, _ = await self._get(f"/repos/{repo}/issues/{n}")
|
|
if status != 200:
|
|
return self._err_msg(status, body)
|
|
is_pr = "pull_request" in body
|
|
kind = self.t("issue.kind_pr") if is_pr else self.t("issue.kind_issue")
|
|
state = body["state"]
|
|
labels = ",".join(l["name"] for l in body.get("labels", []))
|
|
ltxt = self.t("issue.labels", labels=labels) if labels else ""
|
|
comments = body.get("comments", 0)
|
|
text = (body.get("body") or "").strip()
|
|
if len(text) > 400:
|
|
text = text[:400] + "\u2026"
|
|
body_txt = f"\n\n{text}" if text else ""
|
|
return self.t(
|
|
"issue.body",
|
|
emoji="\U0001f500" if is_pr else "\U0001f41b",
|
|
repo=repo,
|
|
kind=kind,
|
|
number=body["number"],
|
|
state=state,
|
|
title=body["title"],
|
|
author=body["user"]["login"],
|
|
comments=comments,
|
|
labels=ltxt,
|
|
url=body["html_url"],
|
|
body=body_txt,
|
|
)
|
|
|
|
async def releases(self, repo: str) -> str:
|
|
repo = _norm_repo(repo)
|
|
if not repo:
|
|
return self.t("releases.usage")
|
|
status, body, _ = await self._get(
|
|
f"/repos/{repo}/releases", {"per_page": 5}
|
|
)
|
|
if status != 200:
|
|
return self._err_msg(status, body)
|
|
if not body:
|
|
return self.t("releases.none", repo=repo)
|
|
lines = [self.t("releases.header", repo=repo)]
|
|
for r in body[:5]:
|
|
pre = self.t("releases.prerelease") if r.get("prerelease") else ""
|
|
date = (r.get("published_at") or "")[:10]
|
|
lines.append(self.t("releases.item", tag=r["tag_name"], name=r.get("name", ""), pre=pre, date=date))
|
|
return "\n".join(lines)
|
|
|
|
async def user_info(self, username: str) -> str:
|
|
username = (username or "").strip().lstrip("@")
|
|
if not username:
|
|
return self.t("user.usage")
|
|
status, body, _ = await self._get(f"/users/{username}")
|
|
if status != 200:
|
|
return self._err_msg(status, body)
|
|
name = body.get("name") or body["login"]
|
|
bio = body.get("bio") or ""
|
|
biotxt = f"\n{bio}" if bio else ""
|
|
return self.t(
|
|
"user.body",
|
|
name=name,
|
|
login=body["login"],
|
|
bio=biotxt,
|
|
repos=body.get("public_repos", 0),
|
|
followers=body.get("followers", 0),
|
|
following=body.get("following", 0),
|
|
url=body["html_url"],
|
|
)
|
|
|
|
async def search_repos(self, query: str) -> str:
|
|
query = (query or "").strip()
|
|
if not query:
|
|
return self.t("search.usage")
|
|
status, body, _ = await self._get(
|
|
"/search/repositories",
|
|
{"q": query, "per_page": 8, "sort": "stars", "order": "desc"},
|
|
)
|
|
if status != 200:
|
|
return self._err_msg(status, body)
|
|
items = body.get("items", [])
|
|
if not items:
|
|
return self.t("search.none", query=query)
|
|
lines = [self.t("search.header", query=query)]
|
|
for it in items[:8]:
|
|
desc = it.get("description") or ""
|
|
if len(desc) > 50:
|
|
desc = desc[:50] + "\u2026"
|
|
lines.append(self.t("search.item", stars=it["stargazers_count"], full_name=it["full_name"], desc=desc))
|
|
return "\n".join(lines)
|
|
|
|
# ------------------------------------------------------- subscriptions
|
|
|
|
async def _load_subs(self, scope: str) -> dict[str, Any]:
|
|
try:
|
|
raw = await self.get_plugin_storage(storage_key(scope))
|
|
except Exception:
|
|
raw = None
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
return json.loads(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
|
|
except Exception:
|
|
return {}
|
|
|
|
async def _save_subs(self, scope: str, subs: dict[str, Any]) -> None:
|
|
await self.set_plugin_storage(
|
|
storage_key(scope), json.dumps(subs, ensure_ascii=False).encode("utf-8")
|
|
)
|
|
|
|
async def subscribe(
|
|
self,
|
|
repo: str,
|
|
session_id: str,
|
|
bot_uuid: str,
|
|
target_type: str,
|
|
target_id: str,
|
|
events: Optional[list[str]],
|
|
) -> str:
|
|
repo = _norm_repo(repo)
|
|
if not repo:
|
|
return self.t("sub.usage")
|
|
poller = self._ensure_poller()
|
|
token = poller.config["token"]
|
|
status, body, _ = await self._get(f"/repos/{repo}", token=token)
|
|
if status != 200:
|
|
return self._err_msg(status, body)
|
|
async with poller.lock:
|
|
subs = await self._load_subs(poller.scope)
|
|
entry = subs.setdefault(repo, {"subscribers": {}, "last_event_id": None})
|
|
if entry.get("last_event_id") is None:
|
|
st, ev, _ = await self._get(
|
|
f"/repos/{repo}/events", {"per_page": 1}, token=token
|
|
)
|
|
if st == 200 and isinstance(ev, list) and ev:
|
|
entry["last_event_id"] = ev[0]["id"]
|
|
entry["subscribers"][session_id] = {
|
|
"bot_uuid": bot_uuid,
|
|
"target_type": target_type,
|
|
"target_id": target_id,
|
|
"events": events, # None = all
|
|
"lang": self.get_language(),
|
|
"added_at": _now_iso(),
|
|
}
|
|
await self._save_subs(poller.scope, subs)
|
|
evtxt = self.t("sub.all_events") if not events else self.t("misc.sep").join(events)
|
|
return self.t(
|
|
"sub.ok",
|
|
repo=repo,
|
|
events=evtxt,
|
|
interval=poller.config["poll_interval"],
|
|
)
|
|
|
|
async def unsubscribe(self, repo: str, session_id: str) -> str:
|
|
repo = _norm_repo(repo)
|
|
if not repo:
|
|
return self.t("unsub.usage")
|
|
poller = self._ensure_poller()
|
|
async with poller.lock:
|
|
subs = await self._load_subs(poller.scope)
|
|
entry = subs.get(repo)
|
|
if not entry or session_id not in entry.get("subscribers", {}):
|
|
return self.t("unsub.not_subbed", repo=repo)
|
|
del entry["subscribers"][session_id]
|
|
if not entry["subscribers"]:
|
|
del subs[repo]
|
|
await self._save_subs(poller.scope, subs)
|
|
return self.t("unsub.ok", repo=repo)
|
|
|
|
async def list_subscriptions(self, session_id: str) -> str:
|
|
poller = self._ensure_poller()
|
|
subs = await self._load_subs(poller.scope)
|
|
mine = []
|
|
for repo, entry in subs.items():
|
|
sub = entry.get("subscribers", {}).get(session_id)
|
|
if sub:
|
|
events = sub.get("events")
|
|
evtxt = self.t("subs.all") if not events else self.t("misc.sep").join(events)
|
|
mine.append(self.t("subs.item", repo=repo, events=evtxt))
|
|
if not mine:
|
|
return self.t("subs.none")
|
|
return self.t("subs.header") + "\n" + "\n".join(mine)
|
|
|
|
# --------------------------------------------------------- poller
|
|
|
|
async def _poll_loop(self, poller: _Poller, handler, inv_config, binding) -> None:
|
|
"""Poll one installation's subscriptions inside its captured invocation.
|
|
|
|
A detached task does not inherit a live invocation, so the config and
|
|
binding captured when the poller started are re-entered explicitly; this
|
|
keeps every storage/send call inside the originating installation.
|
|
"""
|
|
with bind_invocation(handler, config=inv_config, binding=binding):
|
|
await asyncio.sleep(POLL_START_DELAY)
|
|
while True:
|
|
try:
|
|
await self._poll_once(poller)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(poller.config["poll_interval"])
|
|
|
|
async def _poll_once(self, poller: _Poller) -> None:
|
|
async with poller.lock:
|
|
subs = await self._load_subs(poller.scope)
|
|
repos = list(subs.keys())
|
|
if not repos:
|
|
return
|
|
for repo in repos:
|
|
try:
|
|
await self._poll_repo(poller, repo)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
continue
|
|
|
|
async def _poll_repo(self, poller: _Poller, repo: str) -> None:
|
|
status, events, _ = await self._get(
|
|
f"/repos/{repo}/events", {"per_page": 30}, token=poller.config["token"]
|
|
)
|
|
if status != 200 or not isinstance(events, list):
|
|
return
|
|
async with poller.lock:
|
|
subs = await self._load_subs(poller.scope)
|
|
entry = subs.get(repo)
|
|
if not entry or not entry.get("subscribers"):
|
|
return
|
|
last_id = entry.get("last_event_id")
|
|
new_events = []
|
|
for ev in events:
|
|
if last_id is not None and ev["id"] == last_id:
|
|
break
|
|
new_events.append(ev)
|
|
if events:
|
|
entry["last_event_id"] = events[0]["id"]
|
|
subscribers = dict(entry["subscribers"])
|
|
await self._save_subs(poller.scope, subs)
|
|
|
|
if not new_events or last_id is None:
|
|
return
|
|
|
|
new_events.reverse() # chronological order
|
|
new_events = new_events[-poller.config["max_events"] :]
|
|
|
|
for session_id, sub in subscribers.items():
|
|
wanted = sub.get("events") # None = all
|
|
lang = sub.get("lang", poller.config["language"])
|
|
msgs = []
|
|
for ev in new_events:
|
|
etype = _event_kind(ev)
|
|
if wanted and etype not in wanted:
|
|
continue
|
|
line = _format_event(lang, repo, ev)
|
|
if line:
|
|
msgs.append(line)
|
|
if not msgs:
|
|
continue
|
|
text = get_text(lang, "push.header", repo=repo) + "\n" + "\n".join(msgs)
|
|
await self._push(sub, text)
|
|
|
|
async def _push(self, sub: dict, text: str) -> None:
|
|
from langbot_plugin.api.entities.builtin.platform import (
|
|
message as platform_message,
|
|
)
|
|
|
|
try:
|
|
await self.send_message(
|
|
sub["bot_uuid"],
|
|
sub["target_type"],
|
|
str(sub["target_id"]),
|
|
platform_message.MessageChain(
|
|
[platform_message.Plain(text=text)]
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# --------------------------------------------------------------- helpers
|
|
|
|
|
|
def _norm_repo(repo: str) -> str:
|
|
repo = (repo or "").strip()
|
|
if repo.startswith("http"):
|
|
repo = repo.rstrip("/")
|
|
parts = repo.split("/")
|
|
if len(parts) >= 2:
|
|
repo = f"{parts[-2]}/{parts[-1]}"
|
|
repo = repo.removesuffix(".git")
|
|
if repo.count("/") != 1:
|
|
return ""
|
|
return repo
|
|
|
|
|
|
# Map GitHub event API types to short kind tokens used in user filters.
|
|
_EVENT_TYPE_MAP = {
|
|
"PushEvent": "push",
|
|
"PullRequestEvent": "pull_request",
|
|
"IssuesEvent": "issues",
|
|
"IssueCommentEvent": "issue_comment",
|
|
"ReleaseEvent": "release",
|
|
"WatchEvent": "star",
|
|
"ForkEvent": "fork",
|
|
"CreateEvent": "create",
|
|
"DeleteEvent": "delete",
|
|
"PullRequestReviewEvent": "pr_review",
|
|
"PullRequestReviewCommentEvent": "pr_review_comment",
|
|
}
|
|
|
|
# Friendly aliases users may type for the event filter.
|
|
EVENT_ALIASES = {
|
|
"push": "push",
|
|
"commit": "push",
|
|
"commits": "push",
|
|
"pr": "pull_request",
|
|
"prs": "pull_request",
|
|
"pull_request": "pull_request",
|
|
"issue": "issues",
|
|
"issues": "issues",
|
|
"comment": "issue_comment",
|
|
"release": "release",
|
|
"releases": "release",
|
|
"star": "star",
|
|
"stars": "star",
|
|
"fork": "fork",
|
|
"forks": "fork",
|
|
}
|
|
|
|
|
|
def _event_kind(ev: dict) -> str:
|
|
return _EVENT_TYPE_MAP.get(ev.get("type", ""), ev.get("type", "").lower())
|
|
|
|
|
|
def _format_event(lang: str, repo: str, ev: dict) -> str:
|
|
etype = ev.get("type", "")
|
|
actor = (ev.get("actor") or {}).get("login", "?")
|
|
payload = ev.get("payload", {})
|
|
if etype == "PushEvent":
|
|
ref = (payload.get("ref") or "").split("/")[-1]
|
|
commits = payload.get("commits", [])
|
|
n = payload.get("size", len(commits))
|
|
head = ""
|
|
if commits:
|
|
msg = commits[-1].get("message", "").split("\n")[0]
|
|
if len(msg) > 60:
|
|
msg = msg[:60] + "\u2026"
|
|
head = get_text(lang, "ev.push_head", msg=msg)
|
|
return get_text(lang, "ev.push", actor=actor, n=n, ref=ref, head=head)
|
|
if etype == "PullRequestEvent":
|
|
action = payload.get("action", "")
|
|
pr = payload.get("pull_request", {})
|
|
num = pr.get("number", "")
|
|
title = pr.get("title", "")
|
|
amap = {
|
|
"opened": get_text(lang, "ev.pr_opened"),
|
|
"closed": get_text(lang, "ev.pr_closed"),
|
|
"reopened": get_text(lang, "ev.pr_reopened"),
|
|
}
|
|
if action == "closed" and pr.get("merged"):
|
|
amap["closed"] = get_text(lang, "ev.pr_merged")
|
|
act = amap.get(action, action)
|
|
return get_text(lang, "ev.pr", actor=actor, action=act, num=num, title=title)
|
|
if etype == "IssuesEvent":
|
|
action = payload.get("action", "")
|
|
iss = payload.get("issue", {})
|
|
amap = {
|
|
"opened": get_text(lang, "ev.issue_opened"),
|
|
"closed": get_text(lang, "ev.issue_closed"),
|
|
"reopened": get_text(lang, "ev.issue_reopened"),
|
|
}
|
|
act = amap.get(action, action)
|
|
return get_text(lang, "ev.issue", actor=actor, action=act, num=iss.get("number", ""), title=iss.get("title", ""))
|
|
if etype == "IssueCommentEvent":
|
|
iss = payload.get("issue", {})
|
|
return get_text(lang, "ev.comment", actor=actor, num=iss.get("number", ""), title=iss.get("title", ""))
|
|
if etype == "ReleaseEvent":
|
|
rel = payload.get("release", {})
|
|
return get_text(lang, "ev.release", actor=actor, tag=rel.get("tag_name", ""))
|
|
if etype == "WatchEvent":
|
|
return get_text(lang, "ev.star", actor=actor)
|
|
if etype == "ForkEvent":
|
|
return get_text(lang, "ev.fork", actor=actor)
|
|
if etype == "CreateEvent":
|
|
return get_text(lang, "ev.create", actor=actor, ref_type=payload.get("ref_type", ""), ref=payload.get("ref", "") or "").rstrip()
|
|
if etype == "DeleteEvent":
|
|
return get_text(lang, "ev.delete", actor=actor, ref_type=payload.get("ref_type", ""), ref=payload.get("ref", "") or "").rstrip()
|
|
if etype == "PullRequestReviewEvent":
|
|
pr = payload.get("pull_request", {})
|
|
return get_text(lang, "ev.review", actor=actor, num=pr.get("number", ""))
|
|
return ""
|