实现了 GitLab 全功能集成,包含 Webhook 处理、API 客户端、消息收发、会话管理、安全校验、限流器等完整模块,支持 Issues、Merge Requests、流水线等事件监听与交互。
430 lines
17 KiB
Python
430 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
|
|
import gitlab
|
|
|
|
from yuxi.channel.extensions.gitlab.rate_limiter import gitlab_rate_limiter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AsyncGitLabClient:
|
|
"""python-gitlab 的异步包装器,所有同步调用通过 asyncio.to_thread 包装"""
|
|
|
|
def __init__(self, base_url: str, private_token: str, ssl_verify: bool | str = True):
|
|
self._gl = gitlab.Gitlab(
|
|
url=base_url,
|
|
private_token=private_token,
|
|
ssl_verify=ssl_verify,
|
|
retry_transient_errors=True,
|
|
timeout=30,
|
|
)
|
|
self._project_cache: dict[int, gitlab.v4.objects.Project] = {}
|
|
|
|
async def _run(self, func, *args, **kwargs):
|
|
await gitlab_rate_limiter.acquire()
|
|
return await asyncio.to_thread(func, *args, **kwargs)
|
|
|
|
async def get_project(self, project_id: int):
|
|
if project_id not in self._project_cache:
|
|
self._project_cache[project_id] = await self._run(self._gl.projects.get, project_id)
|
|
return self._project_cache[project_id]
|
|
|
|
async def probe(self) -> dict | None:
|
|
try:
|
|
version = await self._run(self._gl.version)
|
|
return version
|
|
except Exception:
|
|
logger.exception("GitLab API probe failed")
|
|
return None
|
|
|
|
async def get_current_user(self) -> dict:
|
|
user = await self._run(self._gl.user)
|
|
return user.as_dict() if hasattr(user, "as_dict") else dict(user.attributes)
|
|
|
|
async def list_issues(self, project_id: int, state: str = "opened", **filters):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.issues.list, state=state, per_page=100, **filters)
|
|
|
|
async def get_issue(self, project_id: int, issue_iid: int):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.issues.get, issue_iid)
|
|
|
|
async def create_issue(self, project_id: int, data: dict):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.issues.create, data)
|
|
|
|
async def update_issue(self, project_id: int, issue_iid: int, data: dict):
|
|
issue = await self.get_issue(project_id, issue_iid)
|
|
for key, value in data.items():
|
|
setattr(issue, key, value)
|
|
return await self._run(issue.save)
|
|
|
|
async def list_issue_notes(self, project_id: int, issue_iid: int, **filters):
|
|
issue = await self.get_issue(project_id, issue_iid)
|
|
return await self._run(issue.notes.list, per_page=100, **filters)
|
|
|
|
async def create_issue_note(self, project_id: int, issue_iid: int, body: str) -> dict:
|
|
issue = await self.get_issue(project_id, issue_iid)
|
|
note = await self._run(issue.notes.create, {"body": body})
|
|
return note.attributes if hasattr(note, "attributes") else dict(note)
|
|
|
|
async def edit_note(
|
|
self,
|
|
project_id: int,
|
|
noteable_type: str,
|
|
noteable_iid: int,
|
|
note_id: int,
|
|
body: str,
|
|
) -> dict:
|
|
project = await self.get_project(project_id)
|
|
if noteable_type == "Issue":
|
|
noteable = await self._run(project.issues.get, noteable_iid)
|
|
elif noteable_type == "MergeRequest":
|
|
noteable = await self._run(project.mergerequests.get, noteable_iid)
|
|
else:
|
|
raise ValueError(f"unsupported noteable_type: {noteable_type}")
|
|
note = await self._run(noteable.notes.get, note_id)
|
|
note.body = body
|
|
await self._run(note.save)
|
|
return note.attributes if hasattr(note, "attributes") else dict(note)
|
|
|
|
async def create_mr_note(self, project_id: int, mr_iid: int, body: str) -> dict:
|
|
project = await self.get_project(project_id)
|
|
mr = await self._run(project.mergerequests.get, mr_iid)
|
|
note = await self._run(mr.notes.create, {"body": body})
|
|
return note.attributes if hasattr(note, "attributes") else dict(note)
|
|
|
|
async def create_diff_note(
|
|
self,
|
|
project_id: int,
|
|
mr_iid: int,
|
|
body: str,
|
|
file_path: str,
|
|
new_line: int,
|
|
old_line: int | None = None,
|
|
base_sha: str = "",
|
|
head_sha: str = "",
|
|
start_sha: str = "",
|
|
) -> dict:
|
|
project = await self.get_project(project_id)
|
|
mr = await self._run(project.mergerequests.get, mr_iid)
|
|
|
|
if not all([base_sha, head_sha]):
|
|
diff_refs = mr.diff_refs
|
|
base_sha = base_sha or diff_refs.get("base_sha", "")
|
|
head_sha = head_sha or diff_refs.get("head_sha", "")
|
|
start_sha = start_sha or diff_refs.get("start_sha", base_sha)
|
|
|
|
position = {
|
|
"base_sha": base_sha,
|
|
"start_sha": start_sha,
|
|
"head_sha": head_sha,
|
|
"position_type": "text",
|
|
"new_path": file_path,
|
|
"old_path": file_path,
|
|
"new_line": new_line,
|
|
"old_line": old_line,
|
|
}
|
|
|
|
note = await self._run(mr.notes.create, {"body": body, "position": position})
|
|
return note.attributes if hasattr(note, "attributes") else dict(note)
|
|
|
|
async def list_mrs(
|
|
self,
|
|
project_id: int,
|
|
state: str = "opened",
|
|
order_by: str = "created_at",
|
|
sort: str = "desc",
|
|
**filters,
|
|
):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(
|
|
project.mergerequests.list,
|
|
state=state,
|
|
order_by=order_by,
|
|
sort=sort,
|
|
per_page=100,
|
|
**filters,
|
|
)
|
|
|
|
async def get_mr(self, project_id: int, mr_iid: int):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.mergerequests.get, mr_iid)
|
|
|
|
async def get_mr_changes(self, project_id: int, mr_iid: int):
|
|
mr = await self.get_mr(project_id, mr_iid)
|
|
return await self._run(mr.changes)
|
|
|
|
async def create_mr(
|
|
self,
|
|
project_id: int,
|
|
source_branch: str,
|
|
target_branch: str,
|
|
title: str,
|
|
description: str = "",
|
|
labels: str = "",
|
|
assignee_id: int | None = None,
|
|
remove_source_branch: bool = False,
|
|
squash: bool = False,
|
|
) -> dict:
|
|
project = await self.get_project(project_id)
|
|
data = {
|
|
"source_branch": source_branch,
|
|
"target_branch": target_branch,
|
|
"title": title,
|
|
"description": description,
|
|
"remove_source_branch": remove_source_branch,
|
|
"squash": squash,
|
|
}
|
|
if labels:
|
|
data["labels"] = labels
|
|
if assignee_id:
|
|
data["assignee_id"] = assignee_id
|
|
mr = await self._run(project.mergerequests.create, data)
|
|
return mr.attributes if hasattr(mr, "attributes") else dict(mr)
|
|
|
|
async def update_mr(self, project_id: int, mr_iid: int, data: dict) -> dict:
|
|
mr = await self.get_mr(project_id, mr_iid)
|
|
for key, value in data.items():
|
|
setattr(mr, key, value)
|
|
result = await self._run(mr.save)
|
|
return result.attributes if hasattr(result, "attributes") else dict(result)
|
|
|
|
async def merge_mr(
|
|
self,
|
|
project_id: int,
|
|
mr_iid: int,
|
|
merge_commit_message: str | None = None,
|
|
squash: bool = False,
|
|
should_remove_source_branch: bool = False,
|
|
merge_when_pipeline_succeeds: bool = False,
|
|
) -> dict:
|
|
mr = await self.get_mr(project_id, mr_iid)
|
|
data = {
|
|
"squash": squash,
|
|
"should_remove_source_branch": should_remove_source_branch,
|
|
}
|
|
if merge_commit_message:
|
|
data["merge_commit_message"] = merge_commit_message
|
|
if merge_when_pipeline_succeeds:
|
|
data["merge_when_pipeline_succeeds"] = True
|
|
result = await self._run(mr.merge, **data)
|
|
if isinstance(result, dict):
|
|
return result
|
|
if hasattr(result, "attributes"):
|
|
return result.attributes
|
|
return dict(result)
|
|
|
|
async def approve_mr(self, project_id: int, mr_iid: int) -> dict:
|
|
mr = await self.get_mr(project_id, mr_iid)
|
|
result = await self._run(mr.approve)
|
|
return result if isinstance(result, dict) else {}
|
|
|
|
async def unapprove_mr(self, project_id: int, mr_iid: int) -> dict:
|
|
mr = await self.get_mr(project_id, mr_iid)
|
|
result = await self._run(mr.unapprove)
|
|
return result if isinstance(result, dict) else {}
|
|
|
|
async def get_pipeline(self, project_id: int, pipeline_id: int):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.pipelines.get, pipeline_id)
|
|
|
|
async def get_job_trace(self, project_id: int, job_id: int, tail_lines: int = 200) -> str:
|
|
project = await self.get_project(project_id)
|
|
job = await self._run(project.jobs.get, job_id)
|
|
trace_bytes = await self._run(job.trace)
|
|
trace = trace_bytes.decode("utf-8", errors="replace")
|
|
lines = trace.splitlines()
|
|
if len(lines) > tail_lines:
|
|
return "\n".join(lines[-tail_lines:])
|
|
return trace
|
|
|
|
async def add_reaction(
|
|
self,
|
|
project_id: int,
|
|
noteable_type: str,
|
|
noteable_iid: int,
|
|
note_id: int,
|
|
emoji_name: str,
|
|
) -> dict:
|
|
project = await self.get_project(project_id)
|
|
if noteable_type == "Issue":
|
|
noteable = await self._run(project.issues.get, noteable_iid)
|
|
else:
|
|
noteable = await self._run(project.mergerequests.get, noteable_iid)
|
|
note = await self._run(noteable.notes.get, note_id)
|
|
emoji = await self._run(note.awardemojis.create, {"name": emoji_name})
|
|
return emoji.attributes if hasattr(emoji, "attributes") else dict(emoji)
|
|
|
|
async def upload_file(self, project_id: int, file_path: str) -> dict:
|
|
project = await self.get_project(project_id)
|
|
uploaded = await self._run(project.upload, file_path)
|
|
return {
|
|
"url": uploaded.get("url", ""),
|
|
"markdown": uploaded.get("markdown", ""),
|
|
}
|
|
|
|
async def list_hooks(self, project_id: int):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.hooks.list)
|
|
|
|
async def create_hook(self, project_id: int, data: dict):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.hooks.create, data)
|
|
|
|
async def delete_hook(self, project_id: int, hook_id: int):
|
|
project = await self.get_project(project_id)
|
|
hook = await self._run(project.hooks.get, hook_id)
|
|
await self._run(hook.delete)
|
|
|
|
async def search(
|
|
self,
|
|
project_id: int,
|
|
scope: str,
|
|
query: str,
|
|
project_ids: list[int] | None = None,
|
|
group_ids: list[int] | None = None,
|
|
) -> list:
|
|
project = await self.get_project(project_id)
|
|
kwargs = {}
|
|
if project_ids:
|
|
kwargs["project_ids"] = project_ids
|
|
if group_ids:
|
|
kwargs["group_ids"] = group_ids
|
|
return await self._run(project.search, scope, query, **kwargs)
|
|
|
|
async def search_code(self, project_id: int, query: str, scope: str = "blobs"):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.search, scope, query)
|
|
|
|
async def list_pipelines(self, project_id: int, status: str | None = None, **filters):
|
|
project = await self.get_project(project_id)
|
|
kwargs = {"per_page": 100, **filters}
|
|
if status:
|
|
kwargs["status"] = status
|
|
return await self._run(project.pipelines.list, **kwargs)
|
|
|
|
async def cancel_pipeline(self, project_id: int, pipeline_id: int) -> dict:
|
|
project = await self.get_project(project_id)
|
|
pipeline = await self._run(project.pipelines.get, pipeline_id)
|
|
result = await self._run(pipeline.cancel)
|
|
return result if isinstance(result, dict) else dict(result)
|
|
|
|
async def retry_pipeline(self, project_id: int, pipeline_id: int) -> dict:
|
|
project = await self.get_project(project_id)
|
|
pipeline = await self._run(project.pipelines.get, pipeline_id)
|
|
result = await self._run(pipeline.retry)
|
|
return result if isinstance(result, dict) else dict(result)
|
|
|
|
async def list_jobs(self, project_id: int, scope: str | None = None, **filters):
|
|
project = await self.get_project(project_id)
|
|
kwargs = {"per_page": 100, **filters}
|
|
if scope:
|
|
kwargs["scope"] = scope
|
|
return await self._run(project.jobs.list, **kwargs)
|
|
|
|
async def get_job(self, project_id: int, job_id: int):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.jobs.get, job_id)
|
|
|
|
async def cancel_job(self, project_id: int, job_id: int) -> dict:
|
|
project = await self.get_project(project_id)
|
|
job = await self._run(project.jobs.get, job_id)
|
|
result = await self._run(job.cancel)
|
|
return result if isinstance(result, dict) else dict(result)
|
|
|
|
async def retry_job(self, project_id: int, job_id: int) -> dict:
|
|
project = await self.get_project(project_id)
|
|
job = await self._run(project.jobs.get, job_id)
|
|
result = await self._run(job.retry)
|
|
return result if isinstance(result, dict) else dict(result)
|
|
|
|
async def list_branches(self, project_id: int, **filters):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.branches.list, per_page=100, **filters)
|
|
|
|
async def get_branch(self, project_id: int, branch_name: str):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.branches.get, branch_name)
|
|
|
|
async def create_branch(self, project_id: int, branch_name: str, ref: str):
|
|
project = await self.get_project(project_id)
|
|
branch = await self._run(project.branches.create, {"branch": branch_name, "ref": ref})
|
|
return branch.attributes if hasattr(branch, "attributes") else dict(branch)
|
|
|
|
async def delete_branch(self, project_id: int, branch_name: str):
|
|
project = await self.get_project(project_id)
|
|
await self._run(project.branches.delete, branch_name)
|
|
return {"deleted": True}
|
|
|
|
async def list_tags(self, project_id: int, **filters):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.tags.list, per_page=100, **filters)
|
|
|
|
async def create_tag(self, project_id: int, tag_name: str, ref: str, message: str = ""):
|
|
project = await self.get_project(project_id)
|
|
data = {"tag_name": tag_name, "ref": ref}
|
|
if message:
|
|
data["message"] = message
|
|
tag = await self._run(project.tags.create, data)
|
|
return tag.attributes if hasattr(tag, "attributes") else dict(tag)
|
|
|
|
async def delete_tag(self, project_id: int, tag_name: str):
|
|
project = await self.get_project(project_id)
|
|
await self._run(project.tags.delete, tag_name)
|
|
return {"deleted": True}
|
|
|
|
async def list_releases(self, project_id: int, **filters):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.releases.list, per_page=100, **filters)
|
|
|
|
async def create_release(
|
|
self,
|
|
project_id: int,
|
|
tag_name: str,
|
|
name: str,
|
|
description: str = "",
|
|
ref: str = "",
|
|
) -> dict:
|
|
project = await self.get_project(project_id)
|
|
data = {"tag_name": tag_name, "name": name, "description": description}
|
|
if ref:
|
|
data["ref"] = ref
|
|
release = await self._run(project.releases.create, data)
|
|
return release.attributes if hasattr(release, "attributes") else dict(release)
|
|
|
|
async def get_file(self, project_id: int, file_path: str, ref: str = "main") -> dict:
|
|
project = await self.get_project(project_id)
|
|
f = await self._run(project.files.get, file_path=file_path, ref=ref)
|
|
return f.attributes if hasattr(f, "attributes") else dict(f)
|
|
|
|
async def list_labels(self, project_id: int, **filters):
|
|
project = await self.get_project(project_id)
|
|
return await self._run(project.labels.list, per_page=100, **filters)
|
|
|
|
async def create_label(self, project_id: int, name: str, color: str, description: str = ""):
|
|
project = await self.get_project(project_id)
|
|
label = await self._run(project.labels.create, {"name": name, "color": color, "description": description})
|
|
return label.attributes if hasattr(label, "attributes") else dict(label)
|
|
|
|
async def reopen_issue(self, project_id: int, issue_iid: int) -> dict:
|
|
return await self.update_issue(project_id, issue_iid, {"state_event": "reopen"})
|
|
|
|
async def list_discussions(self, project_id: int, noteable_type: str, noteable_iid: int):
|
|
project = await self.get_project(project_id)
|
|
if noteable_type == "Issue":
|
|
noteable = await self._run(project.issues.get, noteable_iid)
|
|
else:
|
|
noteable = await self._run(project.mergerequests.get, noteable_iid)
|
|
return await self._run(noteable.discussions.list, per_page=100)
|
|
|
|
async def trigger_pipeline(self, project_id: int, ref: str, variables: dict | None = None):
|
|
project = await self.get_project(project_id)
|
|
pipeline_data = {"ref": ref}
|
|
if variables:
|
|
pipeline_data["variables"] = [{"key": k, "value": v} for k, v in variables.items()]
|
|
return await self._run(project.pipelines.create, pipeline_data)
|