nonebot-plugin-git-poller 0.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_plugin_git_poller/__init__.py +449 -0
- nonebot_plugin_git_poller/archive.py +145 -0
- nonebot_plugin_git_poller/command_args.py +31 -0
- nonebot_plugin_git_poller/config.py +17 -0
- nonebot_plugin_git_poller/file_server.py +126 -0
- nonebot_plugin_git_poller/git.py +430 -0
- nonebot_plugin_git_poller/message.py +135 -0
- nonebot_plugin_git_poller/mirror.py +485 -0
- nonebot_plugin_git_poller/models.py +153 -0
- nonebot_plugin_git_poller/repository.py +161 -0
- nonebot_plugin_git_poller/schedule.py +137 -0
- nonebot_plugin_git_poller/state.py +229 -0
- nonebot_plugin_git_poller-0.1.5.dist-info/METADATA +103 -0
- nonebot_plugin_git_poller-0.1.5.dist-info/RECORD +15 -0
- nonebot_plugin_git_poller-0.1.5.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from nonebot import get_bots, logger, on_command, require
|
|
7
|
+
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message
|
|
8
|
+
from nonebot.exception import FinishedException
|
|
9
|
+
from nonebot.matcher import Matcher
|
|
10
|
+
from nonebot.params import ArgPlainText, CommandArg
|
|
11
|
+
from nonebot.plugin import PluginMetadata
|
|
12
|
+
|
|
13
|
+
require("nonebot_plugin_apscheduler")
|
|
14
|
+
require("nonebot_plugin_localstore")
|
|
15
|
+
|
|
16
|
+
from nonebot_plugin_apscheduler import scheduler
|
|
17
|
+
|
|
18
|
+
from .command_args import parse_repo_command_args
|
|
19
|
+
from .config import Config, plugin_config
|
|
20
|
+
from .file_server import register_archive_file_route
|
|
21
|
+
from .message import (
|
|
22
|
+
ArchiveUploadUriError,
|
|
23
|
+
build_archive_delivery_text,
|
|
24
|
+
send_update_to_group,
|
|
25
|
+
upload_archive_to_group,
|
|
26
|
+
)
|
|
27
|
+
from .mirror import GitPollerService
|
|
28
|
+
from .schedule import parse_schedule
|
|
29
|
+
|
|
30
|
+
__plugin_meta__ = PluginMetadata(
|
|
31
|
+
name="Git Poller",
|
|
32
|
+
description="按群订阅 Git 仓库更新,支持多仓库、多分支定时拉取",
|
|
33
|
+
usage=(
|
|
34
|
+
"/关注仓库 仓库url [--分支名]\n"
|
|
35
|
+
"/取关仓库 仓库url [--分支名]\n"
|
|
36
|
+
"/设置仓库 仓库url [--分支名]\n"
|
|
37
|
+
"/仓库列表\n"
|
|
38
|
+
"/拉取仓库 仓库url [--分支名]\n"
|
|
39
|
+
"/仓库摘要 仓库url [--分支名]"
|
|
40
|
+
),
|
|
41
|
+
type="application",
|
|
42
|
+
config=Config,
|
|
43
|
+
supported_adapters={"~onebot.v11"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
service = GitPollerService(plugin_config)
|
|
47
|
+
COMMAND_PRIORITY = 10
|
|
48
|
+
|
|
49
|
+
follow_repo = on_command(
|
|
50
|
+
"关注仓库",
|
|
51
|
+
priority=COMMAND_PRIORITY,
|
|
52
|
+
block=True,
|
|
53
|
+
)
|
|
54
|
+
unfollow_repo = on_command(
|
|
55
|
+
"取关仓库",
|
|
56
|
+
priority=COMMAND_PRIORITY,
|
|
57
|
+
block=True,
|
|
58
|
+
)
|
|
59
|
+
configure_repo = on_command(
|
|
60
|
+
"设置仓库",
|
|
61
|
+
priority=COMMAND_PRIORITY,
|
|
62
|
+
block=True,
|
|
63
|
+
)
|
|
64
|
+
list_repos = on_command(
|
|
65
|
+
"仓库列表",
|
|
66
|
+
priority=COMMAND_PRIORITY,
|
|
67
|
+
block=True,
|
|
68
|
+
)
|
|
69
|
+
pull_repo = on_command(
|
|
70
|
+
"拉取仓库",
|
|
71
|
+
priority=COMMAND_PRIORITY,
|
|
72
|
+
block=True,
|
|
73
|
+
)
|
|
74
|
+
summarize_repo = on_command(
|
|
75
|
+
"仓库摘要",
|
|
76
|
+
priority=COMMAND_PRIORITY,
|
|
77
|
+
block=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@follow_repo.handle()
|
|
82
|
+
async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()) -> None:
|
|
83
|
+
try:
|
|
84
|
+
parsed = _repo_args(args)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
await matcher.finish(f"用法:/关注仓库 仓库url [--分支名]\n{exc}")
|
|
87
|
+
if parsed is None:
|
|
88
|
+
await matcher.finish("用法:/关注仓库 仓库url [--分支名]")
|
|
89
|
+
group_id = int(event.group_id)
|
|
90
|
+
try:
|
|
91
|
+
result = await service.follow_repo(group_id, parsed.url, parsed.branch)
|
|
92
|
+
if result.already_following:
|
|
93
|
+
await matcher.finish(
|
|
94
|
+
f"本群已经关注:{result.identity.display_name}\n"
|
|
95
|
+
f"分支:{result.subscription.branch}\n"
|
|
96
|
+
f"定时:{result.subscription.schedule}"
|
|
97
|
+
)
|
|
98
|
+
await matcher.finish(
|
|
99
|
+
f"已关注:{result.identity.display_name}\n"
|
|
100
|
+
f"分支:{result.subscription.branch}\n"
|
|
101
|
+
f"定时:{result.subscription.schedule}\n"
|
|
102
|
+
f"当前 commit 已记录,后续有新提交时推送。"
|
|
103
|
+
)
|
|
104
|
+
except FinishedException:
|
|
105
|
+
raise
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
logger.exception("git poller follow command failed")
|
|
108
|
+
await matcher.finish(f"关注仓库失败:{exc}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@unfollow_repo.handle()
|
|
112
|
+
async def _(event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()) -> None:
|
|
113
|
+
try:
|
|
114
|
+
parsed = _repo_args(args)
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
await matcher.finish(f"用法:/取关仓库 仓库url [--分支名]\n{exc}")
|
|
117
|
+
if parsed is None:
|
|
118
|
+
await matcher.finish("用法:/取关仓库 仓库url [--分支名]")
|
|
119
|
+
try:
|
|
120
|
+
identity, removed = service.unfollow_repo(
|
|
121
|
+
int(event.group_id),
|
|
122
|
+
parsed.url,
|
|
123
|
+
parsed.branch,
|
|
124
|
+
)
|
|
125
|
+
if removed:
|
|
126
|
+
_schedule_repo_cleanup(identity.key)
|
|
127
|
+
await matcher.finish(f"已取关:{identity.display_name}")
|
|
128
|
+
await matcher.finish(f"本群没有关注:{identity.display_name}")
|
|
129
|
+
except FinishedException:
|
|
130
|
+
raise
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
logger.exception("git poller unfollow command failed")
|
|
133
|
+
await matcher.finish(f"取关仓库失败:{exc}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@configure_repo.handle()
|
|
137
|
+
async def _(
|
|
138
|
+
event: GroupMessageEvent,
|
|
139
|
+
matcher: Matcher,
|
|
140
|
+
args: Message = CommandArg(),
|
|
141
|
+
) -> None:
|
|
142
|
+
try:
|
|
143
|
+
parsed = _repo_args(args)
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
await matcher.finish(f"用法:/设置仓库 仓库url [--分支名]\n{exc}")
|
|
146
|
+
if parsed is None:
|
|
147
|
+
await matcher.finish("用法:/设置仓库 仓库url [--分支名]")
|
|
148
|
+
try:
|
|
149
|
+
identity, subscription = service.get_repo_subscription(
|
|
150
|
+
int(event.group_id),
|
|
151
|
+
parsed.url,
|
|
152
|
+
parsed.branch,
|
|
153
|
+
)
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
logger.exception("git poller configure command failed")
|
|
156
|
+
await matcher.finish(f"设置仓库失败:{exc}")
|
|
157
|
+
matcher.state["repo_url"] = parsed.url
|
|
158
|
+
matcher.state["repo_branch"] = subscription.branch
|
|
159
|
+
matcher.state["repo_identity_name"] = identity.display_name
|
|
160
|
+
matcher.state["repo_subscription_branch"] = subscription.branch
|
|
161
|
+
matcher.state["repo_config_group_id"] = int(event.group_id)
|
|
162
|
+
matcher.state["repo_config_user_id"] = int(event.user_id)
|
|
163
|
+
await matcher.send(
|
|
164
|
+
"输入设置数字选项\n"
|
|
165
|
+
"1. 修改当前仓库推送抓取时间\n"
|
|
166
|
+
"2. 修改当前仓库上传压缩包密码(选择后输入无则清除当前仓库密码回到全局默认)"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@configure_repo.got("setting_choice")
|
|
171
|
+
async def _(
|
|
172
|
+
event: GroupMessageEvent,
|
|
173
|
+
matcher: Matcher,
|
|
174
|
+
choice: str = ArgPlainText("setting_choice"),
|
|
175
|
+
) -> None:
|
|
176
|
+
if not _same_config_session(event, matcher):
|
|
177
|
+
await matcher.finish("输入非法,已取消设置。")
|
|
178
|
+
choice = choice.strip()
|
|
179
|
+
if choice not in {"1", "2"}:
|
|
180
|
+
await matcher.finish("输入非法,已取消设置。")
|
|
181
|
+
matcher.state["setting_choice"] = choice
|
|
182
|
+
if choice == "1":
|
|
183
|
+
await matcher.send("请输入新的推送抓取时间,例如:每日04:00、每3天04:00、周一04:00")
|
|
184
|
+
else:
|
|
185
|
+
await matcher.send("请输入新的压缩包密码。输入 无 则清除当前仓库密码回到全局默认。")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@configure_repo.got("setting_value")
|
|
189
|
+
async def _(
|
|
190
|
+
event: GroupMessageEvent,
|
|
191
|
+
matcher: Matcher,
|
|
192
|
+
value: str = ArgPlainText("setting_value"),
|
|
193
|
+
) -> None:
|
|
194
|
+
if not _same_config_session(event, matcher):
|
|
195
|
+
await matcher.finish("输入非法,已取消设置。")
|
|
196
|
+
group_id = int(event.group_id)
|
|
197
|
+
url = str(matcher.state["repo_url"])
|
|
198
|
+
branch = matcher.state.get("repo_branch")
|
|
199
|
+
if branch is not None:
|
|
200
|
+
branch = str(branch)
|
|
201
|
+
choice = str(matcher.state["setting_choice"])
|
|
202
|
+
value = value.strip()
|
|
203
|
+
if not value:
|
|
204
|
+
await matcher.finish("输入非法,已取消设置。")
|
|
205
|
+
|
|
206
|
+
if choice == "1":
|
|
207
|
+
try:
|
|
208
|
+
parse_schedule(value, plugin_config.git_poller_timezone)
|
|
209
|
+
identity, subscription = service.update_repo_schedule(
|
|
210
|
+
group_id,
|
|
211
|
+
url,
|
|
212
|
+
branch,
|
|
213
|
+
value,
|
|
214
|
+
)
|
|
215
|
+
_register_schedule(subscription.schedule)
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
logger.exception("git poller schedule setting failed")
|
|
218
|
+
await matcher.finish(f"输入非法,已取消设置:{exc}")
|
|
219
|
+
await matcher.finish(
|
|
220
|
+
f"设置成功:{identity.display_name}\n"
|
|
221
|
+
f"分支:{subscription.branch}\n"
|
|
222
|
+
f"推送抓取时间:{subscription.schedule}"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
password = None if value in {"无", "none", "None", "NONE", "clear", "Clear", "CLEAR"} else value
|
|
226
|
+
try:
|
|
227
|
+
identity, subscription = service.update_repo_archive_password(
|
|
228
|
+
group_id,
|
|
229
|
+
url,
|
|
230
|
+
branch,
|
|
231
|
+
password,
|
|
232
|
+
)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
logger.exception("git poller archive password setting failed")
|
|
235
|
+
await matcher.finish(f"输入非法,已取消设置:{exc}")
|
|
236
|
+
status = "回到全局默认" if subscription.archive_password is None else "已设置当前仓库密码"
|
|
237
|
+
await matcher.finish(
|
|
238
|
+
f"设置成功:{identity.display_name}\n"
|
|
239
|
+
f"分支:{subscription.branch}\n"
|
|
240
|
+
f"压缩包密码:{status}"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@list_repos.handle()
|
|
245
|
+
async def _(event: GroupMessageEvent, matcher: Matcher) -> None:
|
|
246
|
+
subscriptions = service.list_group_subscriptions(int(event.group_id))
|
|
247
|
+
if not subscriptions:
|
|
248
|
+
await matcher.finish("本群还没有关注任何仓库。")
|
|
249
|
+
lines = ["本群关注的仓库:"]
|
|
250
|
+
for index, (repo_key, subscription) in enumerate(subscriptions.items(), start=1):
|
|
251
|
+
status = "启用" if subscription.enabled else "停用"
|
|
252
|
+
last_sha = subscription.last_success_sha[:8] if subscription.last_success_sha else "未记录"
|
|
253
|
+
password_status = "仓库密码" if subscription.archive_password else "全局默认"
|
|
254
|
+
lines.append(
|
|
255
|
+
f"{index}. {subscription.url}\n"
|
|
256
|
+
f" key: {repo_key}\n"
|
|
257
|
+
f" branch: {subscription.branch} / schedule: {subscription.schedule} / {status}\n"
|
|
258
|
+
f" last_success_sha: {last_sha}\n"
|
|
259
|
+
f" archive_password: {password_status}"
|
|
260
|
+
)
|
|
261
|
+
await matcher.finish("\n".join(lines))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@pull_repo.handle()
|
|
265
|
+
async def _(bot: Bot, event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()) -> None:
|
|
266
|
+
try:
|
|
267
|
+
parsed = _repo_args(args)
|
|
268
|
+
except ValueError as exc:
|
|
269
|
+
await matcher.finish(f"用法:/拉取仓库 仓库url [--分支名]\n{exc}")
|
|
270
|
+
if parsed is None:
|
|
271
|
+
await matcher.finish("用法:/拉取仓库 仓库url [--分支名]")
|
|
272
|
+
group_id = int(event.group_id)
|
|
273
|
+
try:
|
|
274
|
+
result = await service.pull_repo(group_id, parsed.url, parsed.branch)
|
|
275
|
+
await upload_archive_to_group(
|
|
276
|
+
bot,
|
|
277
|
+
group_id,
|
|
278
|
+
result.archive,
|
|
279
|
+
config=plugin_config,
|
|
280
|
+
)
|
|
281
|
+
service.mark_pull_success(group_id, result.identity.key, result.target_sha)
|
|
282
|
+
await matcher.finish(
|
|
283
|
+
build_archive_delivery_text(
|
|
284
|
+
result.payload,
|
|
285
|
+
result.archive,
|
|
286
|
+
title="拉取完成",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
except FinishedException:
|
|
290
|
+
raise
|
|
291
|
+
except Exception as exc:
|
|
292
|
+
logger.exception("git poller pull command failed")
|
|
293
|
+
await matcher.finish(f"拉取仓库失败:{exc}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@summarize_repo.handle()
|
|
297
|
+
async def _(bot: Bot, event: GroupMessageEvent, matcher: Matcher, args: Message = CommandArg()) -> None:
|
|
298
|
+
try:
|
|
299
|
+
parsed = _repo_args(args)
|
|
300
|
+
except ValueError as exc:
|
|
301
|
+
await matcher.finish(f"用法:/仓库摘要 仓库url [--分支名]\n{exc}")
|
|
302
|
+
if parsed is None:
|
|
303
|
+
await matcher.finish("用法:/仓库摘要 仓库url [--分支名]")
|
|
304
|
+
group_id = int(event.group_id)
|
|
305
|
+
try:
|
|
306
|
+
summary = await service.summarize_repo(group_id, parsed.url, parsed.branch)
|
|
307
|
+
payload = summary.result.payload
|
|
308
|
+
if payload.previous_sha == payload.target_sha:
|
|
309
|
+
await matcher.finish(
|
|
310
|
+
f"仓库摘要:{payload.repo_name}\n"
|
|
311
|
+
f"分支:{payload.branch}\n"
|
|
312
|
+
f"本地与远程相同:{payload.target_short_sha}"
|
|
313
|
+
)
|
|
314
|
+
await send_update_to_group(bot, group_id, payload)
|
|
315
|
+
behind_text = (
|
|
316
|
+
f"本地记录落后远程 {summary.behind_count} 个 commit。"
|
|
317
|
+
if summary.behind_count is not None
|
|
318
|
+
else "本地记录与远程存在差异。"
|
|
319
|
+
)
|
|
320
|
+
await matcher.finish(
|
|
321
|
+
f"仓库摘要已发送:{payload.repo_name}\n"
|
|
322
|
+
f"分支:{payload.branch}\n"
|
|
323
|
+
f"{behind_text}\n"
|
|
324
|
+
f"本地记录未更新。"
|
|
325
|
+
)
|
|
326
|
+
except FinishedException:
|
|
327
|
+
raise
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
logger.exception("git poller summary command failed")
|
|
330
|
+
await matcher.finish(f"仓库摘要失败:{exc}")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def run_scheduled_check(schedule: str) -> None:
|
|
334
|
+
bots = get_bots()
|
|
335
|
+
if not bots:
|
|
336
|
+
logger.warning("git poller scheduled check skipped: no bot is connected")
|
|
337
|
+
return
|
|
338
|
+
bot = next(iter(bots.values()))
|
|
339
|
+
try:
|
|
340
|
+
async for result in service.poll_schedule(schedule):
|
|
341
|
+
try:
|
|
342
|
+
await upload_archive_to_group(
|
|
343
|
+
bot,
|
|
344
|
+
result.result.group_id,
|
|
345
|
+
result.archive,
|
|
346
|
+
config=plugin_config,
|
|
347
|
+
)
|
|
348
|
+
await bot.send_group_msg(
|
|
349
|
+
group_id=int(result.result.group_id),
|
|
350
|
+
message=build_archive_delivery_text(
|
|
351
|
+
result.result.payload,
|
|
352
|
+
result.archive,
|
|
353
|
+
title="拉取完成",
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
service.mark_success(result.result)
|
|
357
|
+
except ArchiveUploadUriError as exc:
|
|
358
|
+
logger.exception(
|
|
359
|
+
f"git poller scheduled archive upload URI failed: "
|
|
360
|
+
f"group={result.result.group_id}, repo={result.result.repo_key}"
|
|
361
|
+
)
|
|
362
|
+
try:
|
|
363
|
+
await bot.send_group_msg(
|
|
364
|
+
group_id=int(result.result.group_id),
|
|
365
|
+
message=str(exc),
|
|
366
|
+
)
|
|
367
|
+
except Exception:
|
|
368
|
+
logger.exception(
|
|
369
|
+
f"git poller scheduled upload error notification failed: "
|
|
370
|
+
f"group={result.result.group_id}, repo={result.result.repo_key}"
|
|
371
|
+
)
|
|
372
|
+
continue
|
|
373
|
+
except Exception:
|
|
374
|
+
logger.exception(
|
|
375
|
+
f"git poller scheduled push failed: "
|
|
376
|
+
f"group={result.result.group_id}, repo={result.result.repo_key}"
|
|
377
|
+
)
|
|
378
|
+
continue
|
|
379
|
+
except Exception:
|
|
380
|
+
logger.exception(f"git poller scheduled check failed: {schedule}")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def cleanup_unsubscribed_repo(repo_key: str) -> None:
|
|
384
|
+
try:
|
|
385
|
+
await asyncio.to_thread(service.cleanup_unsubscribed_repo, repo_key)
|
|
386
|
+
except Exception:
|
|
387
|
+
logger.exception(f"git poller delayed cleanup failed: repo={repo_key}")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _register_schedules() -> None:
|
|
391
|
+
for schedule_rule in sorted(service.scheduled_rules()):
|
|
392
|
+
_register_schedule(schedule_rule)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _cleanup_orphaned_storage_on_startup() -> None:
|
|
396
|
+
try:
|
|
397
|
+
service.cleanup_orphaned_storage()
|
|
398
|
+
except Exception:
|
|
399
|
+
logger.exception("git poller startup orphan cleanup failed")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _register_schedule(schedule_rule: str) -> None:
|
|
403
|
+
try:
|
|
404
|
+
spec = parse_schedule(schedule_rule, plugin_config.git_poller_timezone)
|
|
405
|
+
except ValueError:
|
|
406
|
+
logger.exception(f"git poller skipped invalid schedule: {schedule_rule}")
|
|
407
|
+
return
|
|
408
|
+
if spec is None:
|
|
409
|
+
return
|
|
410
|
+
scheduler.add_job(
|
|
411
|
+
run_scheduled_check,
|
|
412
|
+
spec.trigger,
|
|
413
|
+
args=[schedule_rule],
|
|
414
|
+
id=f"git_poller:{schedule_rule}",
|
|
415
|
+
max_instances=1,
|
|
416
|
+
coalesce=True,
|
|
417
|
+
replace_existing=True,
|
|
418
|
+
**spec.trigger_kwargs,
|
|
419
|
+
)
|
|
420
|
+
logger.info(f"git poller scheduler registered: {spec.description}")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _schedule_repo_cleanup(repo_key: str) -> None:
|
|
424
|
+
run_at = datetime.now() + timedelta(hours=1)
|
|
425
|
+
scheduler.add_job(
|
|
426
|
+
cleanup_unsubscribed_repo,
|
|
427
|
+
"date",
|
|
428
|
+
args=[repo_key],
|
|
429
|
+
id=f"git_poller:cleanup:{repo_key}",
|
|
430
|
+
run_date=run_at,
|
|
431
|
+
replace_existing=True,
|
|
432
|
+
)
|
|
433
|
+
logger.info(f"git poller delayed cleanup scheduled: repo={repo_key}, run_at={run_at}")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _repo_args(args: Message):
|
|
437
|
+
return parse_repo_command_args(str(args))
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _same_config_session(event: GroupMessageEvent, matcher: Matcher) -> bool:
|
|
441
|
+
return (
|
|
442
|
+
int(event.group_id) == int(matcher.state.get("repo_config_group_id", -1))
|
|
443
|
+
and int(event.user_id) == int(matcher.state.get("repo_config_user_id", -1))
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
_register_schedules()
|
|
448
|
+
_cleanup_orphaned_storage_on_startup()
|
|
449
|
+
register_archive_file_route(plugin_config)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import hashlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
from nonebot import logger
|
|
10
|
+
from nonebot_plugin_localstore import get_plugin_cache_dir
|
|
11
|
+
import py7zr
|
|
12
|
+
|
|
13
|
+
from .models import Subscription, UpdatePayload
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ArchiveFile:
|
|
18
|
+
path: Path
|
|
19
|
+
name: str
|
|
20
|
+
sha256: str
|
|
21
|
+
password: str | None
|
|
22
|
+
password_used: bool
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ArchiveBuilder:
|
|
26
|
+
def __init__(self, default_password: str | None = None) -> None:
|
|
27
|
+
self.default_password = _clean_password(default_password)
|
|
28
|
+
self.archive_dir = get_plugin_cache_dir() / "archives"
|
|
29
|
+
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
def build(
|
|
32
|
+
self,
|
|
33
|
+
payload: UpdatePayload,
|
|
34
|
+
subscription: Subscription,
|
|
35
|
+
source_dir: Path,
|
|
36
|
+
) -> ArchiveFile:
|
|
37
|
+
password = _clean_password(subscription.archive_password) or self.default_password
|
|
38
|
+
archive_name = _archive_name(payload)
|
|
39
|
+
archive_path = _unique_archive_path(self.archive_dir, payload.repo_key, archive_name)
|
|
40
|
+
|
|
41
|
+
logger.info(
|
|
42
|
+
f"git poller building archive: repo={payload.repo_key}, "
|
|
43
|
+
f"target={payload.target_short_sha}, password={password is not None}"
|
|
44
|
+
)
|
|
45
|
+
with py7zr.SevenZipFile(archive_path, "w", password=password) as archive:
|
|
46
|
+
archive.writeall(source_dir, arcname=source_dir.name)
|
|
47
|
+
return ArchiveFile(
|
|
48
|
+
path=archive_path,
|
|
49
|
+
name=archive_name,
|
|
50
|
+
sha256=_sha256_file(archive_path),
|
|
51
|
+
password=password,
|
|
52
|
+
password_used=password is not None,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def source_root(self, payload: UpdatePayload) -> Path:
|
|
56
|
+
name = _source_root_name(payload)
|
|
57
|
+
return Path(tempfile.mkdtemp(prefix=f"{name}-", dir=str(self.archive_dir))) / name
|
|
58
|
+
|
|
59
|
+
def remove_archive(self, path: str | Path | None) -> bool:
|
|
60
|
+
if not path:
|
|
61
|
+
return False
|
|
62
|
+
archive_path = Path(path)
|
|
63
|
+
try:
|
|
64
|
+
archive_path.resolve().relative_to(self.archive_dir.resolve())
|
|
65
|
+
except ValueError:
|
|
66
|
+
logger.warning(f"git poller refused to remove archive outside cache: {archive_path}")
|
|
67
|
+
return False
|
|
68
|
+
try:
|
|
69
|
+
archive_path.unlink()
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return False
|
|
72
|
+
logger.info(f"git poller removed archive: {archive_path}")
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
def remove_archives_for_repo(self, repo_key: str) -> int:
|
|
76
|
+
count = 0
|
|
77
|
+
prefix = f"{_safe_name(repo_key)}-"
|
|
78
|
+
for path in self.archive_dir.glob(f"{prefix}*.7z"):
|
|
79
|
+
try:
|
|
80
|
+
if path.is_file():
|
|
81
|
+
path.unlink()
|
|
82
|
+
count += 1
|
|
83
|
+
except OSError:
|
|
84
|
+
logger.exception(f"git poller failed to remove archive: {path}")
|
|
85
|
+
if count:
|
|
86
|
+
logger.info(f"git poller removed {count} archives for repo: {repo_key}")
|
|
87
|
+
return count
|
|
88
|
+
|
|
89
|
+
def remove_archives_except(self, active_repo_keys: set[str]) -> int:
|
|
90
|
+
active_prefixes = {f"{_safe_name(repo_key)}-" for repo_key in active_repo_keys}
|
|
91
|
+
count = 0
|
|
92
|
+
for path in self.archive_dir.glob("*.7z"):
|
|
93
|
+
if not path.is_file():
|
|
94
|
+
continue
|
|
95
|
+
if any(path.name.startswith(prefix) for prefix in active_prefixes):
|
|
96
|
+
continue
|
|
97
|
+
try:
|
|
98
|
+
path.unlink()
|
|
99
|
+
count += 1
|
|
100
|
+
except OSError:
|
|
101
|
+
logger.exception(f"git poller failed to remove orphan archive: {path}")
|
|
102
|
+
if count:
|
|
103
|
+
logger.info(f"git poller removed {count} orphan archives")
|
|
104
|
+
return count
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _archive_name(payload: UpdatePayload) -> str:
|
|
108
|
+
return f"{_source_root_name(payload)}.7z"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _unique_archive_path(archive_dir: Path, repo_key: str, archive_name: str) -> Path:
|
|
112
|
+
stem = Path(archive_name).stem
|
|
113
|
+
with tempfile.NamedTemporaryFile(
|
|
114
|
+
prefix=f"{_safe_name(repo_key)}-{_safe_name(stem)}-",
|
|
115
|
+
suffix=".7z",
|
|
116
|
+
dir=archive_dir,
|
|
117
|
+
delete=False,
|
|
118
|
+
) as file:
|
|
119
|
+
return Path(file.name)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _source_root_name(payload: UpdatePayload) -> str:
|
|
123
|
+
repo = _safe_name(payload.repo_name)
|
|
124
|
+
branch = _safe_name(payload.branch)
|
|
125
|
+
return f"{repo}-{branch}-{payload.target_short_sha}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _safe_name(value: str) -> str:
|
|
129
|
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip(".-")
|
|
130
|
+
return cleaned[:80] or "repository"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _clean_password(value: str | None) -> str | None:
|
|
134
|
+
if value is None:
|
|
135
|
+
return None
|
|
136
|
+
cleaned = value.strip()
|
|
137
|
+
return cleaned or None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _sha256_file(path: Path) -> str:
|
|
141
|
+
digest = hashlib.sha256()
|
|
142
|
+
with path.open("rb") as file:
|
|
143
|
+
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
|
144
|
+
digest.update(chunk)
|
|
145
|
+
return digest.hexdigest()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class RepoCommandArgs:
|
|
8
|
+
url: str
|
|
9
|
+
branch: str | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_repo_command_args(text: str) -> RepoCommandArgs | None:
|
|
13
|
+
value = text.strip()
|
|
14
|
+
if not value:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
parts = value.split()
|
|
18
|
+
url = parts[0]
|
|
19
|
+
branch: str | None = None
|
|
20
|
+
index = 1
|
|
21
|
+
|
|
22
|
+
if index < len(parts) and parts[index].startswith("--"):
|
|
23
|
+
branch = parts[index][2:].strip()
|
|
24
|
+
if not branch:
|
|
25
|
+
raise ValueError("分支名不能为空。")
|
|
26
|
+
index += 1
|
|
27
|
+
|
|
28
|
+
if index < len(parts):
|
|
29
|
+
raise ValueError("参数格式错误,请使用:仓库url [--分支名]")
|
|
30
|
+
|
|
31
|
+
return RepoCommandArgs(url=url, branch=branch)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
from nonebot import get_plugin_config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Config(BaseModel):
|
|
8
|
+
git_poller_default_schedule: str = "每日04:00"
|
|
9
|
+
git_poller_timezone: str = "Asia/Shanghai"
|
|
10
|
+
|
|
11
|
+
git_poller_proxy: str | None = None
|
|
12
|
+
git_poller_timeout: float = Field(default=60.0, gt=0)
|
|
13
|
+
git_poller_archive_password: str | None = None
|
|
14
|
+
git_poller_file_base_url: str | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
plugin_config = get_plugin_config(Config)
|