FossilRepo

Add fossil tests (26 new), CLI repo commands, Celery beat schedule, pagination Tests (26 new, 140 total): - FossilReader: metadata, timeline, files, wiki, checkin detail, activity - Wiki content extraction: W card parsing, multiline, empty - Delta application: basic coverage - Model: auto-creation via signal, full_path, exists_on_disk - Views: code/timeline/tickets/wiki/forum render, permission denied cases CLI (ctl/main.py): - `repo create <name>`: creates project + inits .fossil file - `repo list`: rich table of all repos with size and disk status - `repo delete <name>`: soft-delete Celery: - CELERY_BEAT_SCHEDULE: fossil.sync_metadata every 5 minutes Pagination: - Configurable per-page (25/50/100) with selector - Page X / Y display with total count

lmata 2026-04-06 21:17 trunk
Commit a22a045da262aa0627258413172c082fc77e40f81f7e1c30e9e660a20ea87c3b
--- config/settings.py
+++ config/settings.py
@@ -187,10 +187,16 @@
187187
CELERY_BROKER_URL = env_str("CELERY_BROKER", "redis://localhost:6379/0")
188188
CELERY_RESULT_BACKEND = "django-db"
189189
CELERY_TASK_TRACK_STARTED = True
190190
CELERY_TASK_TIME_LIMIT = 3600
191191
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
192
+CELERY_BEAT_SCHEDULE = {
193
+ "fossil-sync-metadata": {
194
+ "task": "fossil.sync_metadata",
195
+ "schedule": 300.0, # every 5 minutes
196
+ },
197
+}
192198
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
193199
194200
# --- CORS ---
195201
196202
CORS_ALLOW_CREDENTIALS = True
197203
--- config/settings.py
+++ config/settings.py
@@ -187,10 +187,16 @@
187 CELERY_BROKER_URL = env_str("CELERY_BROKER", "redis://localhost:6379/0")
188 CELERY_RESULT_BACKEND = "django-db"
189 CELERY_TASK_TRACK_STARTED = True
190 CELERY_TASK_TIME_LIMIT = 3600
191 CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
 
 
 
 
 
 
192 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
193
194 # --- CORS ---
195
196 CORS_ALLOW_CREDENTIALS = True
197
--- config/settings.py
+++ config/settings.py
@@ -187,10 +187,16 @@
187 CELERY_BROKER_URL = env_str("CELERY_BROKER", "redis://localhost:6379/0")
188 CELERY_RESULT_BACKEND = "django-db"
189 CELERY_TASK_TRACK_STARTED = True
190 CELERY_TASK_TIME_LIMIT = 3600
191 CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
192 CELERY_BEAT_SCHEDULE = {
193 "fossil-sync-metadata": {
194 "task": "fossil.sync_metadata",
195 "schedule": 300.0, # every 5 minutes
196 },
197 }
198 CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
199
200 # --- CORS ---
201
202 CORS_ALLOW_CREDENTIALS = True
203
+24
--- conftest.py
+++ conftest.py
@@ -58,10 +58,34 @@
5858
content="# Getting Started\n\nWelcome to the docs.",
5959
organization=org,
6060
created_by=admin_user,
6161
)
6262
63
+
64
+@pytest.fixture
65
+def fossil_repo(db, sample_project, admin_user, tmp_path):
66
+ """Create a FossilRepository with a real .fossil file for testing."""
67
+ import shutil
68
+
69
+ from fossil.models import FossilRepository
70
+
71
+ # Copy a test repo to tmp_path
72
+ src = "/tmp/fossil-setup/frontend-app.fossil"
73
+ dest = tmp_path / "test-project.fossil"
74
+ shutil.copy2(src, dest)
75
+
76
+ # Override FOSSIL_DATA_DIR for this test
77
+
78
+ repo = FossilRepository.objects.create(
79
+ project=sample_project,
80
+ filename="test-project.fossil",
81
+ created_by=admin_user,
82
+ )
83
+ # Patch the full_path property to point to our tmp file
84
+ repo._test_path = dest
85
+ return repo
86
+
6387
6488
@pytest.fixture
6589
def admin_client(client, admin_user):
6690
client.login(username="admin", password="testpass123")
6791
return client
6892
--- conftest.py
+++ conftest.py
@@ -58,10 +58,34 @@
58 content="# Getting Started\n\nWelcome to the docs.",
59 organization=org,
60 created_by=admin_user,
61 )
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
64 @pytest.fixture
65 def admin_client(client, admin_user):
66 client.login(username="admin", password="testpass123")
67 return client
68
--- conftest.py
+++ conftest.py
@@ -58,10 +58,34 @@
58 content="# Getting Started\n\nWelcome to the docs.",
59 organization=org,
60 created_by=admin_user,
61 )
62
63
64 @pytest.fixture
65 def fossil_repo(db, sample_project, admin_user, tmp_path):
66 """Create a FossilRepository with a real .fossil file for testing."""
67 import shutil
68
69 from fossil.models import FossilRepository
70
71 # Copy a test repo to tmp_path
72 src = "/tmp/fossil-setup/frontend-app.fossil"
73 dest = tmp_path / "test-project.fossil"
74 shutil.copy2(src, dest)
75
76 # Override FOSSIL_DATA_DIR for this test
77
78 repo = FossilRepository.objects.create(
79 project=sample_project,
80 filename="test-project.fossil",
81 created_by=admin_user,
82 )
83 # Patch the full_path property to point to our tmp file
84 repo._test_path = dest
85 return repo
86
87
88 @pytest.fixture
89 def admin_client(client, admin_user):
90 client.login(username="admin", password="testpass123")
91 return client
92
+61 -3
--- ctl/main.py
+++ ctl/main.py
@@ -118,26 +118,84 @@
118118
119119
@repo.command()
120120
@click.argument("name")
121121
def create(name: str) -> None:
122122
"""Create a new Fossil repository."""
123
+ import django
124
+
125
+ django.setup()
126
+
127
+ from fossil.cli import FossilCLI
128
+ from fossil.models import FossilRepository
129
+ from organization.models import Organization
130
+ from projects.models import Project
131
+
123132
console.print(f"[bold]Creating repo:[/bold] {name}")
124
- raise NotImplementedError("Repo creation not yet implemented")
133
+ cli = FossilCLI()
134
+ if not cli.is_available():
135
+ console.print("[red]Fossil binary not found.[/red]")
136
+ return
137
+
138
+ org = Organization.objects.first()
139
+ if not org:
140
+ console.print("[red]No organization found. Run seed first.[/red]")
141
+ return
142
+
143
+ project, created = Project.objects.get_or_create(name=name, defaults={"organization": org, "visibility": "private"})
144
+ if created:
145
+ console.print(f" Created project: [cyan]{project.slug}[/cyan]")
146
+
147
+ fossil_repo = FossilRepository.objects.filter(project=project).first()
148
+ if fossil_repo and fossil_repo.exists_on_disk:
149
+ console.print(f" Repo already exists: [cyan]{fossil_repo.full_path}[/cyan]")
150
+ elif fossil_repo:
151
+ cli.init(fossil_repo.full_path)
152
+ fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
153
+ fossil_repo.save(update_fields=["file_size_bytes", "updated_at", "version"])
154
+ console.print(f" Initialized: [green]{fossil_repo.full_path}[/green]")
155
+ console.print("[bold green]Done.[/bold green]")
125156
126157
127158
@repo.command(name="list")
128159
def list_repos() -> None:
129160
"""List all Fossil repositories."""
130
- raise NotImplementedError("Repo listing not yet implemented")
161
+ import django
162
+
163
+ django.setup()
164
+ from rich.table import Table
165
+
166
+ from fossil.models import FossilRepository
167
+
168
+ repos = FossilRepository.objects.all()
169
+ table = Table(title="Fossil Repositories")
170
+ table.add_column("Project", style="cyan")
171
+ table.add_column("Filename")
172
+ table.add_column("Size", justify="right")
173
+ table.add_column("On Disk", justify="center")
174
+ for r in repos:
175
+ size = f"{r.file_size_bytes / 1024:.0f} KB" if r.file_size_bytes else "—"
176
+ table.add_row(r.project.name, r.filename, size, "yes" if r.exists_on_disk else "no")
177
+ console.print(table)
131178
132179
133180
@repo.command()
134181
@click.argument("name")
135182
def delete(name: str) -> None:
136183
"""Delete a Fossil repository (soft delete)."""
184
+ import django
185
+
186
+ django.setup()
187
+ from fossil.models import FossilRepository
188
+
137189
console.print(f"[bold]Deleting repo:[/bold] {name}")
138
- raise NotImplementedError("Repo deletion not yet implemented")
190
+ repo = FossilRepository.objects.filter(filename=f"{name}.fossil").first()
191
+ if not repo:
192
+ console.print(f"[red]Repo not found: {name}.fossil[/red]")
193
+ return
194
+ repo.soft_delete()
195
+ console.print(f" Soft-deleted: [yellow]{repo.filename}[/yellow]")
196
+ console.print("[bold green]Done.[/bold green]")
139197
140198
141199
# ---------------------------------------------------------------------------
142200
# Sync commands
143201
# ---------------------------------------------------------------------------
144202
145203
ADDED fossil/tests.py
--- ctl/main.py
+++ ctl/main.py
@@ -118,26 +118,84 @@
118
119 @repo.command()
120 @click.argument("name")
121 def create(name: str) -> None:
122 """Create a new Fossil repository."""
 
 
 
 
 
 
 
 
 
123 console.print(f"[bold]Creating repo:[/bold] {name}")
124 raise NotImplementedError("Repo creation not yet implemented")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
126
127 @repo.command(name="list")
128 def list_repos() -> None:
129 """List all Fossil repositories."""
130 raise NotImplementedError("Repo listing not yet implemented")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
132
133 @repo.command()
134 @click.argument("name")
135 def delete(name: str) -> None:
136 """Delete a Fossil repository (soft delete)."""
 
 
 
 
 
137 console.print(f"[bold]Deleting repo:[/bold] {name}")
138 raise NotImplementedError("Repo deletion not yet implemented")
 
 
 
 
 
 
139
140
141 # ---------------------------------------------------------------------------
142 # Sync commands
143 # ---------------------------------------------------------------------------
144
145 DDED fossil/tests.py
--- ctl/main.py
+++ ctl/main.py
@@ -118,26 +118,84 @@
118
119 @repo.command()
120 @click.argument("name")
121 def create(name: str) -> None:
122 """Create a new Fossil repository."""
123 import django
124
125 django.setup()
126
127 from fossil.cli import FossilCLI
128 from fossil.models import FossilRepository
129 from organization.models import Organization
130 from projects.models import Project
131
132 console.print(f"[bold]Creating repo:[/bold] {name}")
133 cli = FossilCLI()
134 if not cli.is_available():
135 console.print("[red]Fossil binary not found.[/red]")
136 return
137
138 org = Organization.objects.first()
139 if not org:
140 console.print("[red]No organization found. Run seed first.[/red]")
141 return
142
143 project, created = Project.objects.get_or_create(name=name, defaults={"organization": org, "visibility": "private"})
144 if created:
145 console.print(f" Created project: [cyan]{project.slug}[/cyan]")
146
147 fossil_repo = FossilRepository.objects.filter(project=project).first()
148 if fossil_repo and fossil_repo.exists_on_disk:
149 console.print(f" Repo already exists: [cyan]{fossil_repo.full_path}[/cyan]")
150 elif fossil_repo:
151 cli.init(fossil_repo.full_path)
152 fossil_repo.file_size_bytes = fossil_repo.full_path.stat().st_size
153 fossil_repo.save(update_fields=["file_size_bytes", "updated_at", "version"])
154 console.print(f" Initialized: [green]{fossil_repo.full_path}[/green]")
155 console.print("[bold green]Done.[/bold green]")
156
157
158 @repo.command(name="list")
159 def list_repos() -> None:
160 """List all Fossil repositories."""
161 import django
162
163 django.setup()
164 from rich.table import Table
165
166 from fossil.models import FossilRepository
167
168 repos = FossilRepository.objects.all()
169 table = Table(title="Fossil Repositories")
170 table.add_column("Project", style="cyan")
171 table.add_column("Filename")
172 table.add_column("Size", justify="right")
173 table.add_column("On Disk", justify="center")
174 for r in repos:
175 size = f"{r.file_size_bytes / 1024:.0f} KB" if r.file_size_bytes else "—"
176 table.add_row(r.project.name, r.filename, size, "yes" if r.exists_on_disk else "no")
177 console.print(table)
178
179
180 @repo.command()
181 @click.argument("name")
182 def delete(name: str) -> None:
183 """Delete a Fossil repository (soft delete)."""
184 import django
185
186 django.setup()
187 from fossil.models import FossilRepository
188
189 console.print(f"[bold]Deleting repo:[/bold] {name}")
190 repo = FossilRepository.objects.filter(filename=f"{name}.fossil").first()
191 if not repo:
192 console.print(f"[red]Repo not found: {name}.fossil[/red]")
193 return
194 repo.soft_delete()
195 console.print(f" Soft-deleted: [yellow]{repo.filename}[/yellow]")
196 console.print("[bold green]Done.[/bold green]")
197
198
199 # ---------------------------------------------------------------------------
200 # Sync commands
201 # ---------------------------------------------------------------------------
202
203 DDED fossil/tests.py
--- a/fossil/tests.py
+++ b/fossil/tests.py
@@ -0,0 +1,164 @@
1
+import shutifrom unittest.moctil
2
+from import UTC, datetime
3
+from pathlib import Path
4
+
5
+import pytest
6
+
7
+from .models impossilReader, Time
8
+# --- Reader tests ---
9
+
10
+
11
+@pytest.mark.django_db
12
+class TestFossilReader:
13
+ @pytest.fixture
14
+ def repo_path(self, tmp_path):
15
+ src = Path("/tmp/fossil-setup/frontend-app.fossil")
16
+ if not src.exists():
17
+ pytest.skip("Test fossil repo not available")
18
+ dest = tmp_path / "test.fossil"
19
+ shutil.copy2(src, dest)
20
+ return dest
21
+
22
+ def test_get_metadata(self, repo_path):
23
+ with FossilReader(repo_path) as reader:
24
+ meta = reader.get_metadata()
25
+ assert meta.checkin_count >= 2
26
+
27
+ def test_get_timeline(self, repo_path):
28
+ with FossilReader(repo_path) as reader:
29
+ entries = reader.get_timeline(limit=10)
30
+ assert len(entries) > 0
31
+ assert entries[0].uuid
32
+ assert entries[0].user
33
+
34
+ def test_get_timeline_filter_by_type(self, repo_path):
35
+ with FossilReader(repo_path) as reader:
36
+ checkins = reader.get_timeline(limit=10, event_type="ci")
37
+ for e in checkins:
38
+ assert e.event_type == "ci"
39
+
40
+ def test_get_latest_checkin_uuid(self, repo_path):
41
+ with FossilReader(repo_path) as reader:
42
+ uuid = reader.get_latest_checkin_uuid()
43
+ assert uuid is not None
44
+ assert len(uuid) > 10
45
+
46
+ def test_get_files_at_checkin(self, repo_path):
47
+ with FossilReader(repo_path) as reader:
48
+ files = reader.get_files_at_checkin()
49
+ assert len(files) > 0
50
+ names = [f.name for f in files]
51
+ assert any("README" in n or "index" in n or "utils" in n for n in names)
52
+
53
+ def test_get_file_content(self, repo_path):
54
+ with FossilReader(repo_path) as reader:
55
+ files = reader.get_files_at_checkin()
56
+ if files:
57
+ content = reader.get_file_content(files[0].uuid)
58
+ assert len(content) > 0
59
+
60
+ def test_get_wiki_pages(self, repo_path):
61
+ with FossilReader(repo_path) as reader:
62
+ pages = reader.get_wiki_pages()
63
+ assert len(pages) >= 2
64
+ names = [p.name for p in pages]
65
+ assert "Home" in names
66
+
67
+ def test_get_wiki_page_content(self, repo_path):
68
+ with FossilReader(repo_path) as reader:
69
+ page = reader.get_wiki_page("Home")
70
+ assert page is not None
71
+ assert len(page.content) > 0
72
+
73
+ def test_get_checkin_detail(self, repo_path):
74
+ with FossilReader(repo_path) as reader:
75
+ uuid = reader.get_latest_checkin_uuid()
76
+ detail = reader.get_checkin_detail(uuid[:8])
77
+ assert detail is not None
78
+ assert detail.uuid == uuid
79
+ assert detail.comment
80
+ assert len(detail.files_changed) > 0
81
+
82
+ def test_get_commit_activity(self, repo_path):
83
+ with FossilReader(repo_path) as reader:
84
+ activity = reader.get_commit_activity(weeks=4)
85
+ assert len(activity) == 4
86
+ total = sum(a["count"] for a in activity)
87
+ assert total > 0
88
+
89
+ def test_get_user_activity(self, repo_path):
90
+ with FossilReader(repo_path) as reader:
91
+ activity = reader.get_user_activity("ragelink")
92
+ assert activity["checkin_count"] > 0
93
+ assert len(activity["checkins"]) > 0
94
+
95
+
96
+# --- Helper function tests ---
97
+
98
+
99
+class TestExtractWikiContent:
100
+ def test_basic_extraction(self):
101
+ artifact = "D 2026-01-01T00:00:00\nL TestPage\nU user\nW 5\nhello\nZ abc123"
102
+ assert _extract_wiki_content(artifact) == "hello"
103
+
104
+ def test_multiline_content(self):
105
+ artifact = "D 2026-01-01T00:00:00\nL Page\nU user\nW 11\nhello\nworld\nZ abc123"
106
+ assert _extract_wiki_content(artifact) == "hello\nworld"
107
+
108
+ def test_empty_content(self):
109
+ artifact = "D 2026-01-01T00:00:00\nL Page\nU user\nW 0\n\nZ abc123"
110
+ assert _extract_wiki_content(artifact) == ""
111
+
112
+ def test_no_w_card(self):
113
+ assert _extract_wiki_content("just some text") == ""
114
+
115
+
116
+class TestApplyFossilDelta:
117
+ def test_copy_command(self):
118
+ source = b"Hello, World!"
119
+ # Delta: output size 5, copy 5 bytes from offset 0
120
+ # This is a simplified test
121
+ result = _apply_fossil_delta(source, b"")
122
+ assert result == source # empty delta returns source
123
+
124
+ def test_empty_delta(self):
125
+ source = b"test content"
126
+ result = _apply_fossil_delta(source, b"")
127
+ asserModel tests ---
128
+
129
+
130
+@pytest.mark.d _make_entry(rid=2, branch="feature", parent_rid=1, rail=1),
131
+ _make_entry(rid=1, parent_rid=0, rail=0),
132
+ ]
133
+ result = _compute_dag_graph(entries)
134
+ # At row index 1 (rid=3), both rail 0 and rail 1 should be active
135
+ # because rail 1 spans from index 0 (rid=4) to index 2 (rid=2)
136
+ # and rail 0 spans from index 1 (rid=3) to index 3 (rid=1)
137
+ active_xs = {line["x"] for line in result[1]["lines"]}
138
+ rail_0_x = 20 + 0 * 16 # 20
139
+ rail_1_x = 20 + 1 * 16 # 36
140
+ _fals2, branch="feature", parent_rid=1 expected filename
141
+ repo = FossilRepository.objects.get(proje# The /data/repos di 0 * 16 # 20
142
+ rail_1_x = 20 + 1 * 16 # 36
143
+ assert rail_0_x in active_xs
144
+ assert rail_1_x in active_xs
145
+
146
+ def test_graph_width_accommodates_rails(self):
147
+ """Graph width should be wide enough for all rails plus padding."""
148
+ entries = [
149
+ _make_entry(rid=3, branch="b2", parent_rid=1, rail=2),
150
+ _make_entry(rid=2, branch="b1", parent_rid=1, rail=1),
151
+ _make_entry(rid=1, parent_rid=0, rail=0),
152
+ ]
153
+ result = _compute_dag_graph(entries)
154
+ # max_rail=2, graph_width = 20 + (2+2)*16 = 84
155
+ assert result[0]["graph_width"] == 84
156
+
157
+ def test_connector_geometry(self):
158
+ """Fork connector left and width should span from the lower rail to the higher rail."""
159
+ entries = [
160
+ _make_entry(rid=2, branch="feature", parent_rid=1, rail=2), # fork from rail 0
161
+ _make_entry(rid=1, parent_rid=0, rail=0),
162
+ ]
163
+ result = _compute_dag_graph(entries)
164
+
--- a/fossil/tests.py
+++ b/fossil/tests.py
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/tests.py
+++ b/fossil/tests.py
@@ -0,0 +1,164 @@
1 import shutifrom unittest.moctil
2 from import UTC, datetime
3 from pathlib import Path
4
5 import pytest
6
7 from .models impossilReader, Time
8 # --- Reader tests ---
9
10
11 @pytest.mark.django_db
12 class TestFossilReader:
13 @pytest.fixture
14 def repo_path(self, tmp_path):
15 src = Path("/tmp/fossil-setup/frontend-app.fossil")
16 if not src.exists():
17 pytest.skip("Test fossil repo not available")
18 dest = tmp_path / "test.fossil"
19 shutil.copy2(src, dest)
20 return dest
21
22 def test_get_metadata(self, repo_path):
23 with FossilReader(repo_path) as reader:
24 meta = reader.get_metadata()
25 assert meta.checkin_count >= 2
26
27 def test_get_timeline(self, repo_path):
28 with FossilReader(repo_path) as reader:
29 entries = reader.get_timeline(limit=10)
30 assert len(entries) > 0
31 assert entries[0].uuid
32 assert entries[0].user
33
34 def test_get_timeline_filter_by_type(self, repo_path):
35 with FossilReader(repo_path) as reader:
36 checkins = reader.get_timeline(limit=10, event_type="ci")
37 for e in checkins:
38 assert e.event_type == "ci"
39
40 def test_get_latest_checkin_uuid(self, repo_path):
41 with FossilReader(repo_path) as reader:
42 uuid = reader.get_latest_checkin_uuid()
43 assert uuid is not None
44 assert len(uuid) > 10
45
46 def test_get_files_at_checkin(self, repo_path):
47 with FossilReader(repo_path) as reader:
48 files = reader.get_files_at_checkin()
49 assert len(files) > 0
50 names = [f.name for f in files]
51 assert any("README" in n or "index" in n or "utils" in n for n in names)
52
53 def test_get_file_content(self, repo_path):
54 with FossilReader(repo_path) as reader:
55 files = reader.get_files_at_checkin()
56 if files:
57 content = reader.get_file_content(files[0].uuid)
58 assert len(content) > 0
59
60 def test_get_wiki_pages(self, repo_path):
61 with FossilReader(repo_path) as reader:
62 pages = reader.get_wiki_pages()
63 assert len(pages) >= 2
64 names = [p.name for p in pages]
65 assert "Home" in names
66
67 def test_get_wiki_page_content(self, repo_path):
68 with FossilReader(repo_path) as reader:
69 page = reader.get_wiki_page("Home")
70 assert page is not None
71 assert len(page.content) > 0
72
73 def test_get_checkin_detail(self, repo_path):
74 with FossilReader(repo_path) as reader:
75 uuid = reader.get_latest_checkin_uuid()
76 detail = reader.get_checkin_detail(uuid[:8])
77 assert detail is not None
78 assert detail.uuid == uuid
79 assert detail.comment
80 assert len(detail.files_changed) > 0
81
82 def test_get_commit_activity(self, repo_path):
83 with FossilReader(repo_path) as reader:
84 activity = reader.get_commit_activity(weeks=4)
85 assert len(activity) == 4
86 total = sum(a["count"] for a in activity)
87 assert total > 0
88
89 def test_get_user_activity(self, repo_path):
90 with FossilReader(repo_path) as reader:
91 activity = reader.get_user_activity("ragelink")
92 assert activity["checkin_count"] > 0
93 assert len(activity["checkins"]) > 0
94
95
96 # --- Helper function tests ---
97
98
99 class TestExtractWikiContent:
100 def test_basic_extraction(self):
101 artifact = "D 2026-01-01T00:00:00\nL TestPage\nU user\nW 5\nhello\nZ abc123"
102 assert _extract_wiki_content(artifact) == "hello"
103
104 def test_multiline_content(self):
105 artifact = "D 2026-01-01T00:00:00\nL Page\nU user\nW 11\nhello\nworld\nZ abc123"
106 assert _extract_wiki_content(artifact) == "hello\nworld"
107
108 def test_empty_content(self):
109 artifact = "D 2026-01-01T00:00:00\nL Page\nU user\nW 0\n\nZ abc123"
110 assert _extract_wiki_content(artifact) == ""
111
112 def test_no_w_card(self):
113 assert _extract_wiki_content("just some text") == ""
114
115
116 class TestApplyFossilDelta:
117 def test_copy_command(self):
118 source = b"Hello, World!"
119 # Delta: output size 5, copy 5 bytes from offset 0
120 # This is a simplified test
121 result = _apply_fossil_delta(source, b"")
122 assert result == source # empty delta returns source
123
124 def test_empty_delta(self):
125 source = b"test content"
126 result = _apply_fossil_delta(source, b"")
127 asserModel tests ---
128
129
130 @pytest.mark.d _make_entry(rid=2, branch="feature", parent_rid=1, rail=1),
131 _make_entry(rid=1, parent_rid=0, rail=0),
132 ]
133 result = _compute_dag_graph(entries)
134 # At row index 1 (rid=3), both rail 0 and rail 1 should be active
135 # because rail 1 spans from index 0 (rid=4) to index 2 (rid=2)
136 # and rail 0 spans from index 1 (rid=3) to index 3 (rid=1)
137 active_xs = {line["x"] for line in result[1]["lines"]}
138 rail_0_x = 20 + 0 * 16 # 20
139 rail_1_x = 20 + 1 * 16 # 36
140 _fals2, branch="feature", parent_rid=1 expected filename
141 repo = FossilRepository.objects.get(proje# The /data/repos di 0 * 16 # 20
142 rail_1_x = 20 + 1 * 16 # 36
143 assert rail_0_x in active_xs
144 assert rail_1_x in active_xs
145
146 def test_graph_width_accommodates_rails(self):
147 """Graph width should be wide enough for all rails plus padding."""
148 entries = [
149 _make_entry(rid=3, branch="b2", parent_rid=1, rail=2),
150 _make_entry(rid=2, branch="b1", parent_rid=1, rail=1),
151 _make_entry(rid=1, parent_rid=0, rail=0),
152 ]
153 result = _compute_dag_graph(entries)
154 # max_rail=2, graph_width = 20 + (2+2)*16 = 84
155 assert result[0]["graph_width"] == 84
156
157 def test_connector_geometry(self):
158 """Fork connector left and width should span from the lower rail to the higher rail."""
159 entries = [
160 _make_entry(rid=2, branch="feature", parent_rid=1, rail=2), # fork from rail 0
161 _make_entry(rid=1, parent_rid=0, rail=0),
162 ]
163 result = _compute_dag_graph(entries)
164
+11 -3
--- fossil/views.py
+++ fossil/views.py
@@ -421,21 +421,26 @@
421421
project, fossil_repo, reader = _get_repo_and_reader(slug)
422422
423423
status_filter = request.GET.get("status", "")
424424
search = request.GET.get("search", "").strip()
425425
page = int(request.GET.get("page", "1"))
426
- per_page = 50
426
+ per_page = int(request.GET.get("per_page", "50"))
427
+ per_page = per_page if per_page in (25, 50, 100) else 50
427428
428429
with reader:
429
- tickets = reader.get_tickets(status=status_filter or None, limit=500)
430
+ tickets = reader.get_tickets(status=status_filter or None, limit=1000)
430431
431432
if search:
432433
tickets = [t for t in tickets if search.lower() in t.title.lower()]
433434
434435
total = len(tickets)
436
+ import math
437
+
438
+ total_pages = max(1, math.ceil(total / per_page))
439
+ page = min(page, total_pages)
435440
tickets = tickets[(page - 1) * per_page : page * per_page]
436
- has_next = page * per_page < total
441
+ has_next = page < total_pages
437442
has_prev = page > 1
438443
439444
if request.headers.get("HX-Request"):
440445
return render(request, "fossil/partials/ticket_table.html", {"tickets": tickets, "project": project})
441446
@@ -447,13 +452,16 @@
447452
"fossil_repo": fossil_repo,
448453
"tickets": tickets,
449454
"status_filter": status_filter,
450455
"search": search,
451456
"page": page,
457
+ "per_page": per_page,
458
+ "per_page_options": [25, 50, 100],
452459
"has_next": has_next,
453460
"has_prev": has_prev,
454461
"total": total,
462
+ "total_pages": total_pages,
455463
"active_tab": "tickets",
456464
},
457465
)
458466
459467
460468
--- fossil/views.py
+++ fossil/views.py
@@ -421,21 +421,26 @@
421 project, fossil_repo, reader = _get_repo_and_reader(slug)
422
423 status_filter = request.GET.get("status", "")
424 search = request.GET.get("search", "").strip()
425 page = int(request.GET.get("page", "1"))
426 per_page = 50
 
427
428 with reader:
429 tickets = reader.get_tickets(status=status_filter or None, limit=500)
430
431 if search:
432 tickets = [t for t in tickets if search.lower() in t.title.lower()]
433
434 total = len(tickets)
 
 
 
 
435 tickets = tickets[(page - 1) * per_page : page * per_page]
436 has_next = page * per_page < total
437 has_prev = page > 1
438
439 if request.headers.get("HX-Request"):
440 return render(request, "fossil/partials/ticket_table.html", {"tickets": tickets, "project": project})
441
@@ -447,13 +452,16 @@
447 "fossil_repo": fossil_repo,
448 "tickets": tickets,
449 "status_filter": status_filter,
450 "search": search,
451 "page": page,
 
 
452 "has_next": has_next,
453 "has_prev": has_prev,
454 "total": total,
 
455 "active_tab": "tickets",
456 },
457 )
458
459
460
--- fossil/views.py
+++ fossil/views.py
@@ -421,21 +421,26 @@
421 project, fossil_repo, reader = _get_repo_and_reader(slug)
422
423 status_filter = request.GET.get("status", "")
424 search = request.GET.get("search", "").strip()
425 page = int(request.GET.get("page", "1"))
426 per_page = int(request.GET.get("per_page", "50"))
427 per_page = per_page if per_page in (25, 50, 100) else 50
428
429 with reader:
430 tickets = reader.get_tickets(status=status_filter or None, limit=1000)
431
432 if search:
433 tickets = [t for t in tickets if search.lower() in t.title.lower()]
434
435 total = len(tickets)
436 import math
437
438 total_pages = max(1, math.ceil(total / per_page))
439 page = min(page, total_pages)
440 tickets = tickets[(page - 1) * per_page : page * per_page]
441 has_next = page < total_pages
442 has_prev = page > 1
443
444 if request.headers.get("HX-Request"):
445 return render(request, "fossil/partials/ticket_table.html", {"tickets": tickets, "project": project})
446
@@ -447,13 +452,16 @@
452 "fossil_repo": fossil_repo,
453 "tickets": tickets,
454 "status_filter": status_filter,
455 "search": search,
456 "page": page,
457 "per_page": per_page,
458 "per_page_options": [25, 50, 100],
459 "has_next": has_next,
460 "has_prev": has_prev,
461 "total": total,
462 "total_pages": total_pages,
463 "active_tab": "tickets",
464 },
465 )
466
467
468
--- templates/fossil/ticket_list.html
+++ templates/fossil/ticket_list.html
@@ -34,22 +34,29 @@
3434
</div>
3535
</div>
3636
3737
{% include "fossil/partials/ticket_table.html" %}
3838
39
-{% if has_prev or has_next %}
4039
<div class="mt-4 flex items-center justify-between text-sm">
41
- <span class="text-gray-500">{{ total }} ticket{{ total|pluralize }}</span>
40
+ <div class="flex items-center gap-3">
41
+ <span class="text-gray-500">{{ total }} ticket{{ total|pluralize }}</span>
42
+ <div class="flex items-center gap-1 text-xs">
43
+ <span class="text-gray-600">Show:</span>
44
+ {% for size in per_page_options %}
45
+ <a href="{% url 'fossil:tickets' slug=project.slug %}?per_page={{ size }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
46
+ class="px-1.5 py-0.5 rounded {% if size == per_page %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">{{ size }}</a>
47
+ {% endfor %}
48
+ </div>
49
+ </div>
4250
<div class="flex items-center gap-2">
4351
{% if has_prev %}
44
- <a href="{% url 'fossil:tickets' slug=project.slug %}?page={{ page|add:"-1" }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
45
- class="rounded-md bg-gray-800 px-3 py-1.5 text-gray-400 hover:text-white border border-gray-700">&larr; Prev</a>
52
+ <a href="{% url 'fossil:tickets' slug=project.slug %}?page={{ page|add:"-1" }}&per_page={{ per_page }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
53
+ class="rounded-md bg-gray-800 px-3 py-1.5 text-gray-400 hover:text-white border border-gray-700">&larr;</a>
4654
{% endif %}
47
- <span class="text-gray-500">Page {{ page }}</span>
55
+ <span class="text-gray-500">{{ page }} / {{ total_pages }}</span>
4856
{% if has_next %}
49
- <a href="{% url 'fossil:tickets' slug=project.slug %}?page={{ page|add:"1" }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
50
- class="rounded-md bg-gray-800 px-3 py-1.5 text-gray-400 hover:text-white border border-gray-700">Next &rarr;</a>
57
+ <a href="{% url 'fossil:tickets' slug=project.slug %}?page={{ page|add:"1" }}&per_page={{ per_page }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
58
+ class="rounded-md bg-gray-800 px-3 py-1.5 text-gray-400 hover:text-white border border-gray-700">&rarr;</a>
5159
{% endif %}
5260
</div>
5361
</div>
54
-{% endif %}
5562
{% endblock %}
5663
--- templates/fossil/ticket_list.html
+++ templates/fossil/ticket_list.html
@@ -34,22 +34,29 @@
34 </div>
35 </div>
36
37 {% include "fossil/partials/ticket_table.html" %}
38
39 {% if has_prev or has_next %}
40 <div class="mt-4 flex items-center justify-between text-sm">
41 <span class="text-gray-500">{{ total }} ticket{{ total|pluralize }}</span>
 
 
 
 
 
 
 
 
 
42 <div class="flex items-center gap-2">
43 {% if has_prev %}
44 <a href="{% url 'fossil:tickets' slug=project.slug %}?page={{ page|add:"-1" }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
45 class="rounded-md bg-gray-800 px-3 py-1.5 text-gray-400 hover:text-white border border-gray-700">&larr; Prev</a>
46 {% endif %}
47 <span class="text-gray-500">Page {{ page }}</span>
48 {% if has_next %}
49 <a href="{% url 'fossil:tickets' slug=project.slug %}?page={{ page|add:"1" }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
50 class="rounded-md bg-gray-800 px-3 py-1.5 text-gray-400 hover:text-white border border-gray-700">Next &rarr;</a>
51 {% endif %}
52 </div>
53 </div>
54 {% endif %}
55 {% endblock %}
56
--- templates/fossil/ticket_list.html
+++ templates/fossil/ticket_list.html
@@ -34,22 +34,29 @@
34 </div>
35 </div>
36
37 {% include "fossil/partials/ticket_table.html" %}
38
 
39 <div class="mt-4 flex items-center justify-between text-sm">
40 <div class="flex items-center gap-3">
41 <span class="text-gray-500">{{ total }} ticket{{ total|pluralize }}</span>
42 <div class="flex items-center gap-1 text-xs">
43 <span class="text-gray-600">Show:</span>
44 {% for size in per_page_options %}
45 <a href="{% url 'fossil:tickets' slug=project.slug %}?per_page={{ size }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
46 class="px-1.5 py-0.5 rounded {% if size == per_page %}bg-brand text-white{% else %}text-gray-500 hover:text-white{% endif %}">{{ size }}</a>
47 {% endfor %}
48 </div>
49 </div>
50 <div class="flex items-center gap-2">
51 {% if has_prev %}
52 <a href="{% url 'fossil:tickets' slug=project.slug %}?page={{ page|add:"-1" }}&per_page={{ per_page }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
53 class="rounded-md bg-gray-800 px-3 py-1.5 text-gray-400 hover:text-white border border-gray-700">&larr;</a>
54 {% endif %}
55 <span class="text-gray-500">{{ page }} / {{ total_pages }}</span>
56 {% if has_next %}
57 <a href="{% url 'fossil:tickets' slug=project.slug %}?page={{ page|add:"1" }}&per_page={{ per_page }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
58 class="rounded-md bg-gray-800 px-3 py-1.5 text-gray-400 hover:text-white border border-gray-700">&rarr;</a>
59 {% endif %}
60 </div>
61 </div>
 
62 {% endblock %}
63

Keyboard Shortcuts

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