forked from Lesin/reposync
562 lines
24 KiB
Python
562 lines
24 KiB
Python
#!/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 []
|
||
) |