PR同步功能 #22

Open
lifedreamer wants to merge 18 commits from lifedreamer/reposync:qqj into master
7 changed files with 1173 additions and 33 deletions

View File

@ -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

View File

@ -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

View File

@ -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_giteeGitlink到Gitee或to_gitlinkGitee到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()

View File

@ -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 = {

714
src/common/pr_sync.py Normal file
View File

@ -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": []
},
# 配置3minimal 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()

View File

@ -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<API.SearchParams>(initSearchParams);
const [crossSyncVisible, setCrossSyncVisible] = useState<boolean>(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: <ExclamationCircleOutlined />,
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={
<Button onClick={() => history.goBack()} key="go_back">
</Button>
<Space>
<Button
type="primary"
icon={<SyncOutlined />}
onClick={() => setCrossSyncVisible(true)}
>
PR同步
</Button>
<Button onClick={handleSyncPRs}>
PR
</Button>
<Button onClick={() => history.goBack()} key="go_back">
</Button>
</Space>
}
>
<Card className="searchCard">
@ -158,6 +192,69 @@ export default (props: Props) => {
}}
/>
</Card>
{/* 跨平台PR同步弹窗 */}
<ModalForm
title="跨平台PR同步"
visible={crossSyncVisible}
onVisibleChange={setCrossSyncVisible}
onFinish={async (values) => {
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,
},
}}
>
<ProFormText
name="source_repo"
label="源仓库地址"
placeholder="请输入源仓库地址格式owner/repo"
rules={[{ required: true, message: '请输入源仓库地址' }]}
/>
<ProFormText
name="target_repo"
label="目标仓库地址"
placeholder="请输入目标仓库地址格式owner/repo"
rules={[{ required: true, message: '请输入目标仓库地址' }]}
/>
<ProFormSelect
name="direction"
label="同步方向"
options={[
{ label: 'Gitlink -> Gitee', value: 'to_gitee' },
{ label: 'Gitee -> Gitlink', value: 'to_gitlink' },
]}
initialValue="to_gitee"
rules={[{ required: true, message: '请选择同步方向' }]}
/>
<ProFormText
name="gitlink_cookie"
label="Gitlink Cookie"
placeholder="可选,不填则使用系统配置"
/>
<ProFormText
name="gitee_token"
label="Gitee Token"
placeholder="可选,不填则使用系统配置"
/>
</ModalForm>
</PageContainer>
);
};

View File

@ -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_giteeGitlink到Gitee或to_gitlinkGitee到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<API.Response>(
`/cerobot/projects/${param0}/cross-sync-pr`,
{
method: 'POST',
params: {
...queryParams,
},
...(options || {}),
},
);
}