1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""
Per-project timeline cache for recent repository activity.
"""
from __future__ import annotations
import os
import threading
from typing import Literal, TypedDict
import pygit2
from pygitweb.config import settings
from pygitweb.projects import git_get_projects_list
TIMELINE_CACHE_SIZE: int = 10
TIMELINE_CACHE_TOTAL_EVENTS: int = 200
TIMELINE_CACHE_BATCH_SIZE: int = 5
class TimelineEvent(TypedDict):
timestamp: int
oid: str
kind: Literal["commit", "ref"]
description: str | None
class TimelineProjectEvent(TypedDict):
project: str
event: TimelineEvent
PROJECT_TIMELINE_CACHE: dict[str, list[TimelineEvent]] = {}
_TIMELINE_CACHE_LOCK = threading.Lock()
_TIMELINE_WARMING: bool = False
def _commit_description(commit: pygit2.Commit) -> str | None:
if not commit.message:
return None
first_line: str = commit.message.splitlines()[0].strip()
return first_line or None
def _collect_commit_events(repo: pygit2.Repository) -> list[TimelineEvent]:
start_points: list[pygit2.Oid | str] = []
for ref_name in repo.references:
if not ref_name.startswith("refs/heads/"):
continue
try:
resolved: pygit2.Reference = repo.references[ref_name].resolve()
oid: pygit2.Oid | str = resolved.target
start_points.append(oid)
except (KeyError, pygit2.GitError, ValueError):
continue
if not start_points:
try:
head_ref: pygit2.Reference = repo.head
start_points.append(head_ref.target)
except (pygit2.GitError, ValueError, KeyError):
pass
if not start_points:
return []
walker: pygit2.Walker = repo.walk(
start_points[0],
pygit2.enums.SortMode(pygit2.GIT_SORT_TIME | pygit2.GIT_SORT_TOPOLOGICAL),
)
for oid in start_points[1:]:
try:
walker.push(oid)
except (pygit2.GitError, ValueError):
continue
events: list[TimelineEvent] = []
seen: set[str] = set()
for commit in walker:
commit_oid: str = str(commit.id)
if commit_oid in seen:
continue
seen.add(commit_oid)
events.append({
"timestamp": commit.commit_time,
"oid": commit_oid,
"kind": "commit",
"description": _commit_description(commit),
})
return events
def _collect_ref_events(repo: pygit2.Repository) -> list[TimelineEvent]:
events: list[TimelineEvent] = []
for ref_name in repo.references:
if not ref_name.startswith("refs/tags/"):
continue
try:
ref_obj: pygit2.Reference = repo.references[ref_name].resolve()
oid_str: str = str(ref_obj.target)
obj = repo[ref_obj.target]
except (KeyError, pygit2.GitError, ValueError):
continue
if isinstance(obj, pygit2.Tag):
tag_tagger = obj.tagger
timestamp: int = tag_tagger.time if tag_tagger else 0
description: str | None = ref_name.replace("refs/tags/", "", 1)
elif isinstance(obj, pygit2.Commit):
timestamp = obj.commit_time
description = ref_name.replace("refs/tags/", "", 1)
else:
continue
events.append({
"timestamp": timestamp,
"oid": oid_str,
"kind": "ref",
"description": description or None,
})
return events
def build_project_timeline_cache(project: str, max_events: int = TIMELINE_CACHE_SIZE) -> list[TimelineEvent]:
try:
repo: pygit2.Repository = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
except (pygit2.GitError, OSError, ValueError):
return []
commit_events: list[TimelineEvent] = _collect_commit_events(repo)
ref_events: list[TimelineEvent] = _collect_ref_events(repo)
all_events: list[TimelineEvent] = commit_events + ref_events
all_events.sort(key=lambda event: (event["timestamp"], event["oid"]), reverse=True)
return all_events[:max_events]
def warm_timeline_cache() -> None:
global PROJECT_TIMELINE_CACHE, _TIMELINE_WARMING
with _TIMELINE_CACHE_LOCK:
_TIMELINE_WARMING = True
try:
new_cache: dict[str, list[TimelineEvent]] = {}
projects = git_get_projects_list(
filter_path="",
paranoid=settings.STRICT_EXPORT,
export_ok=settings.EXPORT_OK,
)
project_names: list[str] = []
project_event_pool: dict[str, list[TimelineEvent]] = {}
for project_entry in projects:
project_name = project_entry.get("path")
if not isinstance(project_name, str) or not project_name:
continue
project_names.append(project_name)
try:
project_event_pool[project_name] = build_project_timeline_cache(
project_name,
max_events=TIMELINE_CACHE_TOTAL_EVENTS,
)
except (pygit2.GitError, OSError, ValueError, KeyError):
project_event_pool[project_name] = []
for project_name in project_names:
new_cache[project_name] = []
selected_events: list[TimelineProjectEvent] = []
next_index_by_project: dict[str, int] = {project_name: 0 for project_name in project_names}
for project_name in project_names:
if len(selected_events) >= TIMELINE_CACHE_TOTAL_EVENTS:
break
project_events = project_event_pool.get(project_name, [])
if not project_events:
continue
selected_events.append({"project": project_name, "event": project_events[0]})
next_index_by_project[project_name] = 1
while len(selected_events) < TIMELINE_CACHE_TOTAL_EVENTS:
candidate_projects: list[str] = [
project_name
for project_name in project_names
if next_index_by_project[project_name] < len(project_event_pool.get(project_name, []))
]
if not candidate_projects:
break
best_project: str = max(
candidate_projects,
key=lambda project_name: (
project_event_pool[project_name][next_index_by_project[project_name]]["timestamp"],
project_event_pool[project_name][next_index_by_project[project_name]]["oid"],
project_name,
),
)
start_index = next_index_by_project[best_project]
remaining_global = TIMELINE_CACHE_TOTAL_EVENTS - len(selected_events)
remaining_project = len(project_event_pool[best_project]) - start_index
take_count = min(TIMELINE_CACHE_BATCH_SIZE, remaining_global, remaining_project)
for offset in range(take_count):
selected_events.append({
"project": best_project,
"event": project_event_pool[best_project][start_index + offset],
})
next_index_by_project[best_project] = start_index + take_count
for item in selected_events:
new_cache[item["project"]].append(item["event"])
with _TIMELINE_CACHE_LOCK:
PROJECT_TIMELINE_CACHE = new_cache
finally:
with _TIMELINE_CACHE_LOCK:
_TIMELINE_WARMING = False
def warm_timeline_cache_async() -> bool:
global _TIMELINE_WARMING
with _TIMELINE_CACHE_LOCK:
if _TIMELINE_WARMING:
return False
_TIMELINE_WARMING = True
thread = threading.Thread(target=warm_timeline_cache, daemon=True)
thread.start()
return True
def get_all_timeline_events() -> list[TimelineProjectEvent]:
events: list[TimelineProjectEvent] = []
with _TIMELINE_CACHE_LOCK:
cache_snapshot = {
project_name: list(project_events) for project_name, project_events in PROJECT_TIMELINE_CACHE.items()
}
for project_name, project_events in cache_snapshot.items():
for event in project_events:
events.append({"project": project_name, "event": event})
events.sort(
key=lambda item: (item["event"]["timestamp"], item["event"]["oid"], item["project"]),
reverse=True,
)
return events