FossilRepo

Add Chart.js commit activity graph and top contributors to project overview - Weekly commit frequency bar chart (last 52 weeks) using Chart.js - Brand red bars with tooltip showing "X weeks ago" - Top contributors sidebar with clickable links to user profiles - get_commit_activity() reader method: weekly commit counts via SQL - get_top_contributors() reader method: top N users by checkin count

lmata 2026-04-06 15:55 trunk
Commit f713901f005b37d1c2714bd6b27d145d0eb4381e5fee8f682c754b492603ce98
--- fossil/reader.py
+++ fossil/reader.py
@@ -331,10 +331,50 @@
331331
row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='t'", (username,)).fetchone()
332332
result["ticket_count"] = row[0] if row else 0
333333
except sqlite3.OperationalError:
334334
pass
335335
return result
336
+
337
+ def get_commit_activity(self, weeks: int = 52) -> list[dict]:
338
+ """Get weekly commit counts for the last N weeks. Returns [{week, count}]."""
339
+ activity = []
340
+ try:
341
+ # Julian day for "now" minus weeks*7 days
342
+ rows = self.conn.execute(
343
+ """
344
+ SELECT cast((julianday('now') - event.mtime) / 7 as integer) as weeks_ago,
345
+ count(*) as cnt
346
+ FROM event
347
+ WHERE event.type = 'ci'
348
+ AND event.mtime > julianday('now') - ?
349
+ GROUP BY weeks_ago
350
+ ORDER BY weeks_ago DESC
351
+ """,
352
+ (weeks * 7,),
353
+ ).fetchall()
354
+
355
+ # Build a full list with zeros for empty weeks
356
+ counts = {r["weeks_ago"]: r["cnt"] for r in rows}
357
+ for w in range(weeks - 1, -1, -1):
358
+ activity.append({"week": w, "count": counts.get(w, 0)})
359
+ except sqlite3.OperationalError:
360
+ pass
361
+ return activity
362
+
363
+ def get_top_contributors(self, limit: int = 10) -> list[dict]:
364
+ """Get top contributors by checkin count."""
365
+ contributors = []
366
+ try:
367
+ rows = self.conn.execute(
368
+ "SELECT user, count(*) as cnt FROM event WHERE type='ci' GROUP BY user ORDER BY cnt DESC LIMIT ?",
369
+ (limit,),
370
+ ).fetchall()
371
+ for r in rows:
372
+ contributors.append({"user": r["user"], "count": r["cnt"]})
373
+ except sqlite3.OperationalError:
374
+ pass
375
+ return contributors
336376
337377
# --- Timeline ---
338378
339379
def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]:
340380
sql = """
341381
--- fossil/reader.py
+++ fossil/reader.py
@@ -331,10 +331,50 @@
331 row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='t'", (username,)).fetchone()
332 result["ticket_count"] = row[0] if row else 0
333 except sqlite3.OperationalError:
334 pass
335 return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
337 # --- Timeline ---
338
339 def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]:
340 sql = """
341
--- fossil/reader.py
+++ fossil/reader.py
@@ -331,10 +331,50 @@
331 row = self.conn.execute("SELECT count(*) FROM event WHERE user=? AND type='t'", (username,)).fetchone()
332 result["ticket_count"] = row[0] if row else 0
333 except sqlite3.OperationalError:
334 pass
335 return result
336
337 def get_commit_activity(self, weeks: int = 52) -> list[dict]:
338 """Get weekly commit counts for the last N weeks. Returns [{week, count}]."""
339 activity = []
340 try:
341 # Julian day for "now" minus weeks*7 days
342 rows = self.conn.execute(
343 """
344 SELECT cast((julianday('now') - event.mtime) / 7 as integer) as weeks_ago,
345 count(*) as cnt
346 FROM event
347 WHERE event.type = 'ci'
348 AND event.mtime > julianday('now') - ?
349 GROUP BY weeks_ago
350 ORDER BY weeks_ago DESC
351 """,
352 (weeks * 7,),
353 ).fetchall()
354
355 # Build a full list with zeros for empty weeks
356 counts = {r["weeks_ago"]: r["cnt"] for r in rows}
357 for w in range(weeks - 1, -1, -1):
358 activity.append({"week": w, "count": counts.get(w, 0)})
359 except sqlite3.OperationalError:
360 pass
361 return activity
362
363 def get_top_contributors(self, limit: int = 10) -> list[dict]:
364 """Get top contributors by checkin count."""
365 contributors = []
366 try:
367 rows = self.conn.execute(
368 "SELECT user, count(*) as cnt FROM event WHERE type='ci' GROUP BY user ORDER BY cnt DESC LIMIT ?",
369 (limit,),
370 ).fetchall()
371 for r in rows:
372 contributors.append({"user": r["user"], "count": r["cnt"]})
373 except sqlite3.OperationalError:
374 pass
375 return contributors
376
377 # --- Timeline ---
378
379 def get_timeline(self, limit: int = 50, offset: int = 0, event_type: str | None = None) -> list[TimelineEntry]:
380 sql = """
381
+2 -5
--- fossil/views.py
+++ fossil/views.py
@@ -84,15 +84,12 @@
8484
if re.search(r"\[.+\]\[.+\]", stripped):
8585
return True
8686
# Has markdown code fences
8787
if "```" in stripped:
8888
return True
89
- # Starts with HTML block element — it's Fossil wiki/HTML
90
- if re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE):
91
- return False
92
- # Default: treat as markdown (handles plain text gracefully)
93
- return True
89
+ # Starts with HTML block element — it's Fossil wiki/HTML; otherwise default to markdown
90
+ return not re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE)
9491
9592
9693
def _rewrite_fossil_links(html: str, project_slug: str) -> str:
9794
"""Rewrite internal Fossil URLs to our app's URL structure.
9895
9996
--- fossil/views.py
+++ fossil/views.py
@@ -84,15 +84,12 @@
84 if re.search(r"\[.+\]\[.+\]", stripped):
85 return True
86 # Has markdown code fences
87 if "```" in stripped:
88 return True
89 # Starts with HTML block element — it's Fossil wiki/HTML
90 if re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE):
91 return False
92 # Default: treat as markdown (handles plain text gracefully)
93 return True
94
95
96 def _rewrite_fossil_links(html: str, project_slug: str) -> str:
97 """Rewrite internal Fossil URLs to our app's URL structure.
98
99
--- fossil/views.py
+++ fossil/views.py
@@ -84,15 +84,12 @@
84 if re.search(r"\[.+\]\[.+\]", stripped):
85 return True
86 # Has markdown code fences
87 if "```" in stripped:
88 return True
89 # Starts with HTML block element — it's Fossil wiki/HTML; otherwise default to markdown
90 return not re.match(r"<(h[1-6]|p|ol|ul|div|table)\b", stripped, re.IGNORECASE)
 
 
 
91
92
93 def _rewrite_fossil_links(html: str, project_slug: str) -> str:
94 """Rewrite internal Fossil URLs to our app's URL structure.
95
96
--- projects/views.py
+++ projects/views.py
@@ -53,26 +53,39 @@
5353
project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
5454
5555
# Get Fossil repo stats if available
5656
repo_stats = None
5757
recent_commits = []
58
+ commit_activity = []
59
+ top_contributors = []
5860
try:
5961
from fossil.models import FossilRepository
6062
from fossil.reader import FossilReader
6163
6264
fossil_repo = FossilRepository.objects.filter(project=project, deleted_at__isnull=True).first()
6365
if fossil_repo and fossil_repo.exists_on_disk:
6466
with FossilReader(fossil_repo.full_path) as reader:
6567
repo_stats = reader.get_metadata()
6668
recent_commits = reader.get_timeline(limit=5, event_type="ci")
69
+ commit_activity = reader.get_commit_activity(weeks=52)
70
+ top_contributors = reader.get_top_contributors(limit=8)
6771
except Exception:
6872
pass
73
+
74
+ import json
6975
7076
return render(
7177
request,
7278
"projects/project_detail.html",
73
- {"project": project, "project_teams": project_teams, "repo_stats": repo_stats, "recent_commits": recent_commits},
79
+ {
80
+ "project": project,
81
+ "project_teams": project_teams,
82
+ "repo_stats": repo_stats,
83
+ "recent_commits": recent_commits,
84
+ "commit_activity_json": json.dumps([c["count"] for c in commit_activity]),
85
+ "top_contributors": top_contributors,
86
+ },
7487
)
7588
7689
7790
@login_required
7891
def project_update(request, slug):
7992
--- projects/views.py
+++ projects/views.py
@@ -53,26 +53,39 @@
53 project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
54
55 # Get Fossil repo stats if available
56 repo_stats = None
57 recent_commits = []
 
 
58 try:
59 from fossil.models import FossilRepository
60 from fossil.reader import FossilReader
61
62 fossil_repo = FossilRepository.objects.filter(project=project, deleted_at__isnull=True).first()
63 if fossil_repo and fossil_repo.exists_on_disk:
64 with FossilReader(fossil_repo.full_path) as reader:
65 repo_stats = reader.get_metadata()
66 recent_commits = reader.get_timeline(limit=5, event_type="ci")
 
 
67 except Exception:
68 pass
 
 
69
70 return render(
71 request,
72 "projects/project_detail.html",
73 {"project": project, "project_teams": project_teams, "repo_stats": repo_stats, "recent_commits": recent_commits},
 
 
 
 
 
 
 
74 )
75
76
77 @login_required
78 def project_update(request, slug):
79
--- projects/views.py
+++ projects/views.py
@@ -53,26 +53,39 @@
53 project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
54
55 # Get Fossil repo stats if available
56 repo_stats = None
57 recent_commits = []
58 commit_activity = []
59 top_contributors = []
60 try:
61 from fossil.models import FossilRepository
62 from fossil.reader import FossilReader
63
64 fossil_repo = FossilRepository.objects.filter(project=project, deleted_at__isnull=True).first()
65 if fossil_repo and fossil_repo.exists_on_disk:
66 with FossilReader(fossil_repo.full_path) as reader:
67 repo_stats = reader.get_metadata()
68 recent_commits = reader.get_timeline(limit=5, event_type="ci")
69 commit_activity = reader.get_commit_activity(weeks=52)
70 top_contributors = reader.get_top_contributors(limit=8)
71 except Exception:
72 pass
73
74 import json
75
76 return render(
77 request,
78 "projects/project_detail.html",
79 {
80 "project": project,
81 "project_teams": project_teams,
82 "repo_stats": repo_stats,
83 "recent_commits": recent_commits,
84 "commit_activity_json": json.dumps([c["count"] for c in commit_activity]),
85 "top_contributors": top_contributors,
86 },
87 )
88
89
90 @login_required
91 def project_update(request, slug):
92
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -1,7 +1,11 @@
11
{% extends "base.html" %}
22
{% block title %}{{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block extra_head %}
5
+<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
6
+{% endblock %}
37
48
{% block content %}
59
<div class="flex items-center justify-between mb-6">
610
<div>
711
<h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
@@ -25,10 +29,20 @@
2529
{% endif %}
2630
2731
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
2832
<!-- Main content -->
2933
<div class="lg:col-span-2 space-y-6">
34
+ {% if commit_activity_json %}
35
+ <!-- Commit activity chart -->
36
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
37
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Commit Activity (last year)</h3>
38
+ <div style="height: 120px;">
39
+ <canvas id="activityChart"></canvas>
40
+ </div>
41
+ </div>
42
+ {% endif %}
43
+
3044
{% if repo_stats and recent_commits %}
3145
<!-- Recent activity -->
3246
<div class="rounded-lg bg-gray-800 border border-gray-700">
3347
<div class="px-4 py-3 border-b border-gray-700">
3448
<h3 class="text-sm font-medium text-gray-300">Recent Activity</h3>
@@ -106,10 +120,26 @@
106120
<a href="{% url 'fossil:wiki' slug=project.slug %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ repo_stats.wiki_page_count|default:"0" }}</a>
107121
</div>
108122
</div>
109123
</div>
110124
{% endif %}
125
+
126
+ {% if top_contributors %}
127
+ <!-- Contributors -->
128
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
129
+ <h3 class="text-sm font-medium text-gray-300 mb-3">Top Contributors</h3>
130
+ <div class="space-y-1">
131
+ {% for c in top_contributors %}
132
+ <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}"
133
+ class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
134
+ <span class="text-sm text-gray-300">{{ c.user }}</span>
135
+ <span class="text-xs text-gray-500">{{ c.count }} commits</span>
136
+ </a>
137
+ {% endfor %}
138
+ </div>
139
+ </div>
140
+ {% endif %}
111141
112142
<!-- Project info -->
113143
<div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
114144
<h3 class="text-sm font-medium text-gray-300 mb-3">About</h3>
115145
<dl class="space-y-2 text-sm">
@@ -131,6 +161,36 @@
131161
</div>
132162
</dl>
133163
</div>
134164
</div>
135165
</div>
166
+
167
+{% if commit_activity_json %}
168
+<script>
169
+ const ctx = document.getElementById('activityChart').getContext('2d');
170
+ new Chart(ctx, {
171
+ type: 'bar',
172
+ data: {
173
+ labels: {{ commit_activity_json|safe }}.map((_, i) => ''),
174
+ datasets: [{
175
+ data: {{ commit_activity_json|safe }},
176
+ backgroundColor: '#DC394C',
177
+ borderRadius: 2,
178
+ barPercentage: 0.8,
179
+ categoryPercentage: 0.9,
180
+ }]
181
+ },
182
+ options: {
183
+ responsive: true,
184
+ maintainAspectRatio: false,
185
+ plugins: { legend: { display: false }, tooltip: {
186
+ callbacks: { title: (items) => { const w = 51 - items[0].dataIndex; return w === 0 ? 'This week' : w + ' week' + (w > 1 ? 's' : '') + ' ago'; } }
187
+ }},
188
+ scales: {
189
+ x: { display: false, grid: { display: false } },
190
+ y: { display: false, grid: { display: false }, beginAtZero: true }
191
+ }
192
+ }
193
+ });
194
+</script>
195
+{% endif %}
136196
{% endblock %}
137197
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -1,7 +1,11 @@
1 {% extends "base.html" %}
2 {% block title %}{{ project.name }} — Fossilrepo{% endblock %}
 
 
 
 
3
4 {% block content %}
5 <div class="flex items-center justify-between mb-6">
6 <div>
7 <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
@@ -25,10 +29,20 @@
25 {% endif %}
26
27 <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
28 <!-- Main content -->
29 <div class="lg:col-span-2 space-y-6">
 
 
 
 
 
 
 
 
 
 
30 {% if repo_stats and recent_commits %}
31 <!-- Recent activity -->
32 <div class="rounded-lg bg-gray-800 border border-gray-700">
33 <div class="px-4 py-3 border-b border-gray-700">
34 <h3 class="text-sm font-medium text-gray-300">Recent Activity</h3>
@@ -106,10 +120,26 @@
106 <a href="{% url 'fossil:wiki' slug=project.slug %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ repo_stats.wiki_page_count|default:"0" }}</a>
107 </div>
108 </div>
109 </div>
110 {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
112 <!-- Project info -->
113 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
114 <h3 class="text-sm font-medium text-gray-300 mb-3">About</h3>
115 <dl class="space-y-2 text-sm">
@@ -131,6 +161,36 @@
131 </div>
132 </dl>
133 </div>
134 </div>
135 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136 {% endblock %}
137
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -1,7 +1,11 @@
1 {% extends "base.html" %}
2 {% block title %}{{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block extra_head %}
5 <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
6 {% endblock %}
7
8 {% block content %}
9 <div class="flex items-center justify-between mb-6">
10 <div>
11 <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
@@ -25,10 +29,20 @@
29 {% endif %}
30
31 <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
32 <!-- Main content -->
33 <div class="lg:col-span-2 space-y-6">
34 {% if commit_activity_json %}
35 <!-- Commit activity chart -->
36 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
37 <h3 class="text-sm font-medium text-gray-300 mb-3">Commit Activity (last year)</h3>
38 <div style="height: 120px;">
39 <canvas id="activityChart"></canvas>
40 </div>
41 </div>
42 {% endif %}
43
44 {% if repo_stats and recent_commits %}
45 <!-- Recent activity -->
46 <div class="rounded-lg bg-gray-800 border border-gray-700">
47 <div class="px-4 py-3 border-b border-gray-700">
48 <h3 class="text-sm font-medium text-gray-300">Recent Activity</h3>
@@ -106,10 +120,26 @@
120 <a href="{% url 'fossil:wiki' slug=project.slug %}" class="text-sm font-medium text-gray-200 hover:text-brand-light">{{ repo_stats.wiki_page_count|default:"0" }}</a>
121 </div>
122 </div>
123 </div>
124 {% endif %}
125
126 {% if top_contributors %}
127 <!-- Contributors -->
128 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
129 <h3 class="text-sm font-medium text-gray-300 mb-3">Top Contributors</h3>
130 <div class="space-y-1">
131 {% for c in top_contributors %}
132 <a href="{% url 'fossil:user_activity' slug=project.slug username=c.user %}"
133 class="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-gray-700/50">
134 <span class="text-sm text-gray-300">{{ c.user }}</span>
135 <span class="text-xs text-gray-500">{{ c.count }} commits</span>
136 </a>
137 {% endfor %}
138 </div>
139 </div>
140 {% endif %}
141
142 <!-- Project info -->
143 <div class="rounded-lg bg-gray-800 border border-gray-700 p-4">
144 <h3 class="text-sm font-medium text-gray-300 mb-3">About</h3>
145 <dl class="space-y-2 text-sm">
@@ -131,6 +161,36 @@
161 </div>
162 </dl>
163 </div>
164 </div>
165 </div>
166
167 {% if commit_activity_json %}
168 <script>
169 const ctx = document.getElementById('activityChart').getContext('2d');
170 new Chart(ctx, {
171 type: 'bar',
172 data: {
173 labels: {{ commit_activity_json|safe }}.map((_, i) => ''),
174 datasets: [{
175 data: {{ commit_activity_json|safe }},
176 backgroundColor: '#DC394C',
177 borderRadius: 2,
178 barPercentage: 0.8,
179 categoryPercentage: 0.9,
180 }]
181 },
182 options: {
183 responsive: true,
184 maintainAspectRatio: false,
185 plugins: { legend: { display: false }, tooltip: {
186 callbacks: { title: (items) => { const w = 51 - items[0].dataIndex; return w === 0 ? 'This week' : w + ' week' + (w > 1 ? 's' : '') + ' ago'; } }
187 }},
188 scales: {
189 x: { display: false, grid: { display: false } },
190 y: { display: false, grid: { display: false }, beginAtZero: true }
191 }
192 }
193 });
194 </script>
195 {% endif %}
196 {% endblock %}
197

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button