0.3.9.5.a3

Transitioned from RSS-based commit message fetching to API-based fetching for commit messages
This commit is contained in:
Franz Rolfsvaag 2025-08-11 01:32:16 +02:00
parent 2a898802b6
commit 0038a1889c
2 changed files with 73 additions and 79 deletions

2
bot.py
View File

@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
# Version consists of: # Version consists of:
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update) # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.3.9.5.a2" VERSION = "0.3.9.5.a3"
# ---------- Env loading ---------- # ---------- Env loading ----------

View File

@ -4,7 +4,7 @@ import re
import base64 import base64
import json import json
import time import time
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone
from urllib.parse import urlparse from urllib.parse import urlparse
import discord import discord
@ -38,23 +38,20 @@ def _split_subject_body(full_message: str) -> tuple[str | None, str | None]:
def _cmp_versions(a: str | None, b: str | None) -> int: def _cmp_versions(a: str | None, b: str | None) -> int:
""" """
Compare your version style: 1.2.3.4.a2 (last segment alnum optional). Compare versions like 1.2.3.4.a2 (last segment may be alnum).
Returns: -1 if a<b, 0 if equal/unknown, +1 if a>b. Returns -1 if a<b, 0 if equal/unknown, +1 if a>b.
If either is None, treat as equal (0) to avoid false rollback/upgrade. If either is None, treat as equal to avoid false positives.
""" """
if not a or not b: if not a or not b:
return 0 return 0
pa = a.split('.') pa = a.split('.')
pb = b.split('.') pb = b.split('.')
# pad to 5 parts
while len(pa) < 5: pa.append('0') while len(pa) < 5: pa.append('0')
while len(pb) < 5: pb.append('0') while len(pb) < 5: pb.append('0')
def part_key(x: str): def part_key(x: str):
# numeric if digits; else (numeric_prefix, alpha_suffix)
if x.isdigit(): if x.isdigit():
return (int(x), '', 1) return (int(x), '', 1)
# split alnum: digits prefix (if any) + rest
m = re.match(r'(\d+)(.*)', x) m = re.match(r'(\d+)(.*)', x)
if m: if m:
return (int(m.group(1)), m.group(2), 2) return (int(m.group(1)), m.group(2), 2)
@ -65,7 +62,7 @@ def _cmp_versions(a: str | None, b: str | None) -> int:
if ka[0] != kb[0]: if ka[0] != kb[0]:
return 1 if ka[0] > kb[0] else -1 return 1 if ka[0] > kb[0] else -1
if ka[2] != kb[2]: if ka[2] != kb[2]:
return 1 if ka[2] < kb[2] else -1 # prefer pure numeric (1) > num+alpha (2) > alpha (3) return 1 if ka[2] < kb[2] else -1
if ka[1] != kb[1]: if ka[1] != kb[1]:
return 1 if ka[1] > kb[1] else -1 return 1 if ka[1] > kb[1] else -1
return 0 return 0
@ -106,48 +103,51 @@ async def _gitea_get_json(url: str, token: str | None, user: str | None, timeout
if resp.status != 200: if resp.status != 200:
text = await resp.text() text = await resp.text()
raise RuntimeError(f"Gitea GET {url} -> {resp.status}: {text[:200]}") raise RuntimeError(f"Gitea GET {url} -> {resp.status}: {text[:200]}")
# Gitea returns either dict or list depending on endpoint
ctype = resp.headers.get("Content-Type", "")
if "application/json" not in ctype:
text = await resp.text()
raise RuntimeError(f"Gitea GET {url} non-JSON: {ctype} {text[:200]}")
return await resp.json() return await resp.json()
async def _fetch_latest_commit(api_base: str, owner: str, repo: str, branch: str, async def _fetch_latest_commit(api_base: str, owner: str, repo: str, branch: str,
token: str | None, user: str | None) -> tuple[str | None, str | None, str | None]: token: str | None, user: str | None) -> tuple[str | None, str | None, str | None]:
""" """
Returns (sha, subject, body) for the latest commit on branch. Returns (sha, subject, body) for the latest commit on branch using the
list-commits endpoint which includes the full commit message:
/api/v1/repos/{owner}/{repo}/commits?sha={branch}&limit=1&stat=false&verification=false&files=false
""" """
# Fast path: get branch -> commit sha url = (
branch_url = f"{api_base}/repos/{owner}/{repo}/branches/{branch}" f"{api_base}/repos/{owner}/{repo}/commits"
f"?sha={branch}&limit=1&stat=false&verification=false&files=false"
)
data = await _gitea_get_json(url, token, user)
if not isinstance(data, list) or not data:
raise RuntimeError("Commits list empty or invalid.")
latest = data[0]
sha = latest.get("sha") or latest.get("id")
message = ""
commit_obj = latest.get("commit") or {}
if isinstance(commit_obj, dict):
message = commit_obj.get("message") or ""
if not message:
# Extremely unlikely on this endpoint, but try secondary fetch
if sha:
for endpoint in (
f"{api_base}/repos/{owner}/{repo}/git/commits/{sha}",
f"{api_base}/repos/{owner}/{repo}/commits/{sha}",
):
try: try:
bjson = await _gitea_get_json(branch_url, token, user) det = await _gitea_get_json(endpoint, token, user)
sha = bjson.get('commit', {}).get('id') or bjson.get('commit', {}).get('sha') if isinstance(det, dict):
if not sha: m = det.get("message") or det.get("commit", {}).get("message")
raise RuntimeError("No commit sha on branch") if m:
except Exception as e: message = str(m)
# Fallback: list commits break
commits_url = f"{api_base}/repos/{owner}/{repo}/commits/{branch}"
try:
cjson = await _gitea_get_json(commits_url, token, user)
if isinstance(cjson, list) and cjson:
sha = cjson[0].get('sha') or cjson[0].get('id')
else:
raise RuntimeError("Empty commits list")
except Exception as e2:
raise RuntimeError(f"Failed to get latest commit: {e} / {e2}")
# Now fetch full commit message
# Try git/commits first
for endpoint in (f"{api_base}/repos/{owner}/{repo}/git/commits/{sha}",
f"{api_base}/repos/{owner}/{repo}/commits/{sha}"):
try:
data = await _gitea_get_json(endpoint, token, user)
msg = None
if isinstance(data, dict):
msg = data.get('message')
if not msg:
msg = data.get('commit', {}).get('message')
subject, body = _split_subject_body(msg or "")
return sha, (subject or ""), (body or "")
except Exception: except Exception:
continue continue
raise RuntimeError("Unable to fetch commit details") subject, body = _split_subject_body(message)
return sha, (subject or ""), (body or "")
# ---------------- Boot reason inference ---------------- # ---------------- Boot reason inference ----------------
@ -178,17 +178,12 @@ def _format_status_line(kind: str, old_ver: str | None, new_ver: str | None) ->
async def post_boot_notice(bot): async def post_boot_notice(bot):
""" """
Always post a boot status to the modlog channel. Always post a boot status to the modlog channel.
Logic: - Waits for ready.
- Wait until bot is ready (guilds/channels cached). - Infers reason (updated/scheduled/manual/rollback).
- Resolve repo from cfg(repo_url/repo_branch); attempt to fetch latest commit (sha, subject, body). - Fetches latest commit (full message) via list-commits endpoint.
- Compare to stored boot_state (last_sha/last_version/last_boot_ts): - Posts status + commit message (Version bold + md details).
* sha/version advanced -> Updated - Pings guild owner only on rollback.
* sha same and near scheduled time -> Scheduled restart - Persists last sha/version in data_manager['boot_state'].
* sha same and not near schedule -> Manual restart
* version decreased -> Rollback (ping guild owner)
- Post status line.
- Post commit message (bold version + md body).
- Persist new boot_state.
""" """
try: try:
await bot.wait_until_ready() await bot.wait_until_ready()
@ -203,7 +198,6 @@ async def post_boot_notice(bot):
ch = bot.get_channel(modlog_channel_id) ch = bot.get_channel(modlog_channel_id)
if not ch: if not ch:
# fallback: search across guilds
for g in bot.guilds: for g in bot.guilds:
ch = g.get_channel(modlog_channel_id) ch = g.get_channel(modlog_channel_id)
if ch: if ch:
@ -216,17 +210,19 @@ async def post_boot_notice(bot):
r = cfg(bot) r = cfg(bot)
repo_url = r.get('repo_url', '') repo_url = r.get('repo_url', '')
branch = r.get('repo_branch', 'main') branch = r.get('repo_branch', 'main')
api_base = owner = repo = None
if repo_url:
api_base, owner, repo = _parse_repo_url(repo_url)
token = os.getenv("SHAI_GITEA_TOKEN", "").strip() or None
user = os.getenv("SHAI_GITEA_USER", "").strip() or None
check_time_utc = r.get('check_time_utc', '') # e.g., "03:00" check_time_utc = r.get('check_time_utc', '') # e.g., "03:00"
now_utc = datetime.now(timezone.utc) now_utc = datetime.now(timezone.utc)
# State store api_base = owner = repo = None
if repo_url:
api_base, owner, repo = _parse_repo_url(repo_url)
else:
print("[boot_notice] repo_url missing; commit lookup skipped.")
token = os.getenv("SHAI_GITEA_TOKEN", "").strip() or None
user = os.getenv("SHAI_GITEA_USER", "").strip() or None
# State
dm = getattr(bot, "data_manager", None) dm = getattr(bot, "data_manager", None)
if not dm: if not dm:
print("[boot_notice] data_manager missing on bot; cannot persist state.") print("[boot_notice] data_manager missing on bot; cannot persist state.")
@ -243,8 +239,9 @@ async def post_boot_notice(bot):
sha, subject, body = await _fetch_latest_commit(api_base, owner, repo, branch, token, user) sha, subject, body = await _fetch_latest_commit(api_base, owner, repo, branch, token, user)
except Exception as e: except Exception as e:
print(f"[boot_notice] fetch latest commit failed: {e}") print(f"[boot_notice] fetch latest commit failed: {e}")
else:
print("[boot_notice] repo parsing failed; commit lookup skipped.")
# Derive current version (from subject)
curr_ver = _extract_version(subject) if subject else None curr_ver = _extract_version(subject) if subject else None
# Decide reason # Decide reason
@ -258,10 +255,9 @@ async def post_boot_notice(bot):
elif cmpv > 0: elif cmpv > 0:
reason = "rollback" reason = "rollback"
mention_owner = True mention_owner = True
else: # same version else:
reason = "scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual" reason = "scheduled" if _is_near_scheduled(now_utc, check_time_utc) else "manual"
else: else:
# Fall back to sha compare if versions missing
if prev_sha and sha and prev_sha != sha: if prev_sha and sha and prev_sha != sha:
reason = "updated" reason = "updated"
else: else:
@ -270,8 +266,12 @@ async def post_boot_notice(bot):
# Post status line # Post status line
status_line = _format_status_line(reason, prev_ver, curr_ver) status_line = _format_status_line(reason, prev_ver, curr_ver)
try: try:
# ping owner only on rollback allowed = discord.AllowedMentions(
allowed = discord.AllowedMentions(everyone=False, users=True if mention_owner else False, roles=False, replied_user=False) everyone=False,
users=True if mention_owner else False,
roles=False,
replied_user=False
)
if mention_owner and ch.guild and ch.guild.owner_id: if mention_owner and ch.guild and ch.guild.owner_id:
status_line = f"{status_line}\n<@{ch.guild.owner_id}>" status_line = f"{status_line}\n<@{ch.guild.owner_id}>"
await ch.send(status_line, allowed_mentions=allowed) await ch.send(status_line, allowed_mentions=allowed)
@ -279,21 +279,16 @@ async def post_boot_notice(bot):
print(f"[boot_notice] failed to send status line: {e}") print(f"[boot_notice] failed to send status line: {e}")
return return
# Post commit message (if we have it) # Post commit message (Version + md details)
# Format: **Version**\n<md body>
try: try:
title = curr_ver or (subject or "Latest commit") title = curr_ver or (subject or "Latest commit")
if title or body: if title or body:
# Always post a commit message on start; its the core “whats running now” commit_msg = f"**{title}**\n{body}" if body else f"**{title}**"
if body:
commit_msg = f"**{title}**\n{body}"
else:
commit_msg = f"**{title}**"
await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none()) await ch.send(commit_msg, allowed_mentions=discord.AllowedMentions.none())
except Exception as e: except Exception as e:
print(f"[boot_notice] failed to send commit message: {e}") print(f"[boot_notice] failed to send commit message: {e}")
# Persist new state # Persist state
try: try:
new_state = { new_state = {
'last_sha': sha, 'last_sha': sha,
@ -301,7 +296,6 @@ async def post_boot_notice(bot):
'last_subject': subject, 'last_subject': subject,
'last_boot_ts': time.time(), 'last_boot_ts': time.time(),
} }
# keep boot_state as list to preserve history
dm.add('boot_state', new_state) dm.add('boot_state', new_state)
except Exception as e: except Exception as e:
print(f"[boot_notice] failed to persist boot_state: {e}") print(f"[boot_notice] failed to persist boot_state: {e}")