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,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from secrets import token_urlsafe
|
|
7
|
+
import time
|
|
8
|
+
from urllib.parse import quote, urlencode
|
|
9
|
+
|
|
10
|
+
from nonebot import get_driver, logger
|
|
11
|
+
from nonebot_plugin_localstore import get_plugin_cache_dir
|
|
12
|
+
|
|
13
|
+
from .config import Config
|
|
14
|
+
|
|
15
|
+
_runtime_file_token = token_urlsafe(24)
|
|
16
|
+
_route_registered = False
|
|
17
|
+
ARCHIVE_FILE_ROUTE_PREFIX = "/git-poller/files"
|
|
18
|
+
ARCHIVE_FILE_TOKEN_TTL_SECONDS = 3600
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_archive_file_route(config: Config) -> bool:
|
|
22
|
+
driver = get_driver()
|
|
23
|
+
server_app = getattr(driver, "server_app", None)
|
|
24
|
+
if server_app is None or not hasattr(server_app, "add_api_route"):
|
|
25
|
+
message = "git poller archive HTTP route is unavailable: current driver has no server_app"
|
|
26
|
+
if config.git_poller_file_base_url:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
f"{message}. git_poller_file_base_url is configured, so OneBot needs "
|
|
29
|
+
"an HTTP route to download archives. Use a driver with server_app support "
|
|
30
|
+
"or unset git_poller_file_base_url."
|
|
31
|
+
)
|
|
32
|
+
logger.warning(message)
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
if not config.git_poller_file_base_url:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
global _route_registered
|
|
39
|
+
if _route_registered:
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
from starlette.responses import FileResponse, Response
|
|
43
|
+
|
|
44
|
+
route_prefix = ARCHIVE_FILE_ROUTE_PREFIX
|
|
45
|
+
|
|
46
|
+
async def serve_archive(
|
|
47
|
+
filename: str,
|
|
48
|
+
expires: str | None = None,
|
|
49
|
+
token: str | None = None,
|
|
50
|
+
) -> Response:
|
|
51
|
+
if not valid_archive_download_token(filename, expires, token):
|
|
52
|
+
logger.warning(f"git poller archive download rejected for {filename}: invalid token")
|
|
53
|
+
return Response(status_code=403)
|
|
54
|
+
if "/" in filename or "\\" in filename:
|
|
55
|
+
logger.warning(f"git poller archive download rejected for unsafe filename: {filename}")
|
|
56
|
+
return Response(status_code=404)
|
|
57
|
+
|
|
58
|
+
archive_dir = (get_plugin_cache_dir() / "archives").resolve()
|
|
59
|
+
archive_path = (archive_dir / filename).resolve()
|
|
60
|
+
try:
|
|
61
|
+
archive_path.relative_to(archive_dir)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return Response(status_code=404)
|
|
64
|
+
|
|
65
|
+
if not archive_path.is_file():
|
|
66
|
+
logger.warning(f"git poller archive download not found: {archive_path}")
|
|
67
|
+
return Response(status_code=404)
|
|
68
|
+
|
|
69
|
+
logger.info(f"git poller serving archive download: {archive_path}")
|
|
70
|
+
return FileResponse(
|
|
71
|
+
archive_path,
|
|
72
|
+
media_type="application/x-7z-compressed",
|
|
73
|
+
filename=filename,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
server_app.add_api_route(
|
|
77
|
+
f"{route_prefix}/{{filename}}",
|
|
78
|
+
serve_archive,
|
|
79
|
+
methods=["GET"],
|
|
80
|
+
)
|
|
81
|
+
_route_registered = True
|
|
82
|
+
logger.info(f"git poller archive HTTP route registered: {route_prefix}/{{filename}}")
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def build_archive_download_url(path: Path, config: Config) -> str | None:
|
|
87
|
+
if not config.git_poller_file_base_url:
|
|
88
|
+
return None
|
|
89
|
+
base_url = config.git_poller_file_base_url.rstrip("/")
|
|
90
|
+
route_prefix = ARCHIVE_FILE_ROUTE_PREFIX
|
|
91
|
+
filename = quote(path.name)
|
|
92
|
+
expires_at = int(time.time()) + ARCHIVE_FILE_TOKEN_TTL_SECONDS
|
|
93
|
+
query = urlencode(
|
|
94
|
+
{
|
|
95
|
+
"expires": str(expires_at),
|
|
96
|
+
"token": archive_download_token(path.name, expires_at),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
return f"{base_url}{route_prefix}/{filename}?{query}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _archive_file_token() -> str:
|
|
103
|
+
return _runtime_file_token
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def valid_archive_download_token(
|
|
107
|
+
filename: str,
|
|
108
|
+
expires: str | None,
|
|
109
|
+
token: str | None,
|
|
110
|
+
) -> bool:
|
|
111
|
+
if not expires or not token:
|
|
112
|
+
return False
|
|
113
|
+
try:
|
|
114
|
+
expires_at = int(expires)
|
|
115
|
+
except ValueError:
|
|
116
|
+
return False
|
|
117
|
+
if expires_at < int(time.time()):
|
|
118
|
+
return False
|
|
119
|
+
expected = archive_download_token(filename, expires_at)
|
|
120
|
+
return hmac.compare_digest(token, expected)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def archive_download_token(filename: str, expires_at: int) -> str:
|
|
124
|
+
message = f"{filename}\0{expires_at}".encode("utf-8")
|
|
125
|
+
secret = _archive_file_token().encode("utf-8")
|
|
126
|
+
return hmac.new(secret, message, hashlib.sha256).hexdigest()
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone, timedelta
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from dulwich import porcelain
|
|
11
|
+
from dulwich.client import _import_remote_refs, get_transport_and_path
|
|
12
|
+
from dulwich.config import StackedConfig, env_config
|
|
13
|
+
from dulwich.errors import NotGitRepository
|
|
14
|
+
from dulwich.objects import Blob, Commit, Tree
|
|
15
|
+
from dulwich.repo import Repo
|
|
16
|
+
from nonebot import logger
|
|
17
|
+
from nonebot_plugin_localstore import get_plugin_cache_dir
|
|
18
|
+
import os
|
|
19
|
+
import urllib3
|
|
20
|
+
|
|
21
|
+
from .config import Config
|
|
22
|
+
from .models import CommitInfo
|
|
23
|
+
from .repository import build_commit_url
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
MAX_COMMITS = 50
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class RemoteHead:
|
|
31
|
+
branch: str
|
|
32
|
+
sha: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GitRepositoryCache:
|
|
36
|
+
def __init__(self, config: Config) -> None:
|
|
37
|
+
self.config = config
|
|
38
|
+
self.cache_dir = get_plugin_cache_dir() / "repos"
|
|
39
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
def fetch(self, repo_key: str, url: str, branch: str) -> "FetchedRepository":
|
|
42
|
+
repo_path = self.cache_dir / repo_key
|
|
43
|
+
fetch_result = None
|
|
44
|
+
if not _looks_like_bare_repo(repo_path):
|
|
45
|
+
if repo_path.exists():
|
|
46
|
+
raise NotGitRepository(str(repo_path))
|
|
47
|
+
logger.info(f"git poller cloning repository cache: {url} -> {repo_path}")
|
|
48
|
+
cloned = porcelain.clone(
|
|
49
|
+
url,
|
|
50
|
+
repo_path,
|
|
51
|
+
bare=True,
|
|
52
|
+
branch=branch,
|
|
53
|
+
errstream=BytesIO(),
|
|
54
|
+
**self._porcelain_transport_kwargs(url),
|
|
55
|
+
)
|
|
56
|
+
cloned.close()
|
|
57
|
+
else:
|
|
58
|
+
logger.info(f"git poller fetching repository cache: {url} -> {repo_path}")
|
|
59
|
+
fetch_result = self._fetch_existing_repo(repo_path, url)
|
|
60
|
+
|
|
61
|
+
repo = Repo(repo_path)
|
|
62
|
+
try:
|
|
63
|
+
head_sha = _resolve_branch_head(repo, branch, remote_refs=getattr(fetch_result, "refs", None))
|
|
64
|
+
except BaseException:
|
|
65
|
+
repo.close()
|
|
66
|
+
raise
|
|
67
|
+
return FetchedRepository(repo=repo, url=url, branch=branch, head_sha=head_sha)
|
|
68
|
+
|
|
69
|
+
def resolve_remote_head(self, url: str, branch: str | None = None) -> RemoteHead:
|
|
70
|
+
logger.info(f"git poller checking remote head: {url} branch={branch or '<default>'}")
|
|
71
|
+
client, path = get_transport_and_path(
|
|
72
|
+
url,
|
|
73
|
+
config=self._git_config(),
|
|
74
|
+
quiet=True,
|
|
75
|
+
pool_manager=self._pool_manager(url),
|
|
76
|
+
)
|
|
77
|
+
result = client.get_refs(_encode_path(path))
|
|
78
|
+
if branch is None:
|
|
79
|
+
remote_head = _resolve_remote_default_head(result.refs, result.symrefs)
|
|
80
|
+
else:
|
|
81
|
+
remote_head = RemoteHead(
|
|
82
|
+
branch=branch,
|
|
83
|
+
sha=_resolve_remote_branch_head(result.refs, branch),
|
|
84
|
+
)
|
|
85
|
+
logger.info(
|
|
86
|
+
f"git poller remote head resolved: {url} "
|
|
87
|
+
f"branch={remote_head.branch} sha={remote_head.sha[:8]}"
|
|
88
|
+
)
|
|
89
|
+
return remote_head
|
|
90
|
+
|
|
91
|
+
def remove_cache(self, repo_key: str) -> bool:
|
|
92
|
+
repo_path = self.cache_dir / repo_key
|
|
93
|
+
try:
|
|
94
|
+
repo_path.resolve().relative_to(self.cache_dir.resolve())
|
|
95
|
+
except ValueError:
|
|
96
|
+
logger.warning(f"git poller refused to remove cache outside repo cache: {repo_path}")
|
|
97
|
+
return False
|
|
98
|
+
if not repo_path.exists():
|
|
99
|
+
return False
|
|
100
|
+
shutil.rmtree(repo_path)
|
|
101
|
+
logger.info(f"git poller removed repository cache: {repo_path}")
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
def cached_repo_keys(self) -> set[str]:
|
|
105
|
+
result: set[str] = set()
|
|
106
|
+
for path in self.cache_dir.iterdir():
|
|
107
|
+
if path.is_dir() and _looks_like_bare_repo(path):
|
|
108
|
+
result.add(path.name)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
def _porcelain_transport_kwargs(self, url: str) -> dict[str, Any]:
|
|
112
|
+
kwargs: dict[str, Any] = {"quiet": True}
|
|
113
|
+
pool_manager = self._pool_manager(url)
|
|
114
|
+
if pool_manager is not None:
|
|
115
|
+
kwargs["pool_manager"] = pool_manager
|
|
116
|
+
return kwargs
|
|
117
|
+
|
|
118
|
+
def _fetch_existing_repo(self, repo_path: Path, url: str):
|
|
119
|
+
repo = Repo(repo_path)
|
|
120
|
+
try:
|
|
121
|
+
client, path = get_transport_and_path(
|
|
122
|
+
url,
|
|
123
|
+
config=repo.get_config_stack(),
|
|
124
|
+
quiet=True,
|
|
125
|
+
pool_manager=self._pool_manager(url),
|
|
126
|
+
)
|
|
127
|
+
fetch_result = client.fetch(
|
|
128
|
+
_encode_path(path),
|
|
129
|
+
repo,
|
|
130
|
+
progress=lambda data: None,
|
|
131
|
+
)
|
|
132
|
+
_import_remote_refs(
|
|
133
|
+
repo.refs,
|
|
134
|
+
"origin",
|
|
135
|
+
fetch_result.refs,
|
|
136
|
+
message=b"fetch: from " + url.encode("utf-8"),
|
|
137
|
+
prune=True,
|
|
138
|
+
)
|
|
139
|
+
return fetch_result
|
|
140
|
+
finally:
|
|
141
|
+
repo.close()
|
|
142
|
+
|
|
143
|
+
def _pool_manager(self, url: str) -> urllib3.PoolManager | urllib3.ProxyManager | None:
|
|
144
|
+
if url.startswith(("http://", "https://")):
|
|
145
|
+
if self.config.git_poller_proxy:
|
|
146
|
+
return urllib3.ProxyManager(
|
|
147
|
+
self.config.git_poller_proxy,
|
|
148
|
+
timeout=self.config.git_poller_timeout,
|
|
149
|
+
)
|
|
150
|
+
return urllib3.PoolManager(
|
|
151
|
+
timeout=self.config.git_poller_timeout,
|
|
152
|
+
)
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _git_config() -> StackedConfig:
|
|
157
|
+
config = StackedConfig.default()
|
|
158
|
+
env_override = env_config(os.environ)
|
|
159
|
+
if env_override is not None:
|
|
160
|
+
config.backends.insert(0, env_override)
|
|
161
|
+
return config
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class FetchedRepository:
|
|
165
|
+
def __init__(self, repo: Repo, url: str, branch: str, head_sha: str) -> None:
|
|
166
|
+
self.repo = repo
|
|
167
|
+
self.url = url
|
|
168
|
+
self.branch = branch
|
|
169
|
+
self.head_sha = head_sha
|
|
170
|
+
|
|
171
|
+
def head_commit(self) -> CommitInfo:
|
|
172
|
+
return self.commit_info(self.head_sha)
|
|
173
|
+
|
|
174
|
+
def commit_info(self, sha: str) -> CommitInfo:
|
|
175
|
+
commit = self.repo.get_object(sha.encode("ascii"))
|
|
176
|
+
if not isinstance(commit, Commit):
|
|
177
|
+
raise TypeError(f"object is not a commit: {sha}")
|
|
178
|
+
return _commit_to_info(commit, self.url)
|
|
179
|
+
|
|
180
|
+
def commits_since(self, previous_sha: str | None) -> list[CommitInfo]:
|
|
181
|
+
if previous_sha and _has_object(self.repo, previous_sha):
|
|
182
|
+
include = [self.head_sha.encode("ascii")]
|
|
183
|
+
exclude = [previous_sha.encode("ascii")]
|
|
184
|
+
walker = self.repo.get_walker(
|
|
185
|
+
include=include,
|
|
186
|
+
exclude=exclude,
|
|
187
|
+
reverse=True,
|
|
188
|
+
max_entries=MAX_COMMITS,
|
|
189
|
+
)
|
|
190
|
+
commits = [
|
|
191
|
+
_commit_to_info(entry.commit, self.url)
|
|
192
|
+
for entry in walker
|
|
193
|
+
if isinstance(entry.commit, Commit)
|
|
194
|
+
]
|
|
195
|
+
return commits or [self.head_commit()]
|
|
196
|
+
|
|
197
|
+
return [self.head_commit()]
|
|
198
|
+
|
|
199
|
+
def count_commits_since(self, previous_sha: str | None) -> int | None:
|
|
200
|
+
if not previous_sha:
|
|
201
|
+
return None
|
|
202
|
+
if not _has_object(self.repo, previous_sha):
|
|
203
|
+
return None
|
|
204
|
+
walker = self.repo.get_walker(
|
|
205
|
+
include=[self.head_sha.encode("ascii")],
|
|
206
|
+
exclude=[previous_sha.encode("ascii")],
|
|
207
|
+
)
|
|
208
|
+
return sum(1 for entry in walker if isinstance(entry.commit, Commit))
|
|
209
|
+
|
|
210
|
+
def export_head_tree(self, target_dir: Path) -> None:
|
|
211
|
+
commit = self.repo.get_object(self.head_sha.encode("ascii"))
|
|
212
|
+
if not isinstance(commit, Commit):
|
|
213
|
+
raise TypeError(f"object is not a commit: {self.head_sha}")
|
|
214
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
tree = self.repo.get_object(commit.tree)
|
|
216
|
+
if not isinstance(tree, Tree):
|
|
217
|
+
raise TypeError(f"object is not a tree: {commit.tree!r}")
|
|
218
|
+
self._export_tree(tree, target_dir, target_dir.resolve())
|
|
219
|
+
|
|
220
|
+
def close(self) -> None:
|
|
221
|
+
self.repo.close()
|
|
222
|
+
|
|
223
|
+
def _export_tree(self, tree: Tree, target_dir: Path, root_dir: Path) -> None:
|
|
224
|
+
for entry in tree.iteritems(name_order=True):
|
|
225
|
+
name = entry.path.decode("utf-8", errors="replace")
|
|
226
|
+
if not _safe_tree_entry_name(name):
|
|
227
|
+
logger.warning(f"git poller skipped unsafe tree path while exporting: {name!r}")
|
|
228
|
+
continue
|
|
229
|
+
path = _safe_export_path(target_dir, name, root_dir)
|
|
230
|
+
if path is None:
|
|
231
|
+
logger.warning(f"git poller skipped tree path outside export root: {name!r}")
|
|
232
|
+
continue
|
|
233
|
+
mode = entry.mode
|
|
234
|
+
obj = self.repo.get_object(entry.sha)
|
|
235
|
+
if isinstance(obj, Tree):
|
|
236
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
self._export_tree(obj, path, root_dir)
|
|
238
|
+
continue
|
|
239
|
+
if not isinstance(obj, Blob):
|
|
240
|
+
logger.warning(f"git poller skipped non-blob object while exporting: {path}")
|
|
241
|
+
continue
|
|
242
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
data = obj.as_raw_string()
|
|
244
|
+
if _is_symlink_mode(mode):
|
|
245
|
+
# Keep archive generation inside the exported tree even when
|
|
246
|
+
# a repository contains links that point elsewhere.
|
|
247
|
+
path.write_text(data.decode("utf-8", errors="replace"), encoding="utf-8")
|
|
248
|
+
continue
|
|
249
|
+
path.write_bytes(data)
|
|
250
|
+
if _is_executable_mode(mode):
|
|
251
|
+
path.chmod(path.stat().st_mode | 0o111)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _resolve_branch_head(
|
|
255
|
+
repo: Repo,
|
|
256
|
+
branch: str,
|
|
257
|
+
*,
|
|
258
|
+
remote_refs: dict[bytes, bytes | None] | None = None,
|
|
259
|
+
) -> str:
|
|
260
|
+
if remote_refs:
|
|
261
|
+
return _resolve_remote_branch_head(remote_refs, branch)
|
|
262
|
+
refs = repo.get_refs()
|
|
263
|
+
candidates = [
|
|
264
|
+
f"refs/remotes/origin/{branch}".encode("utf-8"),
|
|
265
|
+
f"refs/heads/{branch}".encode("utf-8"),
|
|
266
|
+
]
|
|
267
|
+
if _is_head_branch(branch):
|
|
268
|
+
candidates.extend(
|
|
269
|
+
[
|
|
270
|
+
f"refs/remotes/origin/HEAD".encode("utf-8"),
|
|
271
|
+
b"HEAD",
|
|
272
|
+
]
|
|
273
|
+
)
|
|
274
|
+
for candidate in candidates:
|
|
275
|
+
value = refs.get(candidate)
|
|
276
|
+
if value:
|
|
277
|
+
return value.decode("ascii")
|
|
278
|
+
raise RuntimeError(f"找不到分支:{branch}")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _resolve_remote_branch_head(refs: dict[bytes, bytes | None], branch: str) -> str:
|
|
282
|
+
candidates = [
|
|
283
|
+
f"refs/heads/{branch}".encode("utf-8"),
|
|
284
|
+
]
|
|
285
|
+
if _is_head_branch(branch):
|
|
286
|
+
candidates.append(b"HEAD")
|
|
287
|
+
for candidate in candidates:
|
|
288
|
+
value = refs.get(candidate)
|
|
289
|
+
if value:
|
|
290
|
+
return value.decode("ascii")
|
|
291
|
+
raise RuntimeError(f"找不到分支:{branch}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _resolve_remote_default_head(
|
|
295
|
+
refs: dict[bytes, bytes | None],
|
|
296
|
+
symrefs: dict[bytes, bytes],
|
|
297
|
+
) -> RemoteHead:
|
|
298
|
+
head_ref = symrefs.get(b"HEAD")
|
|
299
|
+
if head_ref:
|
|
300
|
+
branch = _branch_name_from_ref(head_ref)
|
|
301
|
+
if branch:
|
|
302
|
+
value = refs.get(head_ref) or refs.get(b"HEAD")
|
|
303
|
+
if value:
|
|
304
|
+
return RemoteHead(branch=branch, sha=value.decode("ascii"))
|
|
305
|
+
|
|
306
|
+
head_value = refs.get(b"HEAD")
|
|
307
|
+
if head_value:
|
|
308
|
+
matches = [
|
|
309
|
+
RemoteHead(branch=branch, sha=value.decode("ascii"))
|
|
310
|
+
for branch, value in _remote_branch_refs(refs)
|
|
311
|
+
if value == head_value
|
|
312
|
+
]
|
|
313
|
+
if matches:
|
|
314
|
+
return _choose_default_head_match(matches)
|
|
315
|
+
|
|
316
|
+
branch_refs = _remote_branch_refs(refs)
|
|
317
|
+
if len(branch_refs) == 1:
|
|
318
|
+
branch, value = branch_refs[0]
|
|
319
|
+
return RemoteHead(branch=branch, sha=value.decode("ascii"))
|
|
320
|
+
|
|
321
|
+
raise RuntimeError("无法解析远端默认分支。")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _remote_branch_refs(refs: dict[bytes, bytes | None]) -> list[tuple[str, bytes]]:
|
|
325
|
+
result: list[tuple[str, bytes]] = []
|
|
326
|
+
for ref, value in refs.items():
|
|
327
|
+
if not value:
|
|
328
|
+
continue
|
|
329
|
+
branch = _branch_name_from_ref(ref)
|
|
330
|
+
if branch:
|
|
331
|
+
result.append((branch, value))
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _branch_name_from_ref(ref: bytes) -> str | None:
|
|
336
|
+
prefix = b"refs/heads/"
|
|
337
|
+
if not ref.startswith(prefix):
|
|
338
|
+
return None
|
|
339
|
+
branch = ref[len(prefix):].decode("utf-8", errors="replace").strip()
|
|
340
|
+
return branch or None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _choose_default_head_match(matches: list[RemoteHead]) -> RemoteHead:
|
|
344
|
+
for preferred in ("main", "master"):
|
|
345
|
+
for match in matches:
|
|
346
|
+
if match.branch == preferred:
|
|
347
|
+
return match
|
|
348
|
+
return sorted(matches, key=lambda item: item.branch)[0]
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _is_head_branch(branch: str) -> bool:
|
|
352
|
+
return branch.strip().upper() == "HEAD"
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _commit_to_info(commit: Commit, repo_url: str) -> CommitInfo:
|
|
356
|
+
sha = commit.id.decode("ascii")
|
|
357
|
+
lines = _decode(commit.message).splitlines()
|
|
358
|
+
title = (lines[0].strip() if lines else "") or sha[:8]
|
|
359
|
+
author = _author_name(_decode(commit.author))
|
|
360
|
+
committed_at = _format_git_time(commit.commit_time, commit.commit_timezone)
|
|
361
|
+
return CommitInfo(
|
|
362
|
+
sha=sha,
|
|
363
|
+
short_sha=sha[:8],
|
|
364
|
+
title=title,
|
|
365
|
+
committed_at=committed_at,
|
|
366
|
+
author=author,
|
|
367
|
+
url=build_commit_url(repo_url, sha),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _format_git_time(timestamp: int, offset: int) -> str:
|
|
372
|
+
tz = timezone(timedelta(seconds=offset))
|
|
373
|
+
return datetime.fromtimestamp(timestamp, tz=tz).isoformat(timespec="seconds")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _author_name(value: str) -> str:
|
|
377
|
+
if "<" in value:
|
|
378
|
+
return value.split("<", 1)[0].strip()
|
|
379
|
+
return value.strip()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _decode(value: bytes) -> str:
|
|
383
|
+
return value.decode("utf-8", errors="replace")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _safe_tree_entry_name(name: str) -> bool:
|
|
387
|
+
return (
|
|
388
|
+
name not in {"", ".", ".."}
|
|
389
|
+
and "/" not in name
|
|
390
|
+
and "\\" not in name
|
|
391
|
+
and not Path(name).is_absolute()
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _safe_export_path(target_dir: Path, name: str, root_dir: Path) -> Path | None:
|
|
396
|
+
path = target_dir / name
|
|
397
|
+
try:
|
|
398
|
+
path.resolve().relative_to(root_dir)
|
|
399
|
+
except ValueError:
|
|
400
|
+
return None
|
|
401
|
+
return path
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _encode_path(path: str | bytes) -> bytes:
|
|
405
|
+
return path.encode("utf-8") if isinstance(path, str) else path
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _has_object(repo: Repo, sha: str) -> bool:
|
|
409
|
+
try:
|
|
410
|
+
repo.get_object(sha.encode("ascii"))
|
|
411
|
+
except KeyError:
|
|
412
|
+
return False
|
|
413
|
+
return True
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _is_symlink_mode(mode: int) -> bool:
|
|
417
|
+
return mode == 0o120000
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _is_executable_mode(mode: int) -> bool:
|
|
421
|
+
return mode == 0o100755
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _looks_like_bare_repo(path: Path) -> bool:
|
|
425
|
+
return (
|
|
426
|
+
path.is_dir()
|
|
427
|
+
and (path / "objects").is_dir()
|
|
428
|
+
and (path / "refs").is_dir()
|
|
429
|
+
and (path / "HEAD").is_file()
|
|
430
|
+
)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nonebot import logger
|
|
4
|
+
from nonebot.adapters.onebot.v11 import ActionFailed, Bot, Message, MessageSegment
|
|
5
|
+
|
|
6
|
+
from .config import Config
|
|
7
|
+
from .file_server import build_archive_download_url
|
|
8
|
+
from .models import UpdatePayload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_NODE_USER_ID = 2854196310
|
|
12
|
+
ARCHIVE_UPLOAD_URI_ERROR_MESSAGE = (
|
|
13
|
+
"上传压缩包失败:OneBot 无法识别文件地址,"
|
|
14
|
+
"请检查 git_poller_file_base_url 是否正确。"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ArchiveUploadUriError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_forward_nodes(payload: UpdatePayload) -> list[MessageSegment]:
|
|
23
|
+
nodes = [_node(_summary_text(payload), payload.repo_name)]
|
|
24
|
+
for index, commit in enumerate(payload.commits, start=1):
|
|
25
|
+
nodes.append(_node(_commit_text(index, commit), payload.repo_name))
|
|
26
|
+
return nodes
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_archive_delivery_text(payload: UpdatePayload, archive, *, title: str) -> str:
|
|
30
|
+
lines = [
|
|
31
|
+
f"{title}:{payload.repo_name}",
|
|
32
|
+
f"分支:{payload.branch}",
|
|
33
|
+
f"sha256:{archive.sha256}",
|
|
34
|
+
f"密码:{archive.password or '无'}",
|
|
35
|
+
]
|
|
36
|
+
if not payload.commits:
|
|
37
|
+
lines.append("无新增 commit")
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
for index, commit in enumerate(payload.commits):
|
|
41
|
+
prefix = f"最新{commit.short_sha}" if index == len(payload.commits) - 1 else commit.short_sha
|
|
42
|
+
lines.append(f"{prefix}:{commit.title}")
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def send_update_to_group(bot: Bot, group_id: int, payload: UpdatePayload) -> None:
|
|
47
|
+
nodes = build_forward_nodes(payload)
|
|
48
|
+
logger.info(
|
|
49
|
+
f"git poller sending update to group {group_id}: "
|
|
50
|
+
f"repo={payload.repo_key}, target={payload.target_short_sha}, nodes={len(nodes)}"
|
|
51
|
+
)
|
|
52
|
+
await bot.send_group_forward_msg(group_id=int(group_id), messages=nodes)
|
|
53
|
+
logger.info(f"git poller update sent to group {group_id}: {payload.repo_key}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def upload_archive_to_group(
|
|
57
|
+
bot: Bot,
|
|
58
|
+
group_id: int,
|
|
59
|
+
archive,
|
|
60
|
+
*,
|
|
61
|
+
config: Config,
|
|
62
|
+
) -> None:
|
|
63
|
+
upload_file = build_archive_download_url(archive.path, config)
|
|
64
|
+
if upload_file:
|
|
65
|
+
logger.info(
|
|
66
|
+
f"git poller archive upload source for group {group_id} uses HTTP route: "
|
|
67
|
+
f"{upload_file.split('?', 1)[0]}"
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
upload_file = str(archive.path)
|
|
71
|
+
logger.warning(
|
|
72
|
+
"git_poller_file_base_url is not configured; falling back to local archive path. "
|
|
73
|
+
"This only works when the OneBot implementation can read the same filesystem."
|
|
74
|
+
)
|
|
75
|
+
logger.info(
|
|
76
|
+
f"git poller uploading archive to group {group_id}: "
|
|
77
|
+
f"name={archive.name}, password={archive.password_used}, file={upload_file}"
|
|
78
|
+
)
|
|
79
|
+
try:
|
|
80
|
+
await bot.upload_group_file(
|
|
81
|
+
group_id=int(group_id),
|
|
82
|
+
file=upload_file,
|
|
83
|
+
name=archive.name,
|
|
84
|
+
)
|
|
85
|
+
except ActionFailed as exc:
|
|
86
|
+
if _is_unrecognized_upload_uri(exc):
|
|
87
|
+
raise ArchiveUploadUriError(ARCHIVE_UPLOAD_URI_ERROR_MESSAGE) from exc
|
|
88
|
+
raise
|
|
89
|
+
logger.info(f"git poller archive uploaded to group {group_id}: {archive.name}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _summary_text(payload: UpdatePayload) -> str:
|
|
93
|
+
items = [
|
|
94
|
+
f"仓库更新:{payload.repo_name}",
|
|
95
|
+
f"分支:{payload.branch}",
|
|
96
|
+
f"当前:{payload.target_short_sha}",
|
|
97
|
+
f"新增 commit:{len(payload.commits)}",
|
|
98
|
+
]
|
|
99
|
+
if payload.previous_sha:
|
|
100
|
+
items.append(f"上次成功:{payload.previous_sha[:8]}")
|
|
101
|
+
if payload.compare_url:
|
|
102
|
+
items.append(payload.compare_url)
|
|
103
|
+
return "\n".join(items)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _commit_text(index: int, commit) -> str:
|
|
107
|
+
items = [
|
|
108
|
+
f"{index}. {commit.title}",
|
|
109
|
+
f"commit: {commit.short_sha}",
|
|
110
|
+
]
|
|
111
|
+
if commit.author:
|
|
112
|
+
items.append(f"author: {commit.author}")
|
|
113
|
+
if commit.committed_at:
|
|
114
|
+
items.append(f"time: {commit.committed_at}")
|
|
115
|
+
if commit.url:
|
|
116
|
+
items.append(commit.url)
|
|
117
|
+
return "\n".join(items)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _node(content: str, nickname: str) -> MessageSegment:
|
|
121
|
+
return MessageSegment.node_custom(
|
|
122
|
+
user_id=DEFAULT_NODE_USER_ID,
|
|
123
|
+
nickname=nickname or "Git 更新",
|
|
124
|
+
content=Message(content),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_unrecognized_upload_uri(exc: ActionFailed) -> bool:
|
|
129
|
+
info = getattr(exc, "info", {})
|
|
130
|
+
fields = []
|
|
131
|
+
if isinstance(info, dict):
|
|
132
|
+
fields.extend(str(info.get(key, "")) for key in ("message", "wording", "msg"))
|
|
133
|
+
fields.append(repr(exc))
|
|
134
|
+
text = "\n".join(fields)
|
|
135
|
+
return "识别URL失败" in text and "uri=" in text
|