feat: improvements for Gitea integration

This commit is contained in:
Tomas Hruska 2025-10-19 16:00:01 +02:00
parent f2ba770558
commit bf1cc50ece
3 changed files with 162 additions and 14 deletions

View file

@ -1,4 +1,3 @@
import hashlib
import json import json
from typing import Any, Dict, List, Optional, Set, Tuple from typing import Any, Dict, List, Optional, Set, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
@ -31,15 +30,15 @@ class GiteaProvider(GitProvider):
self.pr_url = "" self.pr_url = ""
self.issue_url = "" self.issue_url = ""
gitea_access_token = get_settings().get("GITEA.PERSONAL_ACCESS_TOKEN", None) self.gitea_access_token = get_settings().get("GITEA.PERSONAL_ACCESS_TOKEN", None)
if not gitea_access_token: if not self.gitea_access_token:
self.logger.error("Gitea access token not found in settings.") self.logger.error("Gitea access token not found in settings.")
raise ValueError("Gitea access token not found in settings.") raise ValueError("Gitea access token not found in settings.")
self.repo_settings = get_settings().get("GITEA.REPO_SETTING", None) self.repo_settings = get_settings().get("GITEA.REPO_SETTING", None)
configuration = giteapy.Configuration() configuration = giteapy.Configuration()
configuration.host = "{}/api/v1".format(self.base_url) configuration.host = "{}/api/v1".format(self.base_url)
configuration.api_key['Authorization'] = f'token {gitea_access_token}' configuration.api_key['Authorization'] = f'token {self.gitea_access_token}'
if get_settings().get("GITEA.SKIP_SSL_VERIFICATION", False): if get_settings().get("GITEA.SKIP_SSL_VERIFICATION", False):
configuration.verify_ssl = False configuration.verify_ssl = False
@ -223,6 +222,19 @@ class GiteaProvider(GitProvider):
def get_issue_url(self) -> str: def get_issue_url(self) -> str:
return self.issue_url return self.issue_url
def get_latest_commit_url(self) -> str:
return self.last_commit.html_url
def get_comment_url(self, comment) -> str:
return comment.html_url
def publish_persistent_comment(self, pr_comment: str,
initial_header: str,
update_header: bool = True,
name='review',
final_update_message=True):
self.publish_persistent_comment_full(pr_comment, initial_header, update_header, name, final_update_message)
def publish_comment(self, comment: str,is_temporary: bool = False) -> None: def publish_comment(self, comment: str,is_temporary: bool = False) -> None:
"""Publish a comment to the pull request""" """Publish a comment to the pull request"""
if is_temporary and not get_settings().config.publish_output_progress: if is_temporary and not get_settings().config.publish_output_progress:
@ -308,7 +320,7 @@ class GiteaProvider(GitProvider):
if not response: if not response:
self.logger.error("Failed to publish inline comment") self.logger.error("Failed to publish inline comment")
return None return
self.logger.info("Inline comment published") self.logger.info("Inline comment published")
@ -515,6 +527,13 @@ class GiteaProvider(GitProvider):
self.logger.info(f"Generated link: {link}") self.logger.info(f"Generated link: {link}")
return link return link
def get_pr_id(self):
try:
pr_id = f"{self.repo}/{self.pr_number}"
return pr_id
except:
return ""
def get_files(self) -> List[Dict[str, Any]]: def get_files(self) -> List[Dict[str, Any]]:
"""Get all files in the PR""" """Get all files in the PR"""
return [file.get("filename","") for file in self.git_files] return [file.get("filename","") for file in self.git_files]
@ -611,6 +630,9 @@ class GiteaProvider(GitProvider):
"""Check if the provider is supported""" """Check if the provider is supported"""
return True return True
def get_git_repo_url(self, issues_or_pr_url: str) -> str:
return f"{self.base_url}/{self.owner}/{self.repo}.git" #base_url / <OWNER>/<REPO>.git
def publish_description(self, pr_title: str, pr_body: str) -> None: def publish_description(self, pr_title: str, pr_body: str) -> None:
"""Publish PR description""" """Publish PR description"""
response = self.repo_api.edit_pull_request( response = self.repo_api.edit_pull_request(
@ -685,6 +707,35 @@ class GiteaProvider(GitProvider):
continue continue
self.logger.info(f"Removed initial comment: {comment.get('comment_id')}") self.logger.info(f"Removed initial comment: {comment.get('comment_id')}")
#Clone related
def _prepare_clone_url_with_token(self, repo_url_to_clone: str) -> str | None:
#For example, to clone:
#https://github.com/Codium-ai/pr-agent-pro.git
#Need to embed inside the github token:
#https://<token>@github.com/Codium-ai/pr-agent-pro.git
gitea_token = self.gitea_access_token
gitea_base_url = self.base_url
scheme = gitea_base_url.split("://")[0]
scheme += "://"
if not all([gitea_token, gitea_base_url]):
get_logger().error("Either missing auth token or missing base url")
return None
base_url = gitea_base_url.split(scheme)[1]
if not base_url:
get_logger().error(f"Base url: {gitea_base_url} has an empty base url")
return None
if base_url not in repo_url_to_clone:
get_logger().error(f"url to clone: {repo_url_to_clone} does not contain {base_url}")
return None
repo_full_name = repo_url_to_clone.split(base_url)[-1]
if not repo_full_name:
get_logger().error(f"url to clone: {repo_url_to_clone} is malformed")
return None
clone_url = scheme
clone_url += f"{gitea_token}@{base_url}{repo_full_name}"
return clone_url
class RepoApi(giteapy.RepositoryApi): class RepoApi(giteapy.RepositoryApi):
def __init__(self, client: giteapy.ApiClient): def __init__(self, client: giteapy.ApiClient):
@ -693,7 +744,7 @@ class RepoApi(giteapy.RepositoryApi):
self.logger = get_logger() self.logger = get_logger()
super().__init__(client) super().__init__(client)
def create_inline_comment(self, owner: str, repo: str, pr_number: int, body : str ,commit_id : str, comments: List[Dict[str, Any]]) -> None: def create_inline_comment(self, owner: str, repo: str, pr_number: int, body : str ,commit_id : str, comments: List[Dict[str, Any]]):
body = { body = {
"body": body, "body": body,
"comments": comments, "comments": comments,

View file

@ -1,6 +1,6 @@
import asyncio
import copy import copy
import os import os
import re
from typing import Any, Dict from typing import Any, Dict
from fastapi import APIRouter, FastAPI, HTTPException, Request, Response from fastapi import APIRouter, FastAPI, HTTPException, Request, Response
@ -10,7 +10,9 @@ from starlette_context import context
from starlette_context.middleware import RawContextMiddleware from starlette_context.middleware import RawContextMiddleware
from pr_agent.agent.pr_agent import PRAgent from pr_agent.agent.pr_agent import PRAgent
from pr_agent.algo.utils import update_settings_from_args
from pr_agent.config_loader import get_settings, global_settings from pr_agent.config_loader import get_settings, global_settings
from pr_agent.git_providers.utils import apply_repo_settings
from pr_agent.log import LoggingFormat, get_logger, setup_logger from pr_agent.log import LoggingFormat, get_logger, setup_logger
from pr_agent.servers.utils import verify_signature from pr_agent.servers.utils import verify_signature
@ -70,6 +72,9 @@ async def handle_request(body: Dict[str, Any], event: str):
# Handle different event types # Handle different event types
if event == "pull_request": if event == "pull_request":
if not should_process_pr_logic(body):
get_logger().debug(f"Request ignored: PR logic filtering")
return {}
if action in ["opened", "reopened", "synchronized"]: if action in ["opened", "reopened", "synchronized"]:
await handle_pr_event(body, event, action, agent) await handle_pr_event(body, event, action, agent)
elif event == "issue_comment": elif event == "issue_comment":
@ -90,12 +95,21 @@ async def handle_pr_event(body: Dict[str, Any], event: str, action: str, agent:
# Handle PR based on action # Handle PR based on action
if action in ["opened", "reopened"]: if action in ["opened", "reopened"]:
commands = get_settings().get("gitea.pr_commands", []) # commands = get_settings().get("gitea.pr_commands", [])
for command in commands: await _perform_commands_gitea("pr_commands", agent, body, api_url)
await agent.handle_request(api_url, command) # for command in commands:
# await agent.handle_request(api_url, command)
elif action == "synchronized": elif action == "synchronized":
# Handle push to PR # Handle push to PR
await agent.handle_request(api_url, "/review --incremental") commands_on_push = get_settings().get(f"gitea.push_commands", {})
handle_push_trigger = get_settings().get(f"gitea.handle_push_trigger", False)
if not commands_on_push or not handle_push_trigger:
get_logger().info("Push event, but no push commands found or push trigger is disabled")
return
get_logger().debug(f'A push event has been received: {api_url}')
await _perform_commands_gitea("push_commands", agent, body, api_url)
# for command in commands_on_push:
# await agent.handle_request(api_url, command)
async def handle_comment_event(body: Dict[str, Any], event: str, action: str, agent: PRAgent): async def handle_comment_event(body: Dict[str, Any], event: str, action: str, agent: PRAgent):
"""Handle comment events""" """Handle comment events"""
@ -113,6 +127,85 @@ async def handle_comment_event(body: Dict[str, Any], event: str, action: str, ag
await agent.handle_request(pr_url, comment_body) await agent.handle_request(pr_url, comment_body)
async def _perform_commands_gitea(commands_conf: str, agent: PRAgent, body: dict, api_url: str):
apply_repo_settings(api_url)
if commands_conf == "pr_commands" and get_settings().config.disable_auto_feedback: # auto commands for PR, and auto feedback is disabled
get_logger().info(f"Auto feedback is disabled, skipping auto commands for PR {api_url=}")
return
if not should_process_pr_logic(body): # Here we already updated the configuration with the repo settings
return {}
commands = get_settings().get(f"gitea.{commands_conf}")
if not commands:
get_logger().info(f"New PR, but no auto commands configured")
return
get_settings().set("config.is_auto_command", True)
for command in commands:
split_command = command.split(" ")
command = split_command[0]
args = split_command[1:]
other_args = update_settings_from_args(args)
new_command = ' '.join([command] + other_args)
get_logger().info(f"{commands_conf}. Performing auto command '{new_command}', for {api_url=}")
await agent.handle_request(api_url, new_command)
def should_process_pr_logic(body) -> bool:
try:
pull_request = body.get("pull_request", {})
title = pull_request.get("title", "")
pr_labels = pull_request.get("labels", [])
source_branch = pull_request.get("head", {}).get("ref", "")
target_branch = pull_request.get("base", {}).get("ref", "")
sender = body.get("sender", {}).get("login")
repo_full_name = body.get("repository", {}).get("full_name", "")
# logic to ignore PRs from specific repositories
ignore_repos = get_settings().get("CONFIG.IGNORE_REPOSITORIES", [])
if ignore_repos and repo_full_name:
if any(re.search(regex, repo_full_name) for regex in ignore_repos):
get_logger().info(f"Ignoring PR from repository '{repo_full_name}' due to 'config.ignore_repositories' setting")
return False
# logic to ignore PRs from specific users
ignore_pr_users = get_settings().get("CONFIG.IGNORE_PR_AUTHORS", [])
if ignore_pr_users and sender:
if any(re.search(regex, sender) for regex in ignore_pr_users):
get_logger().info(f"Ignoring PR from user '{sender}' due to 'config.ignore_pr_authors' setting")
return False
# logic to ignore PRs with specific titles
if title:
ignore_pr_title_re = get_settings().get("CONFIG.IGNORE_PR_TITLE", [])
if not isinstance(ignore_pr_title_re, list):
ignore_pr_title_re = [ignore_pr_title_re]
if ignore_pr_title_re and any(re.search(regex, title) for regex in ignore_pr_title_re):
get_logger().info(f"Ignoring PR with title '{title}' due to config.ignore_pr_title setting")
return False
# logic to ignore PRs with specific labels or source branches or target branches.
ignore_pr_labels = get_settings().get("CONFIG.IGNORE_PR_LABELS", [])
if pr_labels and ignore_pr_labels:
labels = [label['name'] for label in pr_labels]
if any(label in ignore_pr_labels for label in labels):
labels_str = ", ".join(labels)
get_logger().info(f"Ignoring PR with labels '{labels_str}' due to config.ignore_pr_labels settings")
return False
# logic to ignore PRs with specific source or target branches
ignore_pr_source_branches = get_settings().get("CONFIG.IGNORE_PR_SOURCE_BRANCHES", [])
ignore_pr_target_branches = get_settings().get("CONFIG.IGNORE_PR_TARGET_BRANCHES", [])
if pull_request and (ignore_pr_source_branches or ignore_pr_target_branches):
if any(re.search(regex, source_branch) for regex in ignore_pr_source_branches):
get_logger().info(
f"Ignoring PR with source branch '{source_branch}' due to config.ignore_pr_source_branches settings")
return False
if any(re.search(regex, target_branch) for regex in ignore_pr_target_branches):
get_logger().info(
f"Ignoring PR with target branch '{target_branch}' due to config.ignore_pr_target_branches settings")
return False
except Exception as e:
get_logger().error(f"Failed 'should_process_pr_logic': {e}")
return True
# FastAPI app setup # FastAPI app setup
middleware = [Middleware(RawContextMiddleware)] middleware = [Middleware(RawContextMiddleware)]
app = FastAPI(middleware=middleware) app = FastAPI(middleware=middleware)

View file

@ -290,7 +290,7 @@ push_commands = [
# Configure SSL validation for GitLab. Can be either set to the path of a custom CA or disabled entirely. # Configure SSL validation for GitLab. Can be either set to the path of a custom CA or disabled entirely.
# ssl_verify = true # ssl_verify = true
[gitea_app] [gitea]
url = "https://gitea.com" url = "https://gitea.com"
handle_push_trigger = false handle_push_trigger = false
pr_commands = [ pr_commands = [
@ -298,6 +298,10 @@ pr_commands = [
"/review", "/review",
"/improve", "/improve",
] ]
push_commands = [
"/describe",
"/review",
]
[bitbucket_app] [bitbucket_app]
pr_commands = [ pr_commands = [