diff --git a/apps/pr/admin.py b/apps/pr/admin.py index ec0eb46..a68102d 100644 --- a/apps/pr/admin.py +++ b/apps/pr/admin.py @@ -40,4 +40,16 @@ class ProjectConfigAdmin(AjaxAdmin): def save_model(self, request, obj, form, change): obj.create_by = request.user.username - return super().save_model(request, obj, form, change) \ No newline at end of file + return super().save_model(request, obj, form, change) + + +@admin.register(models.ProjectHistory) +class ProjectHistoryAdmin(admin.ModelAdmin): + """Admin配置""" + + list_display = ["project", "mr_url", "source_branch", "target_branch", "mr_title"] + list_filter = ["project"] + + def save_model(self, request, obj, form, change): + obj.create_by = request.user.username + return super().save_model(request, obj, form, change) diff --git a/apps/pr/migrations/0002_projecthistory.py b/apps/pr/migrations/0002_projecthistory.py new file mode 100644 index 0000000..b84d592 --- /dev/null +++ b/apps/pr/migrations/0002_projecthistory.py @@ -0,0 +1,108 @@ +# Generated by Django 5.1.6 on 2025-02-26 17:12 + +import django.db.models.deletion +import simplepro.components.fields +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pr", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectHistory", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ( + "uid", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + verbose_name="UUID", + ), + ), + ( + "create_at", + simplepro.components.fields.DateTimeField( + auto_now_add=True, db_index=True, verbose_name="创建时间" + ), + ), + ( + "update_at", + simplepro.components.fields.DateTimeField( + auto_now=True, verbose_name="更新时间" + ), + ), + ( + "delete_at", + simplepro.components.fields.DateTimeField( + blank=True, null=True, verbose_name="删除时间" + ), + ), + ( + "create_by", + simplepro.components.fields.CharField( + blank=True, max_length=32, null=True, verbose_name="创建人" + ), + ), + ( + "detail", + simplepro.components.fields.CharField( + blank=True, max_length=200, null=True, verbose_name="备注信息" + ), + ), + ( + "project_url", + simplepro.components.fields.CharField( + blank=True, max_length=256, null=True, verbose_name="项目地址" + ), + ), + ( + "mr_url", + simplepro.components.fields.CharField( + blank=True, max_length=256, null=True, verbose_name="MR地址" + ), + ), + ( + "source_branch", + simplepro.components.fields.CharField( + blank=True, max_length=16, null=True, verbose_name="源分支" + ), + ), + ( + "target_branch", + simplepro.components.fields.CharField( + blank=True, max_length=16, null=True, verbose_name="目标分支" + ), + ), + ( + "mr_title", + simplepro.components.fields.CharField( + blank=True, max_length=256, null=True, verbose_name="MR标题" + ), + ), + ( + "source_data", + models.JSONField(blank=True, null=True, verbose_name="源数据"), + ), + ( + "project", + simplepro.components.fields.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="pr.projectconfig", + verbose_name="项目配置", + ), + ), + ], + options={ + "verbose_name": "项目历史", + "verbose_name_plural": "项目历史", + }, + ), + ] diff --git a/apps/pr/models.py b/apps/pr/models.py index c7867f4..971f6e9 100644 --- a/apps/pr/models.py +++ b/apps/pr/models.py @@ -101,3 +101,40 @@ class ProjectConfig(BaseModel): def __str__(self): return f"{self.project_name}-{self.project_id}" + +class ProjectHistory(BaseModel): + """ + 项目历史表 + """ + project = fields.ForeignKey( + ProjectConfig, + null=True, + blank=True, + on_delete=models.SET_NULL, + verbose_name="项目配置", + ) + project_url = fields.CharField( + null=True, blank=True, max_length=256, verbose_name="项目地址" + ) + mr_url = fields.CharField( + null=True, blank=True, max_length=256, verbose_name="MR地址" + ) + source_branch = fields.CharField( + null=True, blank=True, max_length=16, verbose_name="源分支" + ) + target_branch = fields.CharField( + null=True, blank=True, max_length=16, verbose_name="目标分支" + ) + mr_title = fields.CharField( + null=True, blank=True, max_length=256, verbose_name="MR标题" + ) + source_data = models.JSONField( + null=True, blank=True, verbose_name="源数据" + ) + + class Meta: + verbose_name = "项目历史" + verbose_name_plural = "项目历史" + + def __str__(self): + return self.mr_title diff --git a/apps/pr/views.py b/apps/pr/views.py index 3fb612b..3310d19 100644 --- a/apps/pr/views.py +++ b/apps/pr/views.py @@ -1,4 +1,5 @@ import json +from urllib.parse import urlparse from pr import models from django.views import View @@ -6,6 +7,7 @@ from django.http import JsonResponse from utils.pr_agent import cli from utils.pr_agent.config_loader import get_settings +from utils.git_config import GitLabProvider from utils import constant @@ -39,77 +41,60 @@ def load_project_config( class WebHookView(View): + + @staticmethod + def select_git_provider(git_type): + """ + 根据git类型选择git服务提供商: 目前仅配置gitlab + :param git_type: + :return: + """ + if git_type == "gitlab": + return GitLabProvider() + else: + return GitLabProvider() + def post(self, request): - data = json.loads(request.body.decode('utf-8')) - if not data: + """ + 处理Git服务器的Webhook事件 + :param request: + :return: + """ + # 校验请求头 + headers = request.headers + GIT_TYPE = constant.UA_TYPE.get(headers.get("User-Agent").split("/")[0]) + + if not GIT_TYPE: + return JsonResponse(status=400, data={"error": "Invalid Git Type"}) + + # 校验请求体 + json_data = json.loads(request.body.decode('utf-8')) + if not json_data: return JsonResponse(status=400, data={"error": "Invalid JSON"}) - project_id = data.get('project', {}).get('id') or data.get('project_id') + # 获取对应处理类 + provider = self.select_git_provider(GIT_TYPE) + project_id = provider.get_project_id(request_json=json_data) if not project_id: return JsonResponse(status=400, data={"error": "Missing project ID"}) - project_config = models.ProjectConfig.objects.filter(project_id=project_id).first() - # AI模型配置 - api_base = project_config.git_config.pr_ai.api_base - api_key = project_config.git_config.pr_ai.api_key - model = project_config.git_config.pr_ai.llm_model - # Git服务器配置 - git_url = project_config.git_config.git_url - git_type = constant.GIT_TYPE[project_config.git_config.git_type][1] - access_token = project_config.git_config.access_token - project_secret = project_config.project_secret - project_commands = project_config.commands.split(",") + # 获取项目配置 + project_config = provider.get_project_config(project_id=project_id) - config = load_project_config( - git_url=git_url, - access_token=access_token, - project_secret=project_secret, - openai_api_base=api_base, - openai_key=api_key, - llm_model=model + # Token 校验 + provider.check_secret(request_headers=headers, project_secret=project_config.get("project_secret")) + + provider.get_merge_request( + request_data=json_data, + git_url=project_config.get("git_url"), + access_token=project_config.get("access_token"), + api_base=project_config.get("api_base"), + api_key=project_config.get("api_key"), + llm_model=project_config.get("llm_model"), + project_commands=project_config.get("commands") ) - token = request.headers.get('X-Gitlab-Token') - if token: - token = token.strip() - expected_token = config["secret"].strip() if config["secret"] else None - if token != expected_token: - return JsonResponse(status=403, data={"error": "Invalid token"}) - # 处理Merge Request事件 - if data.get('object_kind') == 'merge_request': - merge_request = data.get('object_attributes', {}) - if merge_request.get('state') == 'opened': - # 获取Merge Request的详细信息 - mr_url = merge_request.get('url') - mr_action = merge_request.get('action') - get_settings().set("config.git_provider", git_type) - get_settings().set("gitlab.url", git_url) - get_settings().set("gitlab.personal_access_token", access_token) - get_settings().set("openai.api_base", api_base) - get_settings().set("openai.key", api_key) - get_settings().set("llm.model", model) + # 记录请求日志 + provider.save_pr_agent_log(request_data=json_data, project_id=project_config.get("project_id")) - if mr_action == "update": - old_rev = merge_request.get("oldrev") - new_rev = merge_request.get("newrev") - if old_rev == new_rev: - return JsonResponse(status=200, data={"status": "ignored (no code change)"}) - - import threading - - def run_cmd(command): - cli.run_command(mr_url, command) - - threads = [] - for cmd in project_commands: - if cmd not in [cmd[1] for cmd in constant.DEFAULT_COMMANDS]: - continue - t = threading.Thread(target=run_cmd, args=(cmd,)) - threads.append(t) - t.start() - # 记录MR信息 - return JsonResponse(status=200, data={"status": "review started"}) - return JsonResponse(status=400, data={"error": "Merge request URL not found or action not open"}) return JsonResponse(status=200, data={"status": "ignored"}) - - diff --git a/apps/utils/constant.py b/apps/utils/constant.py index c8b0ee4..6c3a7e9 100644 --- a/apps/utils/constant.py +++ b/apps/utils/constant.py @@ -9,3 +9,16 @@ DEFAULT_COMMANDS = ( ("/describe", "/describe"), ("/improve_code", "/improve_code"), ) + +UA_TYPE = { + "GitLab": "gitlab", + "GitHub": "github", + "Go-http-client": "gitea" +} + + +def get_git_type_from_ua(ua_value): + for git_type, git_value in GIT_TYPE: + if git_value == ua_value: + return git_type + return None diff --git a/apps/utils/git_config.py b/apps/utils/git_config.py new file mode 100644 index 0000000..af6e2ff --- /dev/null +++ b/apps/utils/git_config.py @@ -0,0 +1,160 @@ +from abc import ABC, abstractmethod +from threading import Thread + +from django.http import JsonResponse + +from pr import models +from utils.pr_agent import cli +from utils.pr_agent.config_loader import get_settings +from utils import constant + + +class GitProvider(ABC): + @abstractmethod + def get_project_config(self, project_id): + pass + + @abstractmethod + def get_merge_request( + self, + request_data, + git_url, + access_token, + api_base, + api_key, + llm_model, + project_commands + ): + pass + + @abstractmethod + def run_command(self, mr_url, project_commands): + pass + + +class GitLabProvider(GitProvider): + + @staticmethod + def check_secret(request_headers, project_secret): + """ + 检查密钥 + :param request_headers: + :param project_secret: + :return: + """ + token = request_headers.get("X-Gitlab-Token") + if token != project_secret: + return JsonResponse(status=403, data={"error": "Invalid token"}) + + @staticmethod + def get_project_id(request_json): + """ + 获取项目ID + :param request_json: + :return: + """ + return request_json.get("project", {}).get("id") + + def get_project_config(self, project_id): + """ + 实现GitLab项目配置获取逻辑 + :param project_id: + :return: + """ + git_config = models.GitConfig.objects.filter(git_type=0).first() + project_config = models.ProjectConfig.objects.filter( + git_config=git_config, project_id=project_id + ).first() + if not project_config: + return JsonResponse(status=400, data={"error": "Project not found"}) + if not project_config.is_enabled: + return JsonResponse(status=400, data={"error": "Project is disabled"}) + + return { + "api_base": git_config.pr_ai.api_base, + "api_key": git_config.pr_ai.api_key, + "llm_model": git_config.pr_ai.llm_model, + "git_url": git_config.git_url, + "git_type": "gitlab", + "access_token": git_config.access_token, + "project_secret": project_config.project_secret, + "commands": project_config.commands.split(","), + "project_id": project_config.id + } + + def get_merge_request( + self, + request_data, + git_url, + access_token, + api_base, + api_key, + llm_model, + project_commands, + ): + """ + 实现GitLab Merge Request获取逻辑 + :param project_commands: + :param llm_model: + :param api_key: + :param api_base: + :param access_token: + :param git_url: + :param request_data: + :return: + """ + if request_data.get('object_kind') == 'merge_request': + merge_request = request_data.get('object_attributes', {}) + if merge_request.get('state') == 'opened': + mr_url = merge_request.get('url') + mr_action = merge_request.get('action') + get_settings().set("config.git_provider", "gitlab") + get_settings().set("gitlab.url", git_url) + get_settings().set("gitlab.personal_access_token", access_token) + get_settings().set("openai.api_base", api_base) + get_settings().set("openai.key", api_key) + get_settings().set("llm.model", llm_model) + if mr_action == "update": + old_rev = merge_request.get("oldrev") + new_rev = merge_request.get("newrev") + if old_rev == new_rev: + return JsonResponse( + status=200, data={"status": "ignored (no code change)"} + ) + self.run_command(mr_url, project_commands) + # 数据库留存 + return JsonResponse(status=200, data={"status": "review started"}) + return JsonResponse(status=400, data={"error": "Merge request URL not found or action not open"}) + + @staticmethod + def save_pr_agent_log(request_data, project_id): + """ + 记录pr agent日志 + :param request_data: + :param project_id: + :return: + """ + models.ProjectHistory.objects.create( + project_id=project_id, + project_url=request_data.get("project", {}).get("web_url"), + mr_url=request_data.get('object_attributes', {}).get("url"), + source_branch=request_data.get('object_attributes', {}).get("source_branch"), + target_branch=request_data.get('object_attributes', {}).get("target_branch"), + mr_title=request_data.get('object_attributes', {}).get("title"), + source_data=request_data, + ) + + def run_command(self, mr_url, project_commands): + """ + 自定义指令 + :param mr_url: + :param project_commands: + :return: + """ + threads = [] + for cmd in project_commands: + if cmd not in [cmd[1] for cmd in constant.DEFAULT_COMMANDS]: + continue + t = Thread(target=cli.run_command, args=(mr_url, cmd)) + threads.append(t) + t.start()