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,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urlparse, urlunparse
|
|
6
|
+
|
|
7
|
+
from .models import RepositoryIdentity
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_SCP_LIKE_PATTERN = re.compile(r"^(?P<user>[^@/:]+)@(?P<host>[^:]+):(?P<path>.+)$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def normalize_repo_url(url: str) -> str:
|
|
14
|
+
value = url.strip()
|
|
15
|
+
if not value:
|
|
16
|
+
raise ValueError("仓库 URL 不能为空。")
|
|
17
|
+
|
|
18
|
+
scp_like = _SCP_LIKE_PATTERN.fullmatch(value)
|
|
19
|
+
if scp_like:
|
|
20
|
+
host = scp_like.group("host").lower()
|
|
21
|
+
path = _clean_path(scp_like.group("path"))
|
|
22
|
+
return f"{scp_like.group('user')}@{host}:{path}"
|
|
23
|
+
|
|
24
|
+
if _is_local_path(value):
|
|
25
|
+
return value.rstrip("/")
|
|
26
|
+
|
|
27
|
+
parsed = urlparse(value)
|
|
28
|
+
if parsed.scheme and parsed.netloc:
|
|
29
|
+
scheme = parsed.scheme.lower()
|
|
30
|
+
netloc = _normalize_netloc(parsed)
|
|
31
|
+
path = _clean_path(parsed.path)
|
|
32
|
+
return urlunparse((scheme, netloc, path, "", "", ""))
|
|
33
|
+
|
|
34
|
+
return _clean_path(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def repo_key_from_url(url: str, branch: str | None = None) -> str:
|
|
38
|
+
normalized = normalize_repo_url(url)
|
|
39
|
+
normalized_branch = normalize_branch(branch) if branch is not None else None
|
|
40
|
+
return _repo_key_from_normalized_url(normalized, normalized_branch)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_identity(url: str, branch: str | None = None) -> RepositoryIdentity:
|
|
44
|
+
normalized = normalize_repo_url(url)
|
|
45
|
+
return build_identity_from_normalized_url(normalized, branch)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_identity_from_normalized_url(
|
|
49
|
+
normalized_url: str,
|
|
50
|
+
branch: str | None = None,
|
|
51
|
+
) -> RepositoryIdentity:
|
|
52
|
+
normalized_branch = normalize_branch(branch) if branch is not None else None
|
|
53
|
+
return RepositoryIdentity(
|
|
54
|
+
key=_repo_key_from_normalized_url(normalized_url, normalized_branch),
|
|
55
|
+
url=normalized_url,
|
|
56
|
+
display_name=display_name_from_url(normalized_url),
|
|
57
|
+
web_url=web_url_from_git_url(normalized_url),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _repo_key_from_normalized_url(
|
|
62
|
+
normalized: str,
|
|
63
|
+
normalized_branch: str | None,
|
|
64
|
+
) -> str:
|
|
65
|
+
key_source = normalized if normalized_branch is None else f"{normalized}#{normalized_branch}"
|
|
66
|
+
digest = hashlib.sha1(key_source.encode("utf-8")).hexdigest()[:12]
|
|
67
|
+
parts = [display_name_from_url(normalized)]
|
|
68
|
+
if normalized_branch is not None:
|
|
69
|
+
parts.append(_safe_key_part(normalized_branch))
|
|
70
|
+
parts.append(digest)
|
|
71
|
+
return "-".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def normalize_branch(branch: str) -> str:
|
|
75
|
+
value = branch.strip()
|
|
76
|
+
if not value:
|
|
77
|
+
raise ValueError("分支名不能为空。")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def display_name_from_url(url: str) -> str:
|
|
82
|
+
parsed = urlparse(url)
|
|
83
|
+
if parsed.scheme and parsed.netloc:
|
|
84
|
+
name = parsed.path.rstrip("/").rsplit("/", 1)[-1]
|
|
85
|
+
elif ":" in url and _SCP_LIKE_PATTERN.fullmatch(url):
|
|
86
|
+
name = url.rsplit("/", 1)[-1]
|
|
87
|
+
else:
|
|
88
|
+
name = url.rstrip("/").rsplit("/", 1)[-1]
|
|
89
|
+
if name.endswith(".git"):
|
|
90
|
+
name = name[:-4]
|
|
91
|
+
return name or "repository"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def web_url_from_git_url(url: str) -> str | None:
|
|
95
|
+
parsed = urlparse(url)
|
|
96
|
+
if parsed.scheme in {"http", "https"} and parsed.netloc:
|
|
97
|
+
path = parsed.path[:-4] if parsed.path.endswith(".git") else parsed.path
|
|
98
|
+
return urlunparse((parsed.scheme, parsed.netloc, path.rstrip("/"), "", "", ""))
|
|
99
|
+
|
|
100
|
+
scp_like = _SCP_LIKE_PATTERN.fullmatch(url)
|
|
101
|
+
if scp_like:
|
|
102
|
+
path = scp_like.group("path")
|
|
103
|
+
if path.endswith(".git"):
|
|
104
|
+
path = path[:-4]
|
|
105
|
+
return f"https://{scp_like.group('host').lower()}/{path.strip('/')}"
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def build_commit_url(repo_url: str, sha: str) -> str | None:
|
|
111
|
+
web_url = web_url_from_git_url(repo_url)
|
|
112
|
+
if not web_url:
|
|
113
|
+
return None
|
|
114
|
+
return f"{web_url}/-/commit/{sha}" if _uses_gitlab_routes(web_url) else f"{web_url}/commit/{sha}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_compare_url(repo_url: str, from_sha: str | None, to_sha: str) -> str | None:
|
|
118
|
+
if not from_sha:
|
|
119
|
+
return None
|
|
120
|
+
web_url = web_url_from_git_url(repo_url)
|
|
121
|
+
if not web_url:
|
|
122
|
+
return None
|
|
123
|
+
separator = "..."
|
|
124
|
+
path = f"/-/compare/{from_sha}{separator}{to_sha}" if _uses_gitlab_routes(web_url) else f"/compare/{from_sha}{separator}{to_sha}"
|
|
125
|
+
return f"{web_url}{path}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _uses_gitlab_routes(web_url: str) -> bool:
|
|
129
|
+
host = urlparse(web_url).netloc.lower()
|
|
130
|
+
return "gitlab" in host or "gitgud.io" in host
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _clean_path(path: str) -> str:
|
|
134
|
+
cleaned = path.strip().rstrip("/")
|
|
135
|
+
if cleaned.endswith(".git"):
|
|
136
|
+
cleaned = cleaned[:-4]
|
|
137
|
+
return f"{cleaned}.git"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_local_path(value: str) -> bool:
|
|
141
|
+
return (
|
|
142
|
+
value.startswith(("/", "./", "../", "~"))
|
|
143
|
+
or re.match(r"^[A-Za-z]:[\\/]", value) is not None
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _normalize_netloc(parsed) -> str:
|
|
148
|
+
host = (parsed.hostname or "").lower()
|
|
149
|
+
if parsed.port:
|
|
150
|
+
host = f"{host}:{parsed.port}"
|
|
151
|
+
if parsed.username:
|
|
152
|
+
userinfo = parsed.username
|
|
153
|
+
if parsed.password:
|
|
154
|
+
userinfo = f"{userinfo}:{parsed.password}"
|
|
155
|
+
return f"{userinfo}@{host}"
|
|
156
|
+
return host
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _safe_key_part(value: str) -> str:
|
|
160
|
+
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip(".-")
|
|
161
|
+
return safe[:48] or "branch"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime, timedelta
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_DAILY_PATTERN = re.compile(r"^每日(\d{1,2}):(\d{2})$")
|
|
11
|
+
_INTERVAL_DAYS_PATTERN = re.compile(r"^每([1-9]\d*)天(\d{1,2}):(\d{2})$")
|
|
12
|
+
_WEEKLY_PATTERN = re.compile(r"^周([一二三四五六日天])(\d{1,2}):(\d{2})$")
|
|
13
|
+
_INTERVAL_ANCHOR_ORDINAL = date(1970, 1, 1).toordinal()
|
|
14
|
+
_MAX_INTERVAL_DAYS = 30
|
|
15
|
+
_WEEKDAY_MAP = {
|
|
16
|
+
"一": "mon",
|
|
17
|
+
"二": "tue",
|
|
18
|
+
"三": "wed",
|
|
19
|
+
"四": "thu",
|
|
20
|
+
"五": "fri",
|
|
21
|
+
"六": "sat",
|
|
22
|
+
"日": "sun",
|
|
23
|
+
"天": "sun",
|
|
24
|
+
}
|
|
25
|
+
_WEEKDAY_NAME_MAP = {
|
|
26
|
+
"一": "一",
|
|
27
|
+
"二": "二",
|
|
28
|
+
"三": "三",
|
|
29
|
+
"四": "四",
|
|
30
|
+
"五": "五",
|
|
31
|
+
"六": "六",
|
|
32
|
+
"日": "日",
|
|
33
|
+
"天": "日",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ScheduleSpec:
|
|
39
|
+
raw: str
|
|
40
|
+
trigger: str
|
|
41
|
+
trigger_kwargs: dict[str, Any]
|
|
42
|
+
description: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_schedule(value: str, timezone_name: str = "Asia/Shanghai") -> ScheduleSpec | None:
|
|
46
|
+
raw = value.strip()
|
|
47
|
+
if not raw:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
timezone = _parse_timezone(timezone_name)
|
|
51
|
+
daily = _DAILY_PATTERN.fullmatch(raw)
|
|
52
|
+
if daily:
|
|
53
|
+
hour, minute = _parse_time(daily.group(1), daily.group(2))
|
|
54
|
+
return ScheduleSpec(
|
|
55
|
+
raw=raw,
|
|
56
|
+
trigger="cron",
|
|
57
|
+
trigger_kwargs={"hour": hour, "minute": minute, "timezone": timezone},
|
|
58
|
+
description=f"每日 {_format_time(hour, minute)} ({timezone_name})",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
interval_days = _INTERVAL_DAYS_PATTERN.fullmatch(raw)
|
|
62
|
+
if interval_days:
|
|
63
|
+
days = int(interval_days.group(1))
|
|
64
|
+
if days > _MAX_INTERVAL_DAYS:
|
|
65
|
+
raise ValueError(f"间隔天数必须在 1 到 {_MAX_INTERVAL_DAYS} 天之间。")
|
|
66
|
+
hour, minute = _parse_time(interval_days.group(2), interval_days.group(3))
|
|
67
|
+
return ScheduleSpec(
|
|
68
|
+
raw=raw,
|
|
69
|
+
trigger="interval",
|
|
70
|
+
trigger_kwargs={
|
|
71
|
+
"days": days,
|
|
72
|
+
"start_date": _next_interval_start_date(days, hour, minute, timezone),
|
|
73
|
+
"timezone": timezone,
|
|
74
|
+
},
|
|
75
|
+
description=f"每 {days} 天 {_format_time(hour, minute)} ({timezone_name})",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
weekly = _WEEKLY_PATTERN.fullmatch(raw)
|
|
79
|
+
if weekly:
|
|
80
|
+
weekday_text = weekly.group(1)
|
|
81
|
+
day_of_week = _WEEKDAY_MAP[weekday_text]
|
|
82
|
+
hour, minute = _parse_time(weekly.group(2), weekly.group(3))
|
|
83
|
+
return ScheduleSpec(
|
|
84
|
+
raw=raw,
|
|
85
|
+
trigger="cron",
|
|
86
|
+
trigger_kwargs={
|
|
87
|
+
"day_of_week": day_of_week,
|
|
88
|
+
"hour": hour,
|
|
89
|
+
"minute": minute,
|
|
90
|
+
"timezone": timezone,
|
|
91
|
+
},
|
|
92
|
+
description=(
|
|
93
|
+
f"周{_WEEKDAY_NAME_MAP[weekday_text]} "
|
|
94
|
+
f"{_format_time(hour, minute)} ({timezone_name})"
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"定时格式应为 每日hh:mm、每x天hh:mm 或 周xhh:mm,"
|
|
100
|
+
f"天数 x 使用 1 到 {_MAX_INTERVAL_DAYS} 的整数,"
|
|
101
|
+
"周 x 使用一二三四五六日/天。"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_time(hour_text: str, minute_text: str) -> tuple[int, int]:
|
|
106
|
+
hour = int(hour_text)
|
|
107
|
+
minute = int(minute_text)
|
|
108
|
+
if hour > 23 or minute > 59:
|
|
109
|
+
raise ValueError("定时时间必须在 00:00 到 23:59 之间。")
|
|
110
|
+
return hour, minute
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_timezone(value: str) -> ZoneInfo:
|
|
114
|
+
try:
|
|
115
|
+
return ZoneInfo(value)
|
|
116
|
+
except ZoneInfoNotFoundError as exc:
|
|
117
|
+
raise ValueError(f"无效时区:{value!r}") from exc
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _format_time(hour: int, minute: int) -> str:
|
|
121
|
+
return f"{hour:02d}:{minute:02d}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _next_interval_start_date(
|
|
125
|
+
days: int,
|
|
126
|
+
hour: int,
|
|
127
|
+
minute: int,
|
|
128
|
+
timezone: ZoneInfo,
|
|
129
|
+
) -> datetime:
|
|
130
|
+
now = datetime.now(timezone)
|
|
131
|
+
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
132
|
+
if candidate <= now:
|
|
133
|
+
candidate += timedelta(days=1)
|
|
134
|
+
offset = (candidate.date().toordinal() - _INTERVAL_ANCHOR_ORDINAL) % days
|
|
135
|
+
if offset:
|
|
136
|
+
candidate += timedelta(days=days - offset)
|
|
137
|
+
return candidate
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from nonebot import logger
|
|
8
|
+
from nonebot_plugin_localstore import get_plugin_data_dir
|
|
9
|
+
|
|
10
|
+
from .models import Subscription
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StateStore:
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self.data_dir = get_plugin_data_dir()
|
|
16
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
self.state_path = self.data_dir / "state.json"
|
|
18
|
+
|
|
19
|
+
def read_state(self) -> dict[str, Any]:
|
|
20
|
+
if not self.state_path.exists():
|
|
21
|
+
logger.debug(f"git poller state file does not exist yet: {self.state_path}")
|
|
22
|
+
return {"groups": {}}
|
|
23
|
+
logger.debug(f"git poller reading state file: {self.state_path}")
|
|
24
|
+
data = json.loads(self.state_path.read_text(encoding="utf-8"))
|
|
25
|
+
if not isinstance(data, dict):
|
|
26
|
+
return {"groups": {}}
|
|
27
|
+
if not isinstance(data.get("groups"), dict):
|
|
28
|
+
data["groups"] = {}
|
|
29
|
+
return data
|
|
30
|
+
|
|
31
|
+
def write_state(self, state: dict[str, Any]) -> None:
|
|
32
|
+
if not isinstance(state.get("groups"), dict):
|
|
33
|
+
state["groups"] = {}
|
|
34
|
+
self._write_json(self.state_path, state)
|
|
35
|
+
|
|
36
|
+
def get_subscription(self, group_id: int, repo_key: str) -> Subscription | None:
|
|
37
|
+
raw = self._repo_entry(group_id, repo_key)
|
|
38
|
+
if raw is None:
|
|
39
|
+
return None
|
|
40
|
+
return Subscription.from_json(raw)
|
|
41
|
+
|
|
42
|
+
def list_group_subscriptions(self, group_id: int) -> dict[str, Subscription]:
|
|
43
|
+
state = self.read_state()
|
|
44
|
+
repos = self._repos_from_state(state, group_id)
|
|
45
|
+
result: dict[str, Subscription] = {}
|
|
46
|
+
for repo_key, raw in repos.items():
|
|
47
|
+
if isinstance(raw, dict):
|
|
48
|
+
try:
|
|
49
|
+
result[str(repo_key)] = Subscription.from_json(raw)
|
|
50
|
+
except (KeyError, TypeError, ValueError):
|
|
51
|
+
logger.warning(
|
|
52
|
+
f"git poller skipped invalid subscription for group {group_id}: {repo_key}"
|
|
53
|
+
)
|
|
54
|
+
return result
|
|
55
|
+
|
|
56
|
+
def list_all_subscriptions(self) -> dict[int, dict[str, Subscription]]:
|
|
57
|
+
state = self.read_state()
|
|
58
|
+
groups = state.get("groups")
|
|
59
|
+
if not isinstance(groups, dict):
|
|
60
|
+
return {}
|
|
61
|
+
result: dict[int, dict[str, Subscription]] = {}
|
|
62
|
+
for group_id_text in groups:
|
|
63
|
+
try:
|
|
64
|
+
group_id = int(group_id_text)
|
|
65
|
+
except (TypeError, ValueError):
|
|
66
|
+
continue
|
|
67
|
+
subscriptions = self.list_group_subscriptions(group_id)
|
|
68
|
+
if subscriptions:
|
|
69
|
+
result[group_id] = subscriptions
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
def upsert_subscription(
|
|
73
|
+
self,
|
|
74
|
+
group_id: int,
|
|
75
|
+
repo_key: str,
|
|
76
|
+
subscription: Subscription,
|
|
77
|
+
) -> None:
|
|
78
|
+
state = self.read_state()
|
|
79
|
+
repos = self._ensure_repos(state, group_id)
|
|
80
|
+
repos[repo_key] = subscription.to_json()
|
|
81
|
+
logger.info(
|
|
82
|
+
f"git poller subscription saved: group={group_id}, "
|
|
83
|
+
f"repo={repo_key}, branch={subscription.branch}, enabled={subscription.enabled}"
|
|
84
|
+
)
|
|
85
|
+
self.write_state(state)
|
|
86
|
+
|
|
87
|
+
def remove_subscription(self, group_id: int, repo_key: str) -> bool:
|
|
88
|
+
state = self.read_state()
|
|
89
|
+
repos = self._repos_from_state(state, group_id)
|
|
90
|
+
if repo_key not in repos:
|
|
91
|
+
return False
|
|
92
|
+
del repos[repo_key]
|
|
93
|
+
logger.info(f"git poller subscription removed: group={group_id}, repo={repo_key}")
|
|
94
|
+
self.write_state(state)
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def update_last_success(
|
|
98
|
+
self,
|
|
99
|
+
group_id: int,
|
|
100
|
+
repo_key: str,
|
|
101
|
+
sha: str,
|
|
102
|
+
updated_at: str,
|
|
103
|
+
) -> None:
|
|
104
|
+
subscription = self.get_subscription(group_id, repo_key)
|
|
105
|
+
if subscription is None:
|
|
106
|
+
raise KeyError(f"subscription not found: group={group_id}, repo={repo_key}")
|
|
107
|
+
subscription.last_success_sha = sha
|
|
108
|
+
subscription.updated_at = updated_at
|
|
109
|
+
self.upsert_subscription(group_id, repo_key, subscription)
|
|
110
|
+
logger.info(
|
|
111
|
+
f"git poller last_success_sha updated: group={group_id}, "
|
|
112
|
+
f"repo={repo_key}, sha={sha[:8]}"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def update_last_archive_path(
|
|
116
|
+
self,
|
|
117
|
+
group_id: int,
|
|
118
|
+
repo_key: str,
|
|
119
|
+
archive_path: str | None,
|
|
120
|
+
updated_at: str,
|
|
121
|
+
) -> None:
|
|
122
|
+
subscription = self.get_subscription(group_id, repo_key)
|
|
123
|
+
if subscription is None:
|
|
124
|
+
raise KeyError(f"subscription not found: group={group_id}, repo={repo_key}")
|
|
125
|
+
subscription.last_archive_path = archive_path
|
|
126
|
+
subscription.updated_at = updated_at
|
|
127
|
+
self.upsert_subscription(group_id, repo_key, subscription)
|
|
128
|
+
logger.info(
|
|
129
|
+
f"git poller last archive path updated: group={group_id}, "
|
|
130
|
+
f"repo={repo_key}, has_archive={archive_path is not None}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def update_schedule(
|
|
134
|
+
self,
|
|
135
|
+
group_id: int,
|
|
136
|
+
repo_key: str,
|
|
137
|
+
schedule: str,
|
|
138
|
+
updated_at: str,
|
|
139
|
+
) -> Subscription:
|
|
140
|
+
subscription = self.get_subscription(group_id, repo_key)
|
|
141
|
+
if subscription is None:
|
|
142
|
+
raise KeyError(f"subscription not found: group={group_id}, repo={repo_key}")
|
|
143
|
+
subscription.schedule = schedule
|
|
144
|
+
subscription.updated_at = updated_at
|
|
145
|
+
self.upsert_subscription(group_id, repo_key, subscription)
|
|
146
|
+
logger.info(
|
|
147
|
+
f"git poller schedule updated: group={group_id}, repo={repo_key}, schedule={schedule}"
|
|
148
|
+
)
|
|
149
|
+
return subscription
|
|
150
|
+
|
|
151
|
+
def update_archive_password(
|
|
152
|
+
self,
|
|
153
|
+
group_id: int,
|
|
154
|
+
repo_key: str,
|
|
155
|
+
password: str | None,
|
|
156
|
+
updated_at: str,
|
|
157
|
+
) -> Subscription:
|
|
158
|
+
subscription = self.get_subscription(group_id, repo_key)
|
|
159
|
+
if subscription is None:
|
|
160
|
+
raise KeyError(f"subscription not found: group={group_id}, repo={repo_key}")
|
|
161
|
+
subscription.archive_password = password
|
|
162
|
+
subscription.updated_at = updated_at
|
|
163
|
+
self.upsert_subscription(group_id, repo_key, subscription)
|
|
164
|
+
logger.info(
|
|
165
|
+
f"git poller archive password updated: group={group_id}, "
|
|
166
|
+
f"repo={repo_key}, has_password={password is not None}"
|
|
167
|
+
)
|
|
168
|
+
return subscription
|
|
169
|
+
|
|
170
|
+
def subscriptions_for_schedule(
|
|
171
|
+
self,
|
|
172
|
+
schedule: str,
|
|
173
|
+
) -> list[tuple[int, str, Subscription]]:
|
|
174
|
+
target = schedule.strip()
|
|
175
|
+
result: list[tuple[int, str, Subscription]] = []
|
|
176
|
+
for group_id, subscriptions in self.list_all_subscriptions().items():
|
|
177
|
+
for repo_key, subscription in subscriptions.items():
|
|
178
|
+
if subscription.enabled and subscription.schedule.strip() == target:
|
|
179
|
+
result.append((group_id, repo_key, subscription))
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
def is_repo_key_subscribed(self, repo_key: str) -> bool:
|
|
183
|
+
for subscriptions in self.list_all_subscriptions().values():
|
|
184
|
+
if repo_key in subscriptions:
|
|
185
|
+
return True
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
def _repo_entry(self, group_id: int, repo_key: str) -> dict[str, Any] | None:
|
|
189
|
+
repos = self._repos_from_state(self.read_state(), group_id)
|
|
190
|
+
raw = repos.get(repo_key)
|
|
191
|
+
return raw if isinstance(raw, dict) else None
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _repos_from_state(state: dict[str, Any], group_id: int) -> dict[str, Any]:
|
|
195
|
+
groups = state.get("groups")
|
|
196
|
+
if not isinstance(groups, dict):
|
|
197
|
+
return {}
|
|
198
|
+
group_state = groups.get(str(int(group_id)))
|
|
199
|
+
if not isinstance(group_state, dict):
|
|
200
|
+
return {}
|
|
201
|
+
repos = group_state.get("repos")
|
|
202
|
+
return repos if isinstance(repos, dict) else {}
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _ensure_repos(state: dict[str, Any], group_id: int) -> dict[str, Any]:
|
|
206
|
+
groups = state.get("groups")
|
|
207
|
+
if not isinstance(groups, dict):
|
|
208
|
+
groups = {}
|
|
209
|
+
state["groups"] = groups
|
|
210
|
+
group_text = str(int(group_id))
|
|
211
|
+
group_state = groups.get(group_text)
|
|
212
|
+
if not isinstance(group_state, dict):
|
|
213
|
+
group_state = {}
|
|
214
|
+
groups[group_text] = group_state
|
|
215
|
+
repos = group_state.get("repos")
|
|
216
|
+
if not isinstance(repos, dict):
|
|
217
|
+
repos = {}
|
|
218
|
+
group_state["repos"] = repos
|
|
219
|
+
return repos
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
|
223
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
|
225
|
+
tmp_path.write_text(
|
|
226
|
+
json.dumps(data, ensure_ascii=False, indent=2),
|
|
227
|
+
encoding="utf-8",
|
|
228
|
+
)
|
|
229
|
+
tmp_path.replace(path)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: nonebot-plugin-git-poller
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: 按群订阅 Git 仓库更新的 NoneBot2 插件,支持多仓库、多分支定时拉取
|
|
5
|
+
Author: kusadact
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Dist: nonebot2>=2.0.0
|
|
8
|
+
Requires-Dist: nonebot-adapter-onebot>=2.1.3
|
|
9
|
+
Requires-Dist: nonebot-plugin-apscheduler>=0.5.0
|
|
10
|
+
Requires-Dist: nonebot-plugin-localstore>=0.6.0
|
|
11
|
+
Requires-Dist: dulwich>=1.2.6
|
|
12
|
+
Requires-Dist: py7zr>=1.1.0
|
|
13
|
+
Requires-Dist: urllib3>=2.0.0
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
<div align="center">
|
|
18
|
+
<a href="https://v2.nonebot.dev/store">
|
|
19
|
+
<img src="https://raw.githubusercontent.com/fllesser/nonebot-plugin-template/refs/heads/resource/.docs/NoneBotPlugin.svg" width="310" alt="logo">
|
|
20
|
+
</a>
|
|
21
|
+
|
|
22
|
+
## nonebot-plugin-git-poller
|
|
23
|
+
|
|
24
|
+
[](./LICENSE)
|
|
25
|
+
[](https://pypi.org/project/nonebot-plugin-git-poller/)
|
|
26
|
+
[](https://www.python.org)
|
|
27
|
+
[](https://github.com/astral-sh/uv)
|
|
28
|
+
[](https://codecov.io/gh/kusadact/nonebot-plugin-git-poller)
|
|
29
|
+
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
按群订阅 Git 仓库更新的 NoneBot2 插件,支持多仓库、多分支定时拉取,自动推送 commit 更新摘要并上传源码压缩包。
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
在 NoneBot 项目目录中安装插件:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv add nonebot-plugin-git-poller
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
在 `pyproject.toml` 中加载插件:
|
|
43
|
+
|
|
44
|
+
```toml
|
|
45
|
+
[tool.nonebot]
|
|
46
|
+
plugins = ["nonebot_plugin_git_poller"]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 配置
|
|
50
|
+
|
|
51
|
+
| 配置项 | 必填 | 默认值 | 说明 |
|
|
52
|
+
| --- | --- | --- | --- |
|
|
53
|
+
| `git_poller_default_schedule` | 否 | `每日04:00` | 新关注仓库的默认定时规则;留空关闭默认定时注册。 |
|
|
54
|
+
| `git_poller_timezone` | 否 | `Asia/Shanghai` | 定时任务时区。 |
|
|
55
|
+
| `git_poller_proxy` | 否 | 空 | HTTP/HTTPS Git 拉取代理。 |
|
|
56
|
+
| `git_poller_timeout` | 否 | `60.0` | HTTP/HTTPS Git 拉取超时,单位秒。 |
|
|
57
|
+
| `git_poller_archive_password` | 否 | 空 | 全局默认压缩包密码;为空时默认不设置密码。 |
|
|
58
|
+
| `git_poller_file_base_url` | 条件 | 空 | 上传压缩包时使用的 NoneBot HTTP 服务根地址;Bot 和 OneBot/NapCat 不在同一个文件系统时必须配置,例如 http://nonebot:8088。 |
|
|
59
|
+
|
|
60
|
+
## 指令
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
/关注仓库 仓库url [--分支名]
|
|
64
|
+
/取关仓库 仓库url [--分支名]
|
|
65
|
+
/设置仓库 仓库url [--分支名]
|
|
66
|
+
/拉取仓库 仓库url [--分支名]
|
|
67
|
+
/仓库摘要 仓库url [--分支名]
|
|
68
|
+
/仓库列表
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
带 URL 的命令都支持可选分支后缀,例如 `/关注仓库 https://example.test/repo.git --dev`。`/关注仓库` 不写分支时追踪远端默认分支;其他命令不写分支时优先使用本群本仓库的唯一订阅,若同仓库关注了多个分支则需要写 `--分支名`。
|
|
72
|
+
|
|
73
|
+
`/关注仓库` 在当前群关注仓库。同一个群可以关注同一仓库的不同分支。
|
|
74
|
+
|
|
75
|
+
`/取关仓库` 移除当前群的对应仓库分支订阅,不影响其他群。
|
|
76
|
+
|
|
77
|
+
`/设置仓库` 进入设置流程。Bot 会回复:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
输入设置数字选项
|
|
81
|
+
1. 修改当前仓库推送抓取时间
|
|
82
|
+
2. 修改当前仓库上传压缩包密码(选择后输入无则清除当前仓库密码回到全局默认)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
回复 `1` 后,下一条消息必须是合法定时格式。回复 `2` 后,下一条消息会保存为当前仓库压缩包密码,此时输入 `无` 会清除当前仓库密码并回到全局默认。输入非法会取消。
|
|
86
|
+
|
|
87
|
+
`/仓库列表` 显示当前群关注的仓库、分支、定时、启用状态、`last_success_sha` 和压缩包密码来源。
|
|
88
|
+
|
|
89
|
+
`/拉取仓库` 立即拉取当前群已关注的仓库,上传最新的源码压缩包。
|
|
90
|
+
|
|
91
|
+
`/仓库摘要` 仅拉取远端并展示本群记录与远端 HEAD 的差异。
|
|
92
|
+
|
|
93
|
+
## 定时格式
|
|
94
|
+
|
|
95
|
+
支持:
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
每日hh:mm
|
|
99
|
+
每x天hh:mm
|
|
100
|
+
周xhh:mm
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`每x天` 的 `x` 使用 1 到 30 的整数;`周x` 只支持汉字 `一二三四五六日/天`
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
nonebot_plugin_git_poller/__init__.py,sha256=8g-r4aoCHIPSrDNxlm3VzBXDYRyeWrqSMEnyKsnV-v0,16084
|
|
2
|
+
nonebot_plugin_git_poller/archive.py,sha256=SgpvaQLrQ5_hp3oMd2EFIPQIT0nFUTsMUumRpzzb9uU,4805
|
|
3
|
+
nonebot_plugin_git_poller/command_args.py,sha256=kpnrbeYgTRN_CHiaBmatAhiuaR-fe0c9clcH4_3zER0,751
|
|
4
|
+
nonebot_plugin_git_poller/config.py,sha256=XxysFYtRcizMvhzrHw1HHSNnRXIL054xOzG0zNe-sX0,481
|
|
5
|
+
nonebot_plugin_git_poller/file_server.py,sha256=JGDviyQHS4DubKAneLq3sEtC458XAoum6nGhHkJwi0E,4147
|
|
6
|
+
nonebot_plugin_git_poller/git.py,sha256=auUD3vFkYhdWP8vBiLB8LD42dA7x1b1DlBEYfc8xAVk,14478
|
|
7
|
+
nonebot_plugin_git_poller/message.py,sha256=N4T5qWXhjp_wRRFi5McQztyp3iufzhRzhcjBLusj4ZU,4496
|
|
8
|
+
nonebot_plugin_git_poller/mirror.py,sha256=S6My7VzrFIz5ga41AO7TxCfJOR4PnfUneyp2v93HHMo,17600
|
|
9
|
+
nonebot_plugin_git_poller/models.py,sha256=MZRmAtL8rgYSEMvmc-iNERR08ojqTaGDUgku7_bPo9E,4614
|
|
10
|
+
nonebot_plugin_git_poller/repository.py,sha256=Jz4aezH9sG3Pe0L_mVI82m4AVvuLUqcs0qG-uz7CRU0,5071
|
|
11
|
+
nonebot_plugin_git_poller/schedule.py,sha256=AyiwWixnIKSH1FcC2RouizHANLc4amKRXdhb8yvlsEQ,4145
|
|
12
|
+
nonebot_plugin_git_poller/state.py,sha256=MOgtLicOjv42YxAEqLirCzR6yiyTWXXQR41quEFsoXU,8591
|
|
13
|
+
nonebot_plugin_git_poller-0.1.5.dist-info/WHEEL,sha256=s_zqWxHFEH8b58BCtf46hFCqPaISurdB9R1XJ8za6XI,80
|
|
14
|
+
nonebot_plugin_git_poller-0.1.5.dist-info/METADATA,sha256=wjoNAgjPWzKcpM6WkYr7OoajWz7hj-99Vk4aIPiAsBI,4169
|
|
15
|
+
nonebot_plugin_git_poller-0.1.5.dist-info/RECORD,,
|