Add Gitea support to git monitor (#9)

Adds polling and URL parsing support for gitea repositories.

Reviewed-on: #9
Co-authored-by: Slipstream <me@slipstreamm.dev>
Co-committed-by: Slipstream <me@slipstreamm.dev>
This commit is contained in:
Slipstream 2025-06-11 13:07:04 -06:00 committed by slipstream
parent f142e0b8cf
commit 5cbece7420
3 changed files with 125 additions and 50 deletions

View File

@ -9,6 +9,7 @@ from typing import Literal, Optional, List, Dict, Any
import asyncio # For sleep
import aiohttp # For API calls
import requests.utils # For url encoding gitlab project path
from urllib.parse import urlparse
# Assuming settings_manager is in the parent directory
# Adjust the import path if your project structure is different
@ -23,18 +24,29 @@ log = logging.getLogger(__name__)
# Helper to parse repo URL and determine platform
def parse_repo_url(url: str) -> tuple[Optional[str], Optional[str]]:
"""Parses a Git repository URL to extract platform and a simplified repo identifier."""
# Fixed regex pattern for GitHub URLs
github_match = re.match(
r"^(?:https?://)?(?:www\.)?github\.com/([\w.-]+/[\w.-]+)(?:\.git)?/?$", url
)
if github_match:
return "github", github_match.group(1)
if not re.match(r"^https?://", url):
url = "https://" + url
gitlab_match = re.match(
r"^(?:https?://)?(?:www\.)?gitlab\.com/([\w.-]+(?:/[\w.-]+)+)(?:\.git)?/?$", url
)
if gitlab_match:
return "gitlab", gitlab_match.group(1)
try:
parsed = urlparse(url)
except Exception:
return None, None
host = parsed.netloc.lower()
path_parts = [p for p in parsed.path.strip("/").split("/") if p]
if host.endswith("github.com"):
if len(path_parts) >= 2:
return "github", "/".join(path_parts[:2])
return None, None
if host.endswith("gitlab.com"):
if len(path_parts) >= 2:
return "gitlab", "/".join(path_parts)
return None, None
if host and len(path_parts) >= 2:
return "gitea", "/".join(path_parts[:2])
return None, None
@ -179,6 +191,45 @@ class GitMonitorCog(commands.Cog):
log.error(
f"Error fetching GitLab commits for {repo_url}: {response.status} - {await response.text()}"
)
elif platform == "gitea":
parsed = urlparse(
repo_url
if repo_url.startswith("http")
else "https://" + repo_url
)
owner_repo = "/".join(
[p for p in parsed.path.strip("/").split("/") if p][:2]
)
if owner_repo:
api_url = f"https://{parsed.netloc}/api/v1/repos/{owner_repo}/commits"
params = {"limit": 10}
if target_branch:
params["sha"] = target_branch
async with session.get(
api_url, params=params
) as response:
if response.status == 200:
commits_payload = await response.json()
temp_new_commits = []
for commit_item in reversed(commits_payload):
if commit_item["sha"] == last_sha:
temp_new_commits = []
continue
temp_new_commits.append(commit_item)
if temp_new_commits:
new_commits_data = temp_new_commits
latest_fetched_sha = new_commits_data[-1][
"sha"
]
elif response.status == 404:
log.error(
f"Repository {repo_url} not found on Gitea (404)."
)
else:
log.error(
f"Error fetching Gitea commits for {repo_url}: {response.status} - {await response.text()}"
)
except aiohttp.ClientError as ce:
log.error(f"AIOHTTP client error polling {repo_url}: {ce}")
except Exception as ex:
@ -195,14 +246,8 @@ class GitMonitorCog(commands.Cog):
commit_data = commit_item_data.get("commit", {})
commit_msg = commit_data.get("message", "No message.")
commit_url = commit_item_data.get("html_url", "#")
author_info = commit_data.get(
"author", {}
) # Committer info is also available
author_info = commit_data.get("author", {})
author_name = author_info.get("name", "Unknown Author")
# Branch information is not directly available in this specific commit object from /commits endpoint.
# It's part of the push event or needs to be inferred/fetched differently for polling.
# For polling, we typically monitor a specific branch, or assume default.
# Verification status
verification = commit_data.get("verification", {})
verified_status = (
"Verified"
@ -216,19 +261,12 @@ class GitMonitorCog(commands.Cog):
verified_status += (
f" ({verification.get('reason')})"
)
# Files changed and stats require another API call per commit: GET /repos/{owner}/{repo}/commits/{sha}
# This is too API intensive for a simple polling loop.
# We will omit detailed file stats for polled GitHub commits for now.
files_changed_str = (
"File stats not fetched for polled commits."
)
embed = discord.Embed(
title=f"New Commit in {repo_url}",
description=commit_msg.splitlines()[
0
], # First line
description=commit_msg.splitlines()[0],
color=discord.Color.blue(),
url=commit_url,
)
@ -243,13 +281,11 @@ class GitMonitorCog(commands.Cog):
value=verified_status,
inline=True,
)
# embed.add_field(name="Branch", value="default (polling)", inline=True) # Placeholder
embed.add_field(
name="Changes",
value=files_changed_str,
inline=False,
)
elif platform == "gitlab":
commit_id = commit_item_data.get("id", "N/A")
commit_id_short = commit_item_data.get(
@ -257,18 +293,14 @@ class GitMonitorCog(commands.Cog):
)
commit_msg = commit_item_data.get(
"title", "No message."
) # GitLab uses 'title' for first line
)
commit_url = commit_item_data.get("web_url", "#")
author_name = commit_item_data.get(
"author_name", "Unknown Author"
)
# Branch information is not directly in this commit object from /commits.
# It's part of the push event or needs to be inferred.
# GitLab commit stats (added/deleted lines) are in the commit details, not list.
files_changed_str = (
"File stats not fetched for polled commits."
)
embed = discord.Embed(
title=f"New Commit in {repo_url}",
description=commit_msg.splitlines()[0],
@ -281,13 +313,42 @@ class GitMonitorCog(commands.Cog):
value=f"[`{commit_id_short}`]({commit_url})",
inline=True,
)
# embed.add_field(name="Branch", value="default (polling)", inline=True) # Placeholder
embed.add_field(
name="Changes",
value=files_changed_str,
inline=False,
)
elif platform == "gitea":
commit_sha = commit_item_data.get("sha", "N/A")
commit_id_short = commit_sha[:7]
commit_msg = commit_item_data.get("commit", {}).get(
"message", "No message."
)
commit_url = commit_item_data.get("html_url", "#")
author_info = commit_item_data.get("commit", {}).get(
"author", {}
)
author_name = author_info.get("name", "Unknown Author")
files_changed_str = (
"File stats not fetched for polled commits."
)
embed = discord.Embed(
title=f"New Commit in {repo_url}",
description=commit_msg.splitlines()[0],
color=discord.Color.green(),
url=commit_url,
)
embed.set_author(name=author_name)
embed.add_field(
name="Commit",
value=f"[`{commit_id_short}`]({commit_url})",
inline=True,
)
embed.add_field(
name="Changes",
value=files_changed_str,
inline=False,
)
if embed:
try:
await channel.send(embed=embed)
@ -336,7 +397,7 @@ class GitMonitorCog(commands.Cog):
name="add", description="Add a repository to monitor for commits."
)
@app_commands.describe(
repository_url="The full URL of the GitHub or GitLab repository (e.g., https://github.com/user/repo).",
repository_url="The full URL of the GitHub, GitLab, or Gitea repository (e.g., https://github.com/user/repo).",
channel="The channel where commit notifications should be sent.",
monitoring_method="Choose 'webhook' for real-time (requires repo admin rights) or 'poll' for periodic checks.",
branch="The specific branch to monitor (for 'poll' method, defaults to main/master if not specified).",
@ -366,7 +427,7 @@ class GitMonitorCog(commands.Cog):
) # Use cleaned URL
if not platform or not repo_identifier:
await interaction.followup.send(
f"Invalid repository URL: `{repository_url}`. Please provide a valid GitHub or GitLab URL (e.g., https://github.com/user/repo).",
f"Invalid repository URL: `{repository_url}`. Please provide a valid GitHub, GitLab, or Gitea URL (e.g., https://github.com/user/repo).",
ephemeral=True,
)
return

View File

@ -27,6 +27,8 @@ from cogs.git_monitor_cog import parse_repo_url
"http://www.gitlab.com/group/subgroup/project/",
("gitlab", "group/subgroup/project"),
),
("https://gitea.com/user/repo", ("gitea", "user/repo")),
("https://git.example.com/org/repo", ("gitea", "org/repo")),
],
)
def test_parse_repo_url_valid(url, expected):
@ -43,7 +45,6 @@ def test_parse_repo_url_valid(url, expected):
"ftp://github.com/user/repo",
"http:/github.com/user/repo",
"not a url",
"https://gitlabx.com/group/project",
"gitlab.com/group//project",
"github.com/user/repo/extra",
],

View File

@ -1,23 +1,34 @@
import re
from typing import Optional, Tuple
from urllib.parse import urlparse
import pytest
def parse_repo_url(url: str) -> Tuple[Optional[str], Optional[str]]:
"""Parses a Git repository URL and returns platform and repo id."""
github_match = re.match(
r"^(?:https?://)?(?:www\.)?github\.com/([\w.-]+/[\w.-]+)(?:\.git)?/?$",
url,
)
if github_match:
return "github", github_match.group(1)
if not re.match(r"^https?://", url):
url = "https://" + url
gitlab_match = re.match(
r"^(?:https?://)?(?:www\.)?gitlab\.com/([\w.-]+(?:/[\w.-]+)+)(?:\.git)?/?$",
url,
)
if gitlab_match:
return "gitlab", gitlab_match.group(1)
try:
parsed = urlparse(url)
except Exception:
return None, None
host = parsed.netloc.lower()
parts = [p for p in parsed.path.strip("/").split("/") if p]
if host.endswith("github.com"):
if len(parts) >= 2:
return "github", "/".join(parts[:2])
return None, None
if host.endswith("gitlab.com"):
if len(parts) >= 2:
return "gitlab", "/".join(parts)
return None, None
if host and len(parts) >= 2:
return "gitea", "/".join(parts[:2])
return None, None
@ -40,6 +51,8 @@ def parse_repo_url(url: str) -> Tuple[Optional[str], Optional[str]]:
"https://gitlab.com/group/subgroup/project",
("gitlab", "group/subgroup/project"),
),
("https://gitea.com/org/repo", ("gitea", "org/repo")),
("https://mygit.example/repo1/project", ("gitea", "repo1/project")),
("invalid-url", (None, None)),
],
)