0.3.9.5.a3
Transitioned from RSS-based commit message fetching to API-based fetching for commit messages
This commit is contained in:
		
							parent
							
								
									2a898802b6
								
							
						
					
					
						commit
						0038a1889c
					
				
							
								
								
									
										2
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								bot.py
									
									
									
									
									
								
							@ -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; doesn’t trigger auto update)
 | 
					# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
				
			||||||
VERSION = "0.3.9.5.a2"
 | 
					VERSION = "0.3.9.5.a3"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ---------- Env loading ----------
 | 
					# ---------- Env loading ----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -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"
 | 
				
			||||||
    try:
 | 
					        f"?sha={branch}&limit=1&stat=false&verification=false&files=false"
 | 
				
			||||||
        bjson = await _gitea_get_json(branch_url, token, user)
 | 
					    )
 | 
				
			||||||
        sha = bjson.get('commit', {}).get('id') or bjson.get('commit', {}).get('sha')
 | 
					    data = await _gitea_get_json(url, token, user)
 | 
				
			||||||
        if not sha:
 | 
					    if not isinstance(data, list) or not data:
 | 
				
			||||||
            raise RuntimeError("No commit sha on branch")
 | 
					        raise RuntimeError("Commits list empty or invalid.")
 | 
				
			||||||
    except Exception as e:
 | 
					    latest = data[0]
 | 
				
			||||||
        # Fallback: list commits
 | 
					    sha = latest.get("sha") or latest.get("id")
 | 
				
			||||||
        commits_url = f"{api_base}/repos/{owner}/{repo}/commits/{branch}"
 | 
					    message = ""
 | 
				
			||||||
        try:
 | 
					    commit_obj = latest.get("commit") or {}
 | 
				
			||||||
            cjson = await _gitea_get_json(commits_url, token, user)
 | 
					    if isinstance(commit_obj, dict):
 | 
				
			||||||
            if isinstance(cjson, list) and cjson:
 | 
					        message = commit_obj.get("message") or ""
 | 
				
			||||||
                sha = cjson[0].get('sha') or cjson[0].get('id')
 | 
					    if not message:
 | 
				
			||||||
            else:
 | 
					        # Extremely unlikely on this endpoint, but try secondary fetch
 | 
				
			||||||
                raise RuntimeError("Empty commits list")
 | 
					        if sha:
 | 
				
			||||||
        except Exception as e2:
 | 
					            for endpoint in (
 | 
				
			||||||
            raise RuntimeError(f"Failed to get latest commit: {e} / {e2}")
 | 
					                f"{api_base}/repos/{owner}/{repo}/git/commits/{sha}",
 | 
				
			||||||
 | 
					                f"{api_base}/repos/{owner}/{repo}/commits/{sha}",
 | 
				
			||||||
    # Now fetch full commit message
 | 
					            ):
 | 
				
			||||||
    # Try git/commits first
 | 
					                try:
 | 
				
			||||||
    for endpoint in (f"{api_base}/repos/{owner}/{repo}/git/commits/{sha}",
 | 
					                    det = await _gitea_get_json(endpoint, token, user)
 | 
				
			||||||
                     f"{api_base}/repos/{owner}/{repo}/commits/{sha}"):
 | 
					                    if isinstance(det, dict):
 | 
				
			||||||
        try:
 | 
					                        m = det.get("message") or det.get("commit", {}).get("message")
 | 
				
			||||||
            data = await _gitea_get_json(endpoint, token, user)
 | 
					                        if m:
 | 
				
			||||||
            msg = None
 | 
					                            message = str(m)
 | 
				
			||||||
            if isinstance(data, dict):
 | 
					                            break
 | 
				
			||||||
                msg = data.get('message')
 | 
					                except Exception:
 | 
				
			||||||
                if not msg:
 | 
					                    continue
 | 
				
			||||||
                    msg = data.get('commit', {}).get('message')
 | 
					    subject, body = _split_subject_body(message)
 | 
				
			||||||
            subject, body = _split_subject_body(msg or "")
 | 
					    return sha, (subject or ""), (body or "")
 | 
				
			||||||
            return sha, (subject or ""), (body or "")
 | 
					 | 
				
			||||||
        except Exception:
 | 
					 | 
				
			||||||
            continue
 | 
					 | 
				
			||||||
    raise RuntimeError("Unable to fetch commit details")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ---------------- 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; it’s the core “what’s 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}")
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user