diff --git a/.devops/未命名项目.yml b/.devops/未命名项目.yml new file mode 100644 index 000000000..2acff75fc --- /dev/null +++ b/.devops/未命名项目.yml @@ -0,0 +1,65 @@ +version: 2 +name: 未命名项目 +description: "" +global: + concurrent: 1 +trigger: + webhook: gitlink@1.0.0 + event: + - ref: push + ruleset-operator: AND +workflow: + - ref: start + name: 开始 + task: start + - ref: end + name: 结束 + task: end + needs: + - ssh_cmd_0 + - ref: git_clone_0 + name: git clone + task: git_clone@1.2.9 + input: + username: ((qqj.gitlink_user)) + password: ((qqj.gitlink_pwd)) + remote_url: '"https://gitlink.org.cn/lifedreamer/reposync.git"' + ref: '"refs/heads/master"' + commit_id: '""' + depth: 1 + needs: + - start + - ref: ssh_cmd_0 + name: ssh执行命令 + task: ssh_cmd@1.1.1 + input: + ssh_pass: ((qqj.ssh)) + ssh_ip: '"116.62.22.98"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_cmd: "\"docker stop group10 || true && docker rm group10 || true && docker + pull + crpi-tmvkwcuipqu5d1w2.cn-hongkong.personal.cr.aliyuncs.com/group_10/rep\ + o_sync:latest && docker run -d -p 8089:8000 --name group10 -e + BOOT_MODE='app' + crpi-tmvkwcuipqu5d1w2.cn-hongkong.personal.cr.aliyuncs.com/group_10/rep\ + o_sync:latest\"" + needs: + - docker_image_build_0 + - ref: docker_image_build_0 + name: docker镜像构建 + task: docker_image_build@1.6.0 + input: + docker_username: ((qqj.docker_user)) + docker_password: ((qqj.docker_pwd)) + image_name: '"crpi-tmvkwcuipqu5d1w2.cn-hongkong.personal.cr.aliyuncs.com/group_10/repo_sync"' + image_tag: '"latest"' + registry_address: '"crpi-tmvkwcuipqu5d1w2.cn-hongkong.personal.cr.aliyuncs.com"' + docker_file: '"Dockerfile"' + docker_build_path: '"."' + workspace: git_clone_0.git_path + image_push: true + build_args: '""' + needs: + - git_clone_0 + diff --git a/Dockerfile b/Dockerfile index 24a7ae2bd..f2c1d58ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,27 @@ FROM centos:7 -RUN yum update -y && \ - yum install -y wget gcc make openssl-devel bzip2-devel libffi-devel zlib-devel +# 配置yum源为阿里云镜像 +RUN mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.bak && \ + curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo && \ + yum clean all && \ + yum makecache + +RUN yum install -y wget gcc make openssl-devel bzip2-devel libffi-devel zlib-devel RUN wget -P /data/ob-tool https://www.python.org/ftp/python/3.9.6/Python-3.9.6.tgz RUN cd /data/ob-tool && tar xzf Python-3.9.6.tgz RUN cd /data/ob-tool/Python-3.9.6 && ./configure --enable-optimizations && make altinstall ADD ./ /data/ob-robot/ RUN cd /data/ob-robot/ && \ - pip3.9 install -r /data/ob-robot/requirement.txt + pip3.9 install -i https://pypi.tuna.tsinghua.edu.cn/simple -r /data/ob-robot/requirement.txt -RUN yum install -y git openssh-server +# Install OpenSSH and a modern version of Git from the IUS repository. +# This avoids manual compilation, making the build faster and more reliable. +RUN yum install -y openssh-server && \ + yum install -y https://repo.ius.io/ius-release-el7.rpm && \ + yum install -y git236 ENV GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no -i /root/.ssh/id_rsa' -RUN yum install -y autoconf gettext && \ - wget http://github.com/git/git/archive/v2.32.0.tar.gz && \ - tar -xvf v2.32.0.tar.gz && \ - rm -f v2.32.0.tar.gz && \ - cd git-* && \ - make configure && \ - ./configure --prefix=/usr && \ - make -j16 && \ - make install WORKDIR /data/ob-robot CMD if [ "$BOOT_MODE" = "app" ] ; then python3.9 main.py; fi diff --git a/src/api/PullRequest.py b/src/api/PullRequest.py index d978fb21c..954eb7d63 100644 --- a/src/api/PullRequest.py +++ b/src/api/PullRequest.py @@ -1,4 +1,6 @@ import time +import os +import json from fastapi import ( BackgroundTasks, @@ -23,6 +25,16 @@ from src.service.pull_request import PullRequestService from src.service.sync import ProjectService from src.utils import github +# 导入PR同步相关功能 +import requests +from enum import Enum + + +# PR同步方向 +class SyncDirection(str, Enum): + TO_GITEE = "to_gitee" + TO_GITLINK = "to_gitlink" + class PullRequest(Controller): @@ -170,6 +182,225 @@ class PullRequest(Controller): code=Code.SUCCESS, msg="第二期功能,敬请期待" ) + + @pull_request.post("/projects/{name}/cross-sync-pr", response_model=Response, description='跨平台同步PR') + async def cross_sync_pr( + self, + name: str = Query(..., description='同步工程名称'), + source_repo: str = Query(..., description='源仓库地址(格式:owner/repo)'), + target_repo: str = Query(..., description='目标仓库地址(格式:owner/repo)'), + direction: SyncDirection = Query(SyncDirection.TO_GITEE, description='同步方向,可选值:to_gitee(Gitlink到Gitee)或to_gitlink(Gitee到Gitlink)'), + gitlink_cookie: Optional[str] = Query(None, description='Gitlink Cookie'), + gitee_token: Optional[str] = Query(None, description='Gitee Token') + ): + if not name or not source_repo or not target_repo: + raise ErrorTemplate.ARGUMENT_LACK() + + # 创建映射文件 + SYNC_MAPPING_FILE = f"pr_sync_mapping_{name}.json" + REVERSE_SYNC_MAPPING_FILE = f"pr_sync_mapping_reverse_{name}.json" + + # 尝试从环境变量或配置中获取令牌 + from src.base import config + gitlink_cookie = gitlink_cookie or config.ACCOUNT.get('gitlink_cookie', '') + gitee_token = gitee_token or config.ACCOUNT.get('gitee_token', '') + + # 检查令牌是否有效 + if not gitlink_cookie: + raise ErrorTemplate.TIP_ARGUMENT_ERROR("Gitlink Cookie 不能为空") + if not gitee_token: + raise ErrorTemplate.TIP_ARGUMENT_ERROR("Gitee Token 不能为空") + + # 使用后台任务执行同步,避免阻塞API请求 + try: + # 导入PR同步功能 + from src.common.pr_sync import ( + load_sync_mapping, save_sync_mapping, get_gitlink_pulls, get_gitee_branches, + create_gitee_pull, test_gitee_api, get_gitlink_pull_comments, + create_gitee_pull_comment, sync_pull_comments, get_gitlink_pr_index, + check_can_sync_pull_request, get_gitee_pulls, create_gitlink_pull, + check_can_sync_to_gitlink, sync_gitee_to_gitlink, sync_gitlink_to_gitee, + init_config + ) + + # 初始化配置 + init_config(source_repo, target_repo, gitlink_cookie, gitee_token) + + # 根据同步方向执行不同的同步逻辑 + if direction == SyncDirection.TO_GITEE: + # 从Gitlink同步PR到Gitee + # 这里直接调用模块中的函数,传入必要的参数 + results = await self._run_sync_gitlink_to_gitee( + source_repo=source_repo, + target_repo=target_repo, + gitlink_cookie=gitlink_cookie, + gitee_token=gitee_token, + sync_mapping_file=SYNC_MAPPING_FILE + ) + return Response( + code=Code.SUCCESS, + data=results, + msg=f"从Gitlink同步PR到Gitee成功,共同步{results.get('success_count', 0)}个PR" + ) + else: + # 从Gitee同步PR到Gitlink + # 这里直接调用模块中的函数,传入必要的参数 + results = await self._run_sync_gitee_to_gitlink( + source_repo=target_repo, # 注意:这里反过来了 + target_repo=source_repo, # 注意:这里反过来了 + gitlink_cookie=gitlink_cookie, + gitee_token=gitee_token, + sync_mapping_file=REVERSE_SYNC_MAPPING_FILE + ) + return Response( + code=Code.SUCCESS, + data=results, + msg=f"从Gitee同步PR到Gitlink成功,共同步{results.get('success_count', 0)}个PR" + ) + + except Exception as e: + logger.error(f"跨平台同步PR失败: {str(e)}") + raise ErrorTemplate.TIP_ARGUMENT_ERROR(f"同步PR失败: {str(e)}") + + async def _run_sync_gitlink_to_gitee(self, source_repo, target_repo, gitlink_cookie, gitee_token, sync_mapping_file): + """从Gitlink同步PR到Gitee的具体实现""" + # 导入必要的函数 + from src.common.pr_sync import ( + load_sync_mapping, save_sync_mapping, get_gitlink_pulls, get_gitee_branches, + create_gitee_pull, test_gitee_api, get_gitlink_pull_comments, + create_gitee_pull_comment, sync_pull_comments, get_gitlink_pr_index, + check_can_sync_pull_request, init_config + ) + + # 初始化配置 + init_config(source_repo, target_repo, gitlink_cookie, gitee_token) + + # 加载同步映射 + sync_mapping = load_sync_mapping(sync_mapping_file) + + # 测试Gitee API + if not test_gitee_api(): + return {"success": False, "message": "API测试失败,请检查配置"} + + # 获取Gitlink的PR列表 + pulls = get_gitlink_pulls() + if not pulls: + return {"success": True, "message": "没有获取到Gitlink的PR列表", "success_count": 0} + + # 筛选可同步的PR + syncable_pulls = [] + for pull in pulls: + gitlink_pr_id = pull.get("id") + + # 检查是否已同步 + if str(gitlink_pr_id) in sync_mapping: + continue + + # 检查是否可同步 + can_sync, reason = check_can_sync_pull_request(pull) + if can_sync: + syncable_pulls.append(pull) + + if not syncable_pulls: + return {"success": True, "message": "没有发现可同步的PR", "success_count": 0} + + # 同步每个PR + success_count = 0 + for pull in syncable_pulls: + gitlink_pr_id = pull.get("id") + + # 在Gitee创建PR + gitee_pr = create_gitee_pull(pull) + if not gitee_pr: + continue + + gitee_pr_id = gitee_pr.get("id") or gitee_pr.get("number") + success_count += 1 + + # 保存同步映射 + sync_mapping[str(gitlink_pr_id)] = gitee_pr_id + + # 同步评论 + sync_pull_comments(gitlink_pr_id, gitee_pr_id) + + # 避免请求过于频繁 + time.sleep(1) + + # 保存同步映射 + save_sync_mapping(sync_mapping, sync_mapping_file) + + return { + "success": True, + "message": f"同步完成!成功: {success_count}/{len(syncable_pulls)} 个PR", + "success_count": success_count, + "total_count": len(syncable_pulls) + } + + async def _run_sync_gitee_to_gitlink(self, source_repo, target_repo, gitlink_cookie, gitee_token, sync_mapping_file): + """从Gitee同步PR到Gitlink的具体实现""" + # 导入必要的函数 + from src.common.pr_sync import ( + load_sync_mapping, save_sync_mapping, get_gitee_pulls, + create_gitlink_pull, check_can_sync_to_gitlink, init_config + ) + + # 初始化配置 + init_config(source_repo, target_repo, gitlink_cookie, gitee_token) + + # 加载同步映射 + sync_mapping = load_sync_mapping(sync_mapping_file) + + # 获取Gitee的PR列表 + pulls = get_gitee_pulls() + if not pulls: + return {"success": True, "message": "没有获取到Gitee的PR列表", "success_count": 0} + + # 筛选可同步的PR + syncable_pulls = [] + for pull in pulls: + gitee_pr_id = pull.get("id") + + # 检查是否已同步 + if str(gitee_pr_id) in sync_mapping: + continue + + # 检查是否可同步 + can_sync, reason = check_can_sync_to_gitlink(pull) + if can_sync: + syncable_pulls.append(pull) + + if not syncable_pulls: + return {"success": True, "message": "没有发现可同步的PR", "success_count": 0} + + # 同步每个PR + success_count = 0 + for pull in syncable_pulls: + gitee_pr_id = pull.get("id") + gitee_pr_number = pull.get("number") + + # 在Gitlink创建PR + gitlink_pr = create_gitlink_pull(pull) + if not gitlink_pr: + continue + + gitlink_pr_id = gitlink_pr.get("pull_request_id") + success_count += 1 + + # 保存同步映射 + sync_mapping[str(gitee_pr_id)] = gitlink_pr_id + + # 避免请求过于频繁 + time.sleep(1) + + # 保存同步映射 + save_sync_mapping(sync_mapping, sync_mapping_file) + + return { + "success": True, + "message": f"反向同步完成!成功: {success_count}/{len(syncable_pulls)} 个PR", + "success_count": success_count, + "total_count": len(syncable_pulls) + } async def _check_project(self, name: str): project_service = ProjectService() diff --git a/src/base/config.py b/src/base/config.py index d930e9a1e..3392a08df 100644 --- a/src/base/config.py +++ b/src/base/config.py @@ -33,18 +33,18 @@ buc_key and ConfigsUtil.set_obfastapi_config('buc_key', buc_key) DB_ENV = getenv('DB_ENV', 'test_env') DB = { 'test_env': { - 'host': getenv('CEROBOT_MYSQL_HOST', ''), - 'port': getenv('CEROBOT_MYSQL_PORT', 2883, int), - 'user': getenv('CEROBOT_MYSQL_USER', ''), - 'passwd': getenv('CEROBOT_MYSQL_PWD', ''), - 'dbname': getenv('CEROBOT_MYSQL_DB', '') + 'host': getenv('CEROBOT_MYSQL_HOST', 'localhost'), + 'port': getenv('CEROBOT_MYSQL_PORT', 3306, int), + 'user': getenv('CEROBOT_MYSQL_USER', 'root'), + 'passwd': getenv('CEROBOT_MYSQL_PWD', 'Qian15825254201'), + 'dbname': getenv('CEROBOT_MYSQL_DB', 'repo_sync') }, 'local': { - 'host': getenv('CEROBOT_MYSQL_HOST', ''), - 'port': getenv('CEROBOT_MYSQL_PORT', 2881, int), - 'user': getenv('CEROBOT_MYSQL_USER', ''), - 'passwd': getenv('CEROBOT_MYSQL_PWD', ''), - 'dbname': getenv('CEROBOT_MYSQL_DB', '') + 'host': getenv('CEROBOT_MYSQL_HOST', 'localhost'), + 'port': getenv('CEROBOT_MYSQL_PORT', 3306, int), + 'user': getenv('CEROBOT_MYSQL_USER', 'root'), + 'passwd': getenv('CEROBOT_MYSQL_PWD', 'Qian15825254201'), + 'dbname': getenv('CEROBOT_MYSQL_DB', 'repo_sync') } } @@ -60,12 +60,13 @@ ACCOUNT = { 'username': getenv('OB_ROBOT_USERNAME', ''), 'email': getenv('OB_ROBOT_USERNAME', ''), 'github_token': getenv('GITHUB_TOKEN', ''), - 'gitee_token': getenv('GITEE_TOKEN', ''), + 'gitee_token': getenv('GITEE_TOKEN', '821e3b39e558457d50486124315f8782'), 'gitlab_token': getenv('GITLAB_TOKEN', ''), # 暂时还是我的token,待替代为一个内部账号 'antcode_token': getenv('ANTCODE_TOKEN', ''), # 暂时还是我的token,待替代为一个内部账号 'gitcode_token': getenv('GITCODE_TOKEN', ''), # 暂时还是我的token,待替代为ob-robot账号 'robot_code_token': getenv('ROBOT_CODE_TOKEN', ''), - 'robot_antcode_token': getenv('ROBOT_ANTCODE_TOKEN', '') + 'robot_antcode_token': getenv('ROBOT_ANTCODE_TOKEN', ''), + 'gitlink_cookie': getenv('GITLINK_COOKIE', 'a16c742d837d868dc504cf3a7d37e06651eeef7d') } GITLAB_ENV = { @@ -84,8 +85,8 @@ GITEE_ENV = { } GITLINK_ENV = { - 'gitlink_api_address': getenv('GITLINK_API_HOST', ''), - 'gitlink_api_diff_address': getenv('GITLINK_API_DIFF_HOST', ''), + 'gitlink_api_address': getenv('GITLINK_API_HOST', 'https://www.gitlink.org.cn/api'), + 'gitlink_api_diff_address': getenv('GITLINK_API_DIFF_HOST', 'https://www.gitlink.org.cn'), } GITCODE_ENV = { diff --git a/src/common/pr_sync.py b/src/common/pr_sync.py new file mode 100644 index 000000000..c32358fc4 --- /dev/null +++ b/src/common/pr_sync.py @@ -0,0 +1,714 @@ +import requests +import json +import os +import time +import argparse + +# 配置信息 +GITLINK_COOKIE = None # 将在运行时设置 +GITEE_TOKEN = None # 将在运行时设置 +SOURCE_REPO = None # 将在运行时设置 +TARGET_REPO = None # 将在运行时设置 + +# 创建同步映射文件保存同步记录 +SYNC_MAPPING_FILE = "pr_sync_mapping.json" +REVERSE_SYNC_MAPPING_FILE = "pr_sync_mapping_reverse.json" + +def init_config(source_repo, target_repo, gitlink_cookie, gitee_token): + """初始化配置信息""" + global SOURCE_REPO, TARGET_REPO, GITLINK_COOKIE, GITEE_TOKEN + SOURCE_REPO = source_repo + TARGET_REPO = target_repo + GITLINK_COOKIE = gitlink_cookie + GITEE_TOKEN = gitee_token + +def load_sync_mapping(file_name): + """加载已同步的PR映射关系""" + if os.path.exists(file_name): + with open(file_name, 'r') as f: + return json.load(f) + return {} + +def save_sync_mapping(mapping, file_name): + """保存同步映射关系""" + with open(file_name, 'w') as f: + json.dump(mapping, f, indent=2) + +def get_gitlink_pulls(): + """从Gitlink获取PR列表""" + global SOURCE_REPO, GITLINK_COOKIE + url = f"https://www.gitlink.org.cn/api/v1/{SOURCE_REPO}/pulls.json" + headers = { + "Cookie": f"autologin_trustie={GITLINK_COOKIE}" + } + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + return response.json()["pulls"] + except Exception as e: + print(f"获取Gitlink PR失败: {str(e)}") + return [] + +def get_gitee_branches(): + """获取Gitee仓库的分支列表""" + global TARGET_REPO, GITEE_TOKEN + url = f"https://gitee.com/api/v5/repos/{TARGET_REPO}/branches" + headers = { + "Authorization": f"token {GITEE_TOKEN}" + } + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + branches = response.json() + return [branch["name"] for branch in branches] + except Exception as e: + print(f"获取Gitee分支列表失败: {str(e)}") + return [] + +def create_gitee_pull(pull_data): + """在Gitee创建PR""" + global TARGET_REPO, GITEE_TOKEN + url = f"https://gitee.com/api/v5/repos/{TARGET_REPO}/pulls" + headers = { + "Authorization": f"token {GITEE_TOKEN}", + "Content-Type": "application/json" + } + + # 处理分支名称 - 去掉可能的前缀 + head_branch = pull_data.get("head", "").replace("refs/heads/", "").replace("origin/", "") + base_branch = pull_data.get("base", "").replace("refs/heads/", "").replace("origin/", "") + + # 构建PR数据 + payload = { + "title": pull_data.get("title", "未命名PR"), + "head": head_branch, + "base": base_branch, + "body": f"从Gitlink同步的PR\n\n原始PR信息:\n- 标题: {pull_data.get('title', 'N/A')}\n- 原始链接: https://gitlink.org.cn/{SOURCE_REPO}/pulls/{pull_data.get('id', 'N/A')}\n- 状态: {pull_data.get('state', 'N/A')}\n\n{pull_data.get('body', '')}" + } + + # 打印创建信息 + print(f" - 标题: {payload['title']}") + print(f" - 源分支: {payload['head']}") + print(f" - 目标分支: {payload['base']}") + + try: + response = requests.post(url, headers=headers, json=payload) + + # 处理响应 + if response.status_code != 201: + error_message = "未知错误" + try: + error_data = response.json() + if "message" in error_data: + error_message = error_data['message'] + except: + error_message = f"状态码: {response.status_code}" + + print(f" ✗ 创建失败: {error_message}") + return None + + return response.json() + except Exception as e: + print(f" ✗ 请求异常: {str(e)}") + return None + +def test_gitee_api(): + """测试Gitee API连接和权限""" + global TARGET_REPO, GITEE_TOKEN + print("测试Gitee API连接...") + + # 测试基本连接 + url = f"https://gitee.com/api/v5/repos/{TARGET_REPO}" + headers = { + "Authorization": f"token {GITEE_TOKEN}" + } + + try: + response = requests.get(url, headers=headers) + if response.status_code == 200: + repo_info = response.json() + print(f"✓ 成功连接到仓库: {repo_info.get('full_name')}") + print(f" - 默认分支: {repo_info.get('default_branch')}") + print(f" - 权限: {repo_info.get('permissions', {})}") + else: + print(f"✗ 无法访问仓库,状态码: {response.status_code}") + print(f"响应: {response.text}") + return False + except Exception as e: + print(f"✗ API连接失败: {str(e)}") + return False + + # 测试分支列表 + branches = get_gitee_branches() + if branches: + print(f"✓ 获取到 {len(branches)} 个分支: {branches}") + else: + print("✗ 无法获取分支列表") + return False + + return True + +def get_gitlink_pull_comments(pull_index): + """从Gitlink获取PR评论列表""" + global SOURCE_REPO, GITLINK_COOKIE + # 将SOURCE_REPO拆分为owner和repo + repo_parts = SOURCE_REPO.split('/') + if len(repo_parts) != 2: + print(f" ✗ 仓库格式错误: {SOURCE_REPO},应为'owner/repo'格式") + return [] + + owner, repo = repo_parts + + url = f"https://www.gitlink.org.cn/api/v1/{owner}/{repo}/pulls/{pull_index}/journals.json" + + # 确保cookie格式正确 + cookies = { + "autologin_trustie": GITLINK_COOKIE + } + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + + params = { + "is_full": "true", # 获取全部评论包括回复 + "sort_by": "created_on", # 根据API文档,正确的值是created_on,而不是created_at + "sort_direction": "asc" # 按创建时间正序排列 + } + + try: + # 使用cookies参数而不是在headers中设置Cookie + response = requests.get(url, headers=headers, cookies=cookies, params=params) + + if response.status_code != 200: + print(f" ✗ 获取评论失败,状态码: {response.status_code}") + return [] + + try: + data = response.json() + + # 如果返回错误信息 + if data.get("status", 0) < 0: + print(f" ✗ API错误: {data.get('message', '未知错误')}") + return [] + + # 打印找到的评论数量 + comments_count = len(data.get("journals", [])) + return data.get("journals", []) + except json.JSONDecodeError: + print(f" ✗ 响应内容不是有效的JSON") + return [] + + except Exception as e: + print(f" ✗ 获取评论异常: {str(e)}") + return [] + +def create_gitee_pull_comment(gitee_pr_number, comment_data): + """在Gitee创建PR评论""" + global TARGET_REPO, GITEE_TOKEN + url = f"https://gitee.com/api/v5/repos/{TARGET_REPO}/pulls/{gitee_pr_number}/comments" + headers = { + "Authorization": f"token {GITEE_TOKEN}", + "Content-Type": "application/json" + } + + # 构建评论内容,包含原始作者信息 + original_author = comment_data.get("user", {}).get("name", "Unknown") + original_login = comment_data.get("user", {}).get("login", "unknown") + original_time = comment_data.get("created_at", "") + original_note = comment_data.get("note", "") + + # 构建评论正文,标注原始作者 + comment_body = f"**原始评论作者**: {original_author} (@{original_login})\n" + comment_body += f"**原始评论时间**: {original_time}\n\n" + comment_body += f"{original_note}" + + # 如果是代码评论,添加路径和行号信息 + if comment_data.get("path") and comment_data.get("line_code"): + comment_body += f"\n\n*原始位置*: `{comment_data.get('path')}` (行: {comment_data.get('line_code')})" + + payload = { + "body": comment_body + } + + # 如果是代码评论,尝试添加相关信息(注意:Gitee的代码评论需要更多参数) + if comment_data.get("commit_id"): + payload["commit_id"] = comment_data["commit_id"] + if comment_data.get("path"): + payload["path"] = comment_data["path"] + if comment_data.get("line_code"): + # 尝试从line_code中提取position信息 + try: + # line_code格式通常是: commit_id_old_line_new_line + parts = comment_data["line_code"].split("_") + if len(parts) >= 3: + payload["position"] = int(parts[-1]) # 使用最后一部分作为position + except: + pass # 如果解析失败,就作为普通评论处理 + + try: + response = requests.post(url, headers=headers, json=payload) + + if response.status_code == 201: + return response.json() + else: + print(f"创建评论失败,状态码: {response.status_code}") + print(f"响应内容: {response.text}") + # 如果代码评论创建失败,尝试创建普通评论 + if "commit_id" in payload or "path" in payload or "position" in payload: + print("尝试创建普通评论...") + simple_payload = {"body": comment_body} + response = requests.post(url, headers=headers, json=simple_payload) + if response.status_code == 201: + return response.json() + return None + + except Exception as e: + print(f"创建Gitee评论失败: {str(e)}") + return None + +def sync_pull_comments(gitlink_pr_id, gitee_pr_number): + """同步PR评论""" + print(f"开始同步PR评论...") + + # 需要查找PR的索引(index)而不是ID + gitlink_pr_index = get_gitlink_pr_index(gitlink_pr_id) + if gitlink_pr_index is None: + print(f" ✗ 无法获取PR索引,无法同步评论") + return + + comments = get_gitlink_pull_comments(gitlink_pr_index) + if not comments: + print(" - 没有找到评论,无需同步") + return + + print(f" - 找到 {len(comments)} 条评论,开始同步") + + # 按parent_id分组处理评论(先处理顶级评论,再处理回复) + top_level_comments = [c for c in comments if not c.get("parent_id")] + reply_comments = [c for c in comments if c.get("parent_id")] + + successful_syncs = 0 + + # 先同步顶级评论 + if top_level_comments: + print(f" - 同步 {len(top_level_comments)} 条顶级评论") + for comment in top_level_comments: + comment_id = comment.get("id") + author = comment.get("user", {}).get("name", "Unknown") + + result = create_gitee_pull_comment(gitee_pr_number, comment) + if result: + successful_syncs += 1 + + # 避免请求过于频繁 + time.sleep(0.5) + + # 再同步回复评论 + if reply_comments: + print(f" - 同步 {len(reply_comments)} 条回复评论") + for comment in reply_comments: + comment_id = comment.get("id") + parent_id = comment.get("parent_id") + + # 为回复评论添加额外的上下文信息 + original_comment = comment.copy() + original_comment["note"] = f"[回复评论#{parent_id}]\n\n{comment.get('note', '')}" + + result = create_gitee_pull_comment(gitee_pr_number, original_comment) + if result: + successful_syncs += 1 + + # 避免请求过于频繁 + time.sleep(0.5) + + print(f" ✓ 评论同步完成: {successful_syncs}/{len(comments)} 条成功") + +def get_gitlink_pr_index(pr_id): + """根据PR ID获取PR索引(index)""" + # 获取所有PR列表 + pulls = get_gitlink_pulls() + + # 查找匹配ID的PR + for pull in pulls: + pull_id = pull.get("id") + pull_index = pull.get("index") + + if pull_id == pr_id: + return pull_index + + return None + +def check_can_sync_pull_request(pull_data): + """检查PR是否可以同步,返回(可同步, 原因)""" + # 获取Gitee仓库的分支列表 + available_branches = get_gitee_branches() + + # 处理分支名称 - 去掉可能的前缀 + head_branch = pull_data.get("head", "").replace("refs/heads/", "").replace("origin/", "") + base_branch = pull_data.get("base", "").replace("refs/heads/", "").replace("origin/", "") + + # 如果分支名称为空,使用默认值 + if not head_branch: + return False, "源分支名称为空" + if not base_branch: + return False, "目标分支名称为空" + + # 检查分支是否存在 + if head_branch not in available_branches: + return False, f"源分支 '{head_branch}' 在目标仓库中不存在" + + if base_branch not in available_branches: + return False, f"目标分支 '{base_branch}' 在目标仓库中不存在" + + # 检查源分支和目标分支是否有差异 + url = f"https://gitee.com/api/v5/repos/{TARGET_REPO}/compare/{base_branch}...{head_branch}" + headers = { + "Authorization": f"token {GITEE_TOKEN}" + } + + try: + response = requests.get(url, headers=headers) + data = response.json() + + # 如果没有提交差异,则不需要同步 + if not data.get("commits") or len(data.get("commits", [])) == 0: + return False, f"源分支和目标分支没有差异" + except Exception as e: + # 如果无法检查差异,默认认为可以同步 + pass + + # 检查是否已存在相同分支的PR + url = f"https://gitee.com/api/v5/repos/{TARGET_REPO}/pulls" + params = { + "state": "open", # 只检查开放的PR + "head": head_branch, + "base": base_branch + } + + try: + response = requests.get(url, headers=headers, params=params) + pulls = response.json() + + if pulls and len(pulls) > 0: + pr_numbers = [f"#{pr['number']} {pr['title']}" for pr in pulls] + return False, f"已存在相同源分支、目标分支的PR: {', '.join(pr_numbers)}" + except Exception as e: + # 如果无法检查现有PR,默认认为可以同步 + pass + + return True, "可以同步" + +# ================== 反向同步功能 (Gitee -> Gitlink) ================== + +def get_gitee_pulls(): + """从Gitee获取PR列表""" + global TARGET_REPO, GITEE_TOKEN + url = f"https://gitee.com/api/v5/repos/{TARGET_REPO}/pulls" + headers = { + "Authorization": f"token {GITEE_TOKEN}" + } + params = { + "state": "open", # 只获取开放状态的PR + "sort": "created", + "direction": "desc", + "per_page": 100 + } + + try: + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + except Exception as e: + print(f"获取Gitee PR失败: {str(e)}") + return [] + +def create_gitlink_pull(pull_data): + """在Gitlink创建PR""" + global SOURCE_REPO, GITLINK_COOKIE + owner, repo = SOURCE_REPO.split('/') + url = f"https://www.gitlink.org.cn/api/{owner}/{repo}/pulls.json" + + headers = { + "Cookie": f"autologin_trustie={GITLINK_COOKIE}", + "Content-Type": "application/json" + } + + # 处理分支名称 + head_branch = pull_data.get("head", {}).get("ref", "").replace("refs/heads/", "") + base_branch = pull_data.get("base", {}).get("ref", "").replace("refs/heads/", "") + + # 尝试不同的payload配置 + payloads_to_try = [ + # 配置1:按照API示例,非fork仓库 + { + "title": pull_data.get("title", "未命名PR"), + "body": f"从Gitee同步的PR\n\n原始PR信息:\n- 标题: {pull_data.get('title', 'N/A')}\n- 原始链接: {pull_data.get('html_url', 'N/A')}\n- 状态: {pull_data.get('state', 'N/A')}\n\n{pull_data.get('body', '')}", + "head": head_branch, + "base": base_branch, + "priority_id": "2", + "is_original": False, + "fork_project_id": "", + "receivers_login": [], + "merge_project_identifier": "" + }, + # 配置2:尝试不发送fork相关字段 + { + "title": pull_data.get("title", "未命名PR"), + "body": f"从Gitee同步的PR\n\n原始PR信息:\n- 标题: {pull_data.get('title', 'N/A')}\n- 原始链接: {pull_data.get('html_url', 'N/A')}\n- 状态: {pull_data.get('state', 'N/A')}\n\n{pull_data.get('body', '')}", + "head": head_branch, + "base": base_branch, + "priority_id": "2", + "receivers_login": [] + }, + # 配置3:minimal payload,只包含必需字段 + { + "title": pull_data.get("title", "未命名PR"), + "body": f"从Gitee同步的PR\n\n{pull_data.get('body', '')}", + "head": head_branch, + "base": base_branch, + "priority_id": "2", + "is_original": False, + "fork_project_id": "", + "receivers_login": [], + "merge_project_identifier": "" + } + ] + + # 打印创建信息 + print(f" - 标题: {pull_data.get('title', '未命名PR')}") + print(f" - 源分支: {head_branch}") + print(f" - 目标分支: {base_branch}") + print(f" - 请求URL: {url}") + + # 尝试不同的配置 + for i, payload in enumerate(payloads_to_try): + print(f" - 尝试配置 {i+1}...") + + try: + response = requests.post(url, headers=headers, json=payload) + + print(f" 状态码: {response.status_code}") + if response.text: + print(f" 响应: {response.text[:300]}") + + # 处理响应 + if response.status_code == 200: + data = response.json() + if data.get("status") == 0: + print(f" ✓ 创建成功: PR #{data.get('pull_request_id')}") + return data + else: + print(f" API错误: {data.get('message', '未知错误')}") + # 继续尝试下一个配置 + continue + else: + print(f" HTTP错误: {response.status_code}") + # 继续尝试下一个配置 + continue + + except Exception as e: + print(f" 异常: {str(e)}") + # 继续尝试下一个配置 + continue + + # 所有配置都失败了 + print(f" ✗ 所有配置都失败了") + return None + +def check_can_sync_to_gitlink(pull_data): + """检查PR是否可以同步到Gitlink,返回(可同步, 原因)""" + # 处理分支名称 + head_branch = pull_data.get("head", {}).get("ref", "").replace("refs/heads/", "") + base_branch = pull_data.get("base", {}).get("ref", "").replace("refs/heads/", "") + + # 如果分支名称为空,使用默认值 + if not head_branch: + return False, "源分支名称为空" + if not base_branch: + return False, "目标分支名称为空" + + # 检查是否已存在相同分支的PR + global SOURCE_REPO, GITLINK_COOKIE + owner, repo = SOURCE_REPO.split('/') + url = f"https://www.gitlink.org.cn/api/v1/{owner}/{repo}/pulls.json" + headers = { + "Cookie": f"autologin_trustie={GITLINK_COOKIE}" + } + + try: + response = requests.get(url, headers=headers) + if response.status_code == 200: + pulls = response.json().get("pulls", []) + for pull in pulls: + if (pull.get("head") == head_branch and + pull.get("base") == base_branch and + pull.get("state") == "open"): + return False, f"已存在相同分支的PR: #{pull.get('id')} {pull.get('title')}" + except Exception as e: + print(f"检查Gitlink现有PR失败: {str(e)}") + + return True, "可以同步" + +def sync_gitee_to_gitlink(): + """从Gitee同步PR到Gitlink""" + print("开始从Gitee同步PR到Gitlink...") + + # 加载已有的反向同步映射 + sync_mapping = load_sync_mapping(REVERSE_SYNC_MAPPING_FILE) + + # 获取Gitee的PR列表 + pulls = get_gitee_pulls() + if not pulls: + print("没有获取到Gitee的PR列表") + return + + print(f"获取到 {len(pulls)} 个Gitee PR") + + # 筛选可同步的PR + syncable_pulls = [] + for pull in pulls: + gitee_pr_id = pull.get("id") + + # 检查是否已同步 + if str(gitee_pr_id) in sync_mapping: + continue + + # 检查是否可同步 + can_sync, reason = check_can_sync_to_gitlink(pull) + if can_sync: + syncable_pulls.append(pull) + else: + # 可以选择在调试模式下输出不可同步的原因 + # print(f"PR #{gitee_pr_id} ({pull.get('title')}) 不符合同步条件: {reason}") + pass + + if not syncable_pulls: + print("没有发现可同步的PR") + return + + print(f"发现 {len(syncable_pulls)} 个可同步的PR:") + for i, pull in enumerate(syncable_pulls): + head_branch = pull.get("head", {}).get("ref", "").replace("refs/heads/", "") + base_branch = pull.get("base", {}).get("ref", "").replace("refs/heads/", "") + print(f"{i+1}. PR #{pull.get('number')} - {pull.get('title')} (源分支: {head_branch}, 目标分支: {base_branch})") + + # 同步每个PR + success_count = 0 + for pull in syncable_pulls: + gitee_pr_id = pull.get("id") + gitee_pr_number = pull.get("number") + + print(f"\n正在同步PR #{gitee_pr_number}: {pull.get('title', 'Unknown')}") + + # 在Gitlink创建PR + gitlink_pr = create_gitlink_pull(pull) + if not gitlink_pr: + print(f"PR #{gitee_pr_number} 同步失败") + continue + + gitlink_pr_id = gitlink_pr.get("pull_request_id") + print(f"✓ 成功创建Gitlink PR #{gitlink_pr_id}") + success_count += 1 + + # 保存同步映射 + sync_mapping[str(gitee_pr_id)] = gitlink_pr_id + + # 避免请求过于频繁 + time.sleep(1) + + # 保存同步映射 + save_sync_mapping(sync_mapping, REVERSE_SYNC_MAPPING_FILE) + print(f"\n反向同步完成!成功: {success_count}/{len(syncable_pulls)} 个PR") + +def sync_gitlink_to_gitee(): + """从Gitlink同步PR到Gitee""" + # 首先测试API连接 + if not test_gitee_api(): + print("API测试失败,请检查配置") + return + + # 加载已有的同步映射 + sync_mapping = load_sync_mapping(SYNC_MAPPING_FILE) + + # 获取Gitlink的PR列表 + pulls = get_gitlink_pulls() + if not pulls: + print("没有获取到Gitlink的PR列表") + return + + print(f"\n获取到 {len(pulls)} 个Gitlink PR") + + # 筛选可同步的PR + syncable_pulls = [] + for pull in pulls: + gitlink_pr_id = pull.get("id") + + # 检查是否已同步 + if str(gitlink_pr_id) in sync_mapping: + continue + + # 检查是否可同步 + can_sync, reason = check_can_sync_pull_request(pull) + if can_sync: + syncable_pulls.append(pull) + else: + # 可以选择在调试模式下输出不可同步的原因 + # print(f"PR #{gitlink_pr_id} ({pull.get('title')}) 不符合同步条件: {reason}") + pass + + if not syncable_pulls: + print("没有发现可同步的PR") + return + + print(f"\n发现 {len(syncable_pulls)} 个可同步的PR:") + for i, pull in enumerate(syncable_pulls): + print(f"{i+1}. PR #{pull.get('id')} - {pull.get('title')} (源分支: {pull.get('head')}, 目标分支: {pull.get('base')})") + + # 同步每个PR + success_count = 0 + for pull in syncable_pulls: + gitlink_pr_id = pull.get("id") + + print(f"\n正在同步PR #{gitlink_pr_id}: {pull.get('title', 'Unknown')}") + + # 在Gitee创建PR + gitee_pr = create_gitee_pull(pull) + if not gitee_pr: + print(f"PR #{gitlink_pr_id} 同步失败") + continue + + gitee_pr_id = gitee_pr.get("id") or gitee_pr.get("number") + print(f"✓ 成功创建Gitee PR #{gitee_pr_id}") + success_count += 1 + + # 保存同步映射 + sync_mapping[str(gitlink_pr_id)] = gitee_pr_id + + sync_pull_comments(gitlink_pr_id, gitee_pr_id) + + # 避免请求过于频繁 + time.sleep(1) + + # 保存同步映射 + save_sync_mapping(sync_mapping, SYNC_MAPPING_FILE) + print(f"\n同步完成!成功: {success_count}/{len(syncable_pulls)} 个PR") + +def main(): + parser = argparse.ArgumentParser(description='双向同步PR') + parser.add_argument('--direction', choices=['to_gitee', 'to_gitlink'], default='to_gitee', + help='同步方向: to_gitee (从Gitlink到Gitee), to_gitlink (从Gitee到Gitlink)') + + args = parser.parse_args() + + if args.direction == 'to_gitee': + sync_gitlink_to_gitee() + elif args.direction == 'to_gitlink': + sync_gitee_to_gitlink() + else: + print("无效的同步方向,使用 --help 查看帮助") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/web/src/pages/pullRequest/index.tsx b/web/src/pages/pullRequest/index.tsx index 08dd4acdb..52131f632 100644 --- a/web/src/pages/pullRequest/index.tsx +++ b/web/src/pages/pullRequest/index.tsx @@ -2,15 +2,17 @@ import { useState } from 'react'; import { history, useModel } from 'umi'; import type { ColumnsType } from 'antd/es/table'; import { PageContainer } from '@ant-design/pro-layout'; -import { QueryFilter, ProFormText } from '@ant-design/pro-form'; -import { Button, Table, Card, Modal, Divider, Tooltip, message } from 'antd'; -import { ExclamationCircleOutlined } from '@ant-design/icons'; +import { QueryFilter, ProFormText, ProFormSelect, ModalForm } from '@ant-design/pro-form'; +import { Button, Table, Card, Modal, Divider, Tooltip, message, Space } from 'antd'; +import { ExclamationCircleOutlined, SyncOutlined } from '@ant-design/icons'; import useRequest from '@/utils/useRequest'; import { listPullRequest, approvePullRequest, mergePullRequest, pressPullRequest, + crossSyncPR, + syncPullRequest, } from '@/services/Pullrequests'; interface Props { @@ -25,6 +27,7 @@ export default (props: Props) => { const { name } = props?.location?.query || {}; const { initSearchParams } = useModel('global'); const [searchParams, setSearchParams] = useState(initSearchParams); + const [crossSyncVisible, setCrossSyncVisible] = useState(false); const { run: fetchPullRequest, @@ -41,6 +44,8 @@ export default (props: Props) => { const { run: handleApprove } = useRequest(approvePullRequest); const { run: handleMerge } = useRequest(mergePullRequest); const { run: handlePress } = useRequest(pressPullRequest); + const { run: handleSync } = useRequest(syncPullRequest); + const { run: handleCrossSync, loading: crossSyncLoading } = useRequest(crossSyncPR); const handleActions = (id: number, type: string) => { let request: any; @@ -72,6 +77,23 @@ export default (props: Props) => { }); }; + const handleSyncPRs = () => { + if (!name) return; + Modal.confirm({ + title: `确定同步当前项目的PR吗?`, + icon: , + onOk: async () => { + handleSync({ name }).then(({ success }: API.Response) => { + if (success) { + message?.destroy(); + message.success('PR同步成功'); + loadData(); + } + }) + }, + }); + }; + const onTableChange = ({ current, pageSize }: { current: number, pageSize: number }) => { loadData({ ...searchParams, pageNum: current, pageSize }); } @@ -124,9 +146,21 @@ export default (props: Props) => { ghost header={{ title: name }} extra={ - + + + + + } > @@ -158,6 +192,69 @@ export default (props: Props) => { }} /> + + {/* 跨平台PR同步弹窗 */} + { + if (!name) return false; + + const params = { + name, + ...values, + }; + + const res = await handleCrossSync(params); + if (res.success) { + message.success(res.msg || '同步成功'); + loadData(); + return true; + } else { + message.error(res.msg || '同步失败'); + return false; + } + }} + submitter={{ + submitButtonProps: { + loading: crossSyncLoading, + }, + }} + > + + + Gitee', value: 'to_gitee' }, + { label: 'Gitee -> Gitlink', value: 'to_gitlink' }, + ]} + initialValue="to_gitee" + rules={[{ required: true, message: '请选择同步方向' }]} + /> + + + ); }; diff --git a/web/src/services/Pullrequests.ts b/web/src/services/Pullrequests.ts index 54ecf7653..caeb94ac2 100644 --- a/web/src/services/Pullrequests.ts +++ b/web/src/services/Pullrequests.ts @@ -121,3 +121,35 @@ export async function pressPullRequest( }, ); } + +/** crossSyncPR 跨平台同步PR POST /cerobot/projects/${param0}/cross-sync-pr */ +export async function crossSyncPR( + params: { + // path + name: string; + // query + /** 源仓库地址(格式:owner/repo) */ + source_repo: string; + /** 目标仓库地址(格式:owner/repo) */ + target_repo: string; + /** 同步方向,可选值:to_gitee(Gitlink到Gitee)或to_gitlink(Gitee到Gitlink) */ + direction?: 'to_gitee' | 'to_gitlink'; + /** Gitlink Cookie */ + gitlink_cookie?: string; + /** Gitee Token */ + gitee_token?: string; + }, + options?: { [key: string]: any }, +) { + const { name: param0, ...queryParams } = params; + return request( + `/cerobot/projects/${param0}/cross-sync-pr`, + { + method: 'POST', + params: { + ...queryParams, + }, + ...(options || {}), + }, + ); +}