reposync/issue_sync_module/clients/gitlink_client.py

562 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GitLink Issue API 客户端
"""
import requests
import json
import os
from typing import List, Dict, Optional
try:
from src.base import config
from src.utils.logger import logger
except ImportError:
# 兼容独立运行
import logging
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
config = None
from .gitlink_parser import GitLinkIssueParser
class GitLinkIssueClient:
"""GitLink Issue操作客户端 - 使用Bearer Token认证"""
def __init__(self, organization: str, repo_name: str):
self.organization = organization
self.repo_name = repo_name
# 获取GitLink Token和Cookie
if config:
self.token = config.ACCOUNT.get('gitlink_token', '')
self.cookie = config.ACCOUNT.get('gitlink_cookie', '47e59630e29a069a4489476a5489991d50feac84')
self.base_url = config.GITLINK_ENV.get('gitlink_api_address', 'https://gitlink.org.cn/api/v1')
else:
# 从环境变量获取,提供默认值
self.token = os.getenv('GITLINK_TOKEN', '')
self.cookie = os.getenv('GITLINK_COOKIE', '47e59630e29a069a4489476a5489991d50feac84') # 使用硬编码的cookie
self.base_url = os.getenv('GITLINK_API_HOST', 'https://gitlink.org.cn/api/v1')
# 确保base_url不为空
if not self.base_url or self.base_url.strip() == '':
self.base_url = 'https://gitlink.org.cn/api/v1'
logger.warning("GitLink base_url为空使用默认值: https://gitlink.org.cn/api/v1")
logger.info(f"GitLink API Base URL: {self.base_url}")
# 设置请求头
self.headers = {
'Content-Type': 'application/json',
'User-Agent': 'RepoSync/1.0.0'
}
# 设置认证方式优先级Token > Cookie > 无认证)
self.cookies = {}
if self.token:
self.headers['Authorization'] = f'Bearer {self.token}'
logger.info("GitLink: 使用Token认证")
elif self.cookie:
self.cookies['autologin_trustie'] = self.cookie
logger.info("GitLink: 使用Cookie认证")
else:
logger.warning("GitLink: 未设置Token或Cookie尝试无认证访问")
def get_issue_statuses(self) -> List[Dict]:
"""获取疑修状态列表"""
# 使用正确的GitLink状态API端点格式
url = f"{self.base_url}/{self.organization}/{self.repo_name}/issue_statues.json"
params = {
'page': 1,
'limit': 15
}
try:
logger.info(f"调用GitLink状态API: {url}")
response = requests.get(url, headers=self.headers, cookies=self.cookies, params=params)
response.raise_for_status()
# 检查响应内容类型
content_type = response.headers.get('content-type', '')
if 'application/json' not in content_type and 'text/html' in content_type:
logger.error(f"GitLink状态API返回HTML页面可能是认证失败")
return []
# 检查响应内容
if response.text.strip() == '':
logger.warning("GitLink状态API返回空响应")
return []
data = response.json()
logger.info(f"GitLink状态API完整响应: {json.dumps(data, indent=2, ensure_ascii=False) if isinstance(data, dict) else data}")
# 根据API文档响应格式是 {"total_count": 5, "statues": [...]}
statuses = []
if isinstance(data, dict):
if 'statues' in data:
statuses = data['statues']
total_count = data.get('total_count', 0)
logger.info(f"GitLink状态统计 - 总计: {total_count} 个状态")
elif 'statuses' in data:
# 备用字段名
statuses = data['statuses']
elif isinstance(data, list):
# 如果直接返回状态列表
statuses = data
else:
logger.warning("未知的GitLink状态API响应格式")
statuses = []
logger.info(f"✅ 获取到GitLink仓库 {len(statuses)} 个状态")
if statuses:
logger.info(f"状态示例: {statuses[0]}")
return statuses
except requests.exceptions.HTTPError as e:
logger.error(f"GitLink状态API HTTP错误: {e.response.status_code} - {e.response.text[:200]}")
return []
except ValueError as e:
logger.error(f"GitLink状态API响应格式错误: {str(e)} - 响应内容: {response.text[:200] if 'response' in locals() else 'N/A'}")
return []
except Exception as e:
logger.error(f"GitLink状态API响应格式错误: {str(e)} - 响应内容: {response.text[:200] if 'response' in locals() else 'N/A'}")
return []
def get_issue_priorities(self) -> List[Dict]:
"""获取疑修优先级列表"""
url = f"{self.base_url}/{self.organization}/{self.repo_name}/issue_priorities.json"
try:
response = requests.get(url, headers=self.headers, cookies=self.cookies)
response.raise_for_status()
priorities = response.json()
# 调试:打印优先级响应结构
logger.info(f"GitLink优先级API响应: {json.dumps(priorities, indent=2, ensure_ascii=False) if isinstance(priorities, (dict, list)) else priorities}")
# 处理不同的响应格式
if isinstance(priorities, dict):
# 检查是否有包装的数据结构
if 'priorities' in priorities:
priorities = priorities['priorities']
elif 'total_count' in priorities and 'priorities' in priorities:
# 处理带分页信息的响应
actual_priorities = priorities['priorities']
logger.info(f"GitLink优先级API返回包装格式实际优先级数量: {len(actual_priorities)}")
priorities = actual_priorities
else:
# 如果是单个优先级对象,包装成列表
priorities = [priorities]
elif isinstance(priorities, list):
# 如果是字符串列表,转换为对象列表
if priorities and isinstance(priorities[0], str):
logger.info("GitLink优先级API返回字符串列表转换为对象格式")
priorities = [{"id": i+1, "name": name} for i, name in enumerate(priorities)]
elif priorities and isinstance(priorities[0], dict):
logger.info("GitLink优先级API返回对象列表")
else:
logger.warning("GitLink优先级API返回空列表")
else:
logger.warning(f"未知的GitLink优先级API响应类型: {type(priorities)}")
priorities = []
logger.info(f"获取到GitLink仓库 {len(priorities)} 个优先级")
return priorities
except Exception as e:
logger.error(f"获取GitLink Issue优先级失败: {str(e)}")
return []
def get_issues(self, category: str = 'all', participant_category: str = 'all',
keyword: str = '', author_id: int = None, milestone_id: int = None,
assigner_id: int = None, status_id: int = None, sort_by: str = '',
sort_direction: str = '', issue_tag_ids: str = '',
page: int = 1, limit: int = 100) -> List[Dict]:
"""获取仓库的Issue列表
Args:
category: 疑修类型all 全部 opened 开启中 closed 已关闭
participant_category: 参与类型all 全部 aboutme 关于我的 authoredme 我创建的 assignedme 我负责的 atme @我的
keyword: 搜索关键词
author_id: 发布人用户ID
milestone_id: 里程碑ID
assigner_id: 负责人用户ID
status_id: 状态ID
sort_by: 排序字段issues.updated_on 更新时间 issues.created_on 创建时间 issue_priorities.position 优先级
sort_direction: 排序类型asc 正序 desc 倒序
issue_tag_ids: 标记ID支持多个用,隔开
page: 页码
limit: 限制数量
"""
url = f"{self.base_url}/{self.organization}/{self.repo_name}/issues.json"
logger.info(f"构建GitLink Issues API URL: {url}")
# 构建查询参数按照API文档格式
params = {
'category': category,
'participant_category': participant_category,
'keyword': keyword,
'page': page,
'limit': limit
}
# 添加可选参数(只有在有值时才添加)
if author_id is not None:
params['author_id'] = author_id
if milestone_id is not None:
params['milestone_id'] = milestone_id
if assigner_id is not None:
params['assigner_id'] = assigner_id
if status_id is not None:
params['status_id'] = status_id
if sort_by:
params['sort_by'] = sort_by
if sort_direction:
params['sort_direction'] = sort_direction
if issue_tag_ids:
params['issue_tag_ids'] = issue_tag_ids
try:
response = requests.get(url, headers=self.headers, params=params, cookies=self.cookies)
response.raise_for_status()
data = response.json()
# 调试:打印完整响应结构
logger.info(f"GitLink API完整响应: {json.dumps(data, indent=2, ensure_ascii=False) if isinstance(data, dict) else data}")
# 根据官方API文档响应结构应该是:
# {
# "total_count": int,
# "opened_count": int,
# "closed_count": int,
# "issues": [...]
# }
issues = []
if isinstance(data, dict):
if 'issues' in data:
issues = data['issues']
total_count = data.get('total_count', 0)
opened_count = data.get('opened_count', 0)
closed_count = data.get('closed_count', 0)
logger.info(f"GitLink仓库统计 - 总计: {total_count}, 开启: {opened_count}, 关闭: {closed_count}")
elif 'total_issues_count' in data:
# 处理实际API返回的字段名
issues = data['issues']
total_count = data.get('total_issues_count', 0)
opened_count = data.get('opened_count', 0)
closed_count = data.get('closed_count', 0)
logger.info(f"GitLink仓库统计 - 总计: {total_count}, 开启: {opened_count}, 关闭: {closed_count}")
elif isinstance(data, list):
# 如果直接返回Issue列表
issues = data
else:
# 可能是单个Issue
if 'id' in data and 'subject' in data:
issues = [data]
logger.info(f"获取到GitLink仓库 {self.organization}/{self.repo_name}{len(issues)} 个Issue")
if issues:
logger.info(f"第一个Issue示例: 标题='{issues[0].get('subject', 'N/A')}', ID={issues[0].get('id', 'N/A')}")
return issues
except requests.exceptions.HTTPError as e:
logger.error(f"获取GitLink Issues HTTP错误: {e.response.status_code} - {e.response.text[:500]}")
return []
except ValueError as e:
logger.error(f"GitLink Issues API响应格式错误: {str(e)} - 响应内容: {response.text[:500] if 'response' in locals() else 'N/A'}")
return []
except Exception as e:
logger.error(f"获取GitLink Issues失败: {str(e)}")
return []
def get_issue(self, issue_id: int) -> Optional[Dict]:
"""获取单个Issue详情"""
url = f"{self.base_url}/{self.organization}/{self.repo_name}/issues/{issue_id}.json"
try:
response = requests.get(url, headers=self.headers, cookies=self.cookies)
response.raise_for_status()
issue = response.json()
logger.info(f"获取GitLink Issue #{issue_id} 成功")
return issue
except Exception as e:
logger.error(f"获取GitLink Issue #{issue_id} 失败: {str(e)}")
return None
def create_issue(self, title: str, description: str = "", priority_id: int = None,
status_id: int = None, assigned_ids: List[int] = None) -> Optional[Dict]:
"""创建新Issue
Args:
title: Issue标题
description: Issue描述
priority_id: 优先级ID必需可先调用get_issue_priorities获取
status_id: 状态ID必需可先调用get_issue_statuses获取
assigned_ids: 负责人ID列表
"""
url = f"{self.base_url}/{self.organization}/{self.repo_name}/issues.json"
# 如果没有提供必需的ID尝试获取默认值
if not status_id:
statuses = self.get_issue_statuses()
if statuses:
status_id = statuses[0]['id']
else:
# 如果无法获取状态列表(可能需要认证),使用常见的默认值
status_id = 1 # 通常1代表"新建"或"打开"状态
logger.warning(f"无法获取GitLink状态列表使用默认status_id: {status_id}")
if not priority_id:
priorities = self.get_issue_priorities()
if priorities:
priority_id = priorities[0]['id']
else:
# 如果无法获取优先级列表,使用常见的默认值
priority_id = 2 # 通常2代表"普通"优先级
logger.warning(f"无法获取GitLink优先级列表使用默认priority_id: {priority_id}")
data = {
"subject": title,
"description": description or "",
"status_id": status_id,
"priority_id": priority_id
}
if assigned_ids:
data["assigner_ids"] = assigned_ids
try:
response = requests.post(url, headers=self.headers, json=data, cookies=self.cookies)
response.raise_for_status()
issue = response.json()
logger.info(f"创建GitLink Issue成功: #{issue.get('id', 'unknown')} - {title}")
return issue
except requests.exceptions.HTTPError as e:
logger.error(f"创建GitLink Issue HTTP错误: {e.response.status_code}")
logger.error(f"响应内容: {e.response.text[:500]}")
logger.error(f"请求数据: {data}")
return None
except Exception as e:
logger.error(f"创建GitLink Issue失败: {str(e)}")
logger.error(f"请求数据: {data}")
return None
def update_issue(self, issue_id: int, title: str = None, description: str = None,
priority: str = None, status: str = None) -> Optional[Dict]:
"""更新Issue"""
url = f"{self.base_url}/{self.organization}/{self.repo_name}/issues/{issue_id}.json"
data = {}
if title is not None:
data["subject"] = title
if description is not None:
data["description"] = description
if priority is not None:
data["priority_id"] = GitLinkIssueParser.get_priority_id(priority)
if status is not None:
data["status_id"] = GitLinkIssueParser.get_status_id(status)
try:
response = requests.put(url, headers=self.headers, json=data, cookies=self.cookies)
response.raise_for_status()
issue = response.json()
logger.info(f"更新GitLink Issue #{issue_id} 成功")
return issue
except Exception as e:
logger.error(f"更新GitLink Issue #{issue_id} 失败: {str(e)}")
return None
def close_issue(self, issue_id: int) -> bool:
"""关闭Issue"""
return self.update_issue(issue_id, status="closed") is not None
def delete_issue(self, issue_id: int) -> bool:
"""删除Issue"""
url = f"{self.base_url}/{self.organization}/{self.repo_name}/issues/{issue_id}.json"
try:
response = requests.delete(url, headers=self.headers, cookies=self.cookies)
response.raise_for_status()
logger.info(f"删除GitLink Issue #{issue_id} 成功")
return True
except Exception as e:
logger.error(f"删除GitLink Issue #{issue_id} 失败: {str(e)}")
return False
def find_issue_by_title(self, title: str) -> Optional[Dict]:
"""根据标题查找Issue避免重复创建"""
issues = self.get_issues()
for issue in issues:
if issue.get('subject', '') == title:
return issue
return None
def parse_issue(self, issue: Dict) -> Dict:
"""解析Issue数据为标准格式"""
return GitLinkIssueParser.parse_issue(issue)
def get_issue_comments(self, issue_index: int, category: str = 'comment',
page: int = 1, limit: int = 100) -> List[Dict]:
"""获取Issue的评论列表
Args:
issue_index: Issue序号 (project_issues_index)
category: 类型 - 'comment' 仅评论, 'operate' 仅操作记录, 'all' 所有
page: 页码
limit: 每页数量
Returns:
评论列表
"""
url = f"{self.base_url}/{self.organization}/{self.repo_name}/issues/{issue_index}/journals.json"
params = {
'category': category,
'page': page,
'limit': limit
}
try:
response = requests.get(url, headers=self.headers, cookies=self.cookies, params=params)
response.raise_for_status()
data = response.json()
logger.info(f"GitLink评论API完整响应: {json.dumps(data, indent=2, ensure_ascii=False) if isinstance(data, dict) else data}")
# 解析响应数据
if isinstance(data, dict) and 'journals' in data:
journals = data['journals']
total_count = data.get('total_comment_journals_count', 0)
logger.info(f"获取到GitLink Issue #{issue_index}{len(journals)} 条记录,其中评论 {total_count}")
# 如果只要评论,过滤掉操作记录
if category == 'comment':
comments = [j for j in journals if not j.get('is_journal_detail', False)]
logger.info(f"过滤后的评论数量: {len(comments)}")
return comments
else:
return journals
else:
logger.warning("GitLink评论API响应格式异常")
return []
except requests.exceptions.HTTPError as e:
logger.error(f"获取GitLink Issue #{issue_index} 评论HTTP错误: {e.response.status_code} - {e.response.text[:200]}")
return []
except ValueError as e:
logger.error(f"GitLink评论API响应解析错误: {str(e)} - 响应内容: {response.text[:200] if 'response' in locals() else 'N/A'}")
return []
except Exception as e:
logger.error(f"获取GitLink Issue #{issue_index} 评论失败: {str(e)}")
return []
def get_issue_comment(self, comment_id: int) -> Optional[Dict]:
"""获取单个评论详情如果API支持"""
# 注意GitLink API文档中没有单独获取评论的接口需要通过列表接口获取
logger.warning("GitLink不支持单独获取评论请使用get_issue_comments方法")
return None
def create_issue_comment(self, issue_index: int, notes: str,
parent_id: int = None, reply_id: int = None,
attachment_ids: List[int] = None,
receivers_login: List[str] = None) -> Optional[Dict]:
"""创建Issue评论
Args:
issue_index: Issue序号 (project_issues_index)
notes: 评论内容
parent_id: 父评论ID (用于评论的评论)
reply_id: 回复的评论ID
attachment_ids: 附件ID列表
receivers_login: @用户名列表
Returns:
创建的评论对象
"""
url = f"{self.base_url}/{self.organization}/{self.repo_name}/issues/{issue_index}/journals.json"
# 构建请求数据
data = {
'notes': notes,
'receivers_login': receivers_login or [] # 必需字段,如果为空则传空数组
}
# 添加可选参数
if parent_id is not None:
data['parent_id'] = parent_id
if reply_id is not None:
data['reply_id'] = reply_id
if attachment_ids:
data['attachment_ids'] = attachment_ids
# 设置请求头
headers = {
'Content-Type': 'application/json'
}
headers.update(self.headers) # 合并已有的headers
try:
response = requests.post(url, json=data, headers=headers, cookies=self.cookies)
response.raise_for_status()
comment = response.json()
logger.info(f"GitLink评论创建响应: {json.dumps(comment, indent=2, ensure_ascii=False) if isinstance(comment, dict) else comment}")
# 验证响应格式
if isinstance(comment, dict) and 'id' in comment:
comment_id = comment.get('id')
comment_content = comment.get('notes', '')[:50] + ('...' if len(comment.get('notes', '')) > 50 else '')
logger.info(f"✅ 成功创建GitLink Issue #{issue_index} 评论: #{comment_id} - '{comment_content}'")
return comment
else:
logger.warning("GitLink评论创建响应格式异常")
return None
except requests.exceptions.HTTPError as e:
logger.error(f"创建GitLink Issue #{issue_index} 评论HTTP错误: {e.response.status_code} - {e.response.text[:200]}")
return None
except ValueError as e:
logger.error(f"GitLink评论创建响应解析错误: {str(e)} - 响应内容: {response.text[:200] if 'response' in locals() else 'N/A'}")
return None
except Exception as e:
logger.error(f"创建GitLink Issue #{issue_index} 评论失败: {str(e)}")
return None
def reply_to_comment(self, issue_index: int, parent_comment_id: int,
reply_content: str, mention_users: List[str] = None) -> Optional[Dict]:
"""回复Issue评论便捷方法
Args:
issue_index: Issue序号
parent_comment_id: 父评论ID
reply_content: 回复内容
mention_users: 要@的用户列表
Returns:
创建的回复评论对象
"""
logger.info(f"回复GitLink Issue #{issue_index} 评论 #{parent_comment_id}")
return self.create_issue_comment(
issue_index=issue_index,
notes=reply_content,
parent_id=parent_comment_id,
reply_id=parent_comment_id, # reply_id通常与parent_id相同
receivers_login=mention_users or []
)