FossilRepo

Add releases, DAG fork/merge graph, blame age coloring Releases: model (Release + ReleaseAsset), full CRUD views, file upload/ download with atomic download counts, markdown changelog rendering, draft/prerelease support, Releases tab in project nav. 31 tests. DAG graph: fork/merge connectors with horizontal bracket lines, merge commit diamond nodes, leaf indicators (open circles), 8-color rail palette. Reader now carries merge_parent_rids for plink detection. Blame: age-based color gradient from gray-500 (oldest) to brand red (newest), subtle background tint, hover tooltips with full date.

lmata 2026-04-07 07:21 UTC trunk
Commit dbe2a0b51386eaadff4cc86470e4ce34fe2bcdf4be533800a13ad29017b469c0
--- .github/workflows/deploy.yaml
+++ .github/workflows/deploy.yaml
@@ -6,62 +6,29 @@
66
paths-ignore:
77
- 'docs/**'
88
- 'mkdocs.yml'
99
- '*.md'
1010
11
-env:
12
- AWS_REGION: ${{ secrets.AWS_REGION }}
13
- ECR_REPO: fossilrepo
14
- INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }}
15
-
1611
jobs:
1712
ci:
1813
uses: ./.github/workflows/ci.yaml
1914
2015
deploy:
2116
needs: [ci]
2217
runs-on: ubuntu-latest
23
- permissions:
24
- id-token: write
25
- contents: read
2618
2719
steps:
28
- - uses: actions/checkout@v4
29
-
3020
- uses: aws-actions/configure-aws-credentials@v4
3121
with:
3222
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
3323
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
34
- aws-region: ${{ env.AWS_REGION }}
35
-
36
- - uses: aws-actions/amazon-ecr-login@v2
37
- id: ecr
38
-
39
- - uses: docker/setup-qemu-action@v3
40
-
41
- - uses: docker/setup-buildx-action@v3
42
-
43
- - name: Build and push image (ARM64)
44
- env:
45
- ECR_REGISTRY: ${{ steps.ecr.outputs.registry }}
46
- IMAGE_TAG: ${{ github.sha }}
47
- run: |
48
- docker buildx build \
49
- --platform linux/arm64 \
50
- --push \
51
- -t $ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG \
52
- -t $ECR_REGISTRY/$ECR_REPO:latest \
53
- .
54
-
55
- - name: Deploy to EC2 via SSM
24
+ aws-region: ${{ secrets.AWS_REGION }}
25
+
26
+ - name: Deploy via SSM
5627
run: |
5728
aws ssm send-command \
58
- --instance-ids "$INSTANCE_ID" \
29
+ --instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \
5930
--document-name "AWS-RunShellScript" \
60
- --parameters 'commands=[
61
- "aws ecr get-login-password --region '${{ env.AWS_REGION }}' | docker login --username AWS --password-stdin '${{ steps.ecr.outputs.registry }}'",
62
- "cd /opt/fossilrepo && docker compose pull app && docker compose up -d app",
63
- "sleep 10 && docker compose exec -T app python manage.py migrate --noinput"
64
- ]' \
31
+ --parameters 'commands=["fossilrepo-deploy"]' \
6532
--timeout-seconds 300 \
6633
--output text
67
- echo "Deploy command sent to $INSTANCE_ID"
34
+ echo "Deploy sent"
6835
--- .github/workflows/deploy.yaml
+++ .github/workflows/deploy.yaml
@@ -6,62 +6,29 @@
6 paths-ignore:
7 - 'docs/**'
8 - 'mkdocs.yml'
9 - '*.md'
10
11 env:
12 AWS_REGION: ${{ secrets.AWS_REGION }}
13 ECR_REPO: fossilrepo
14 INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }}
15
16 jobs:
17 ci:
18 uses: ./.github/workflows/ci.yaml
19
20 deploy:
21 needs: [ci]
22 runs-on: ubuntu-latest
23 permissions:
24 id-token: write
25 contents: read
26
27 steps:
28 - uses: actions/checkout@v4
29
30 - uses: aws-actions/configure-aws-credentials@v4
31 with:
32 aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
33 aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
34 aws-region: ${{ env.AWS_REGION }}
35
36 - uses: aws-actions/amazon-ecr-login@v2
37 id: ecr
38
39 - uses: docker/setup-qemu-action@v3
40
41 - uses: docker/setup-buildx-action@v3
42
43 - name: Build and push image (ARM64)
44 env:
45 ECR_REGISTRY: ${{ steps.ecr.outputs.registry }}
46 IMAGE_TAG: ${{ github.sha }}
47 run: |
48 docker buildx build \
49 --platform linux/arm64 \
50 --push \
51 -t $ECR_REGISTRY/$ECR_REPO:$IMAGE_TAG \
52 -t $ECR_REGISTRY/$ECR_REPO:latest \
53 .
54
55 - name: Deploy to EC2 via SSM
56 run: |
57 aws ssm send-command \
58 --instance-ids "$INSTANCE_ID" \
59 --document-name "AWS-RunShellScript" \
60 --parameters 'commands=[
61 "aws ecr get-login-password --region '${{ env.AWS_REGION }}' | docker login --username AWS --password-stdin '${{ steps.ecr.outputs.registry }}'",
62 "cd /opt/fossilrepo && docker compose pull app && docker compose up -d app",
63 "sleep 10 && docker compose exec -T app python manage.py migrate --noinput"
64 ]' \
65 --timeout-seconds 300 \
66 --output text
67 echo "Deploy command sent to $INSTANCE_ID"
68
--- .github/workflows/deploy.yaml
+++ .github/workflows/deploy.yaml
@@ -6,62 +6,29 @@
6 paths-ignore:
7 - 'docs/**'
8 - 'mkdocs.yml'
9 - '*.md'
10
 
 
 
 
 
11 jobs:
12 ci:
13 uses: ./.github/workflows/ci.yaml
14
15 deploy:
16 needs: [ci]
17 runs-on: ubuntu-latest
 
 
 
18
19 steps:
 
 
20 - uses: aws-actions/configure-aws-credentials@v4
21 with:
22 aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
23 aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
24 aws-region: ${{ secrets.AWS_REGION }}
25
26 - name: Deploy via SSM
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27 run: |
28 aws ssm send-command \
29 --instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \
30 --document-name "AWS-RunShellScript" \
31 --parameters 'commands=["fossilrepo-deploy"]' \
 
 
 
 
32 --timeout-seconds 300 \
33 --output text
34 echo "Deploy sent"
35
--- fossil/admin.py
+++ fossil/admin.py
@@ -2,10 +2,11 @@
22
33
from core.admin import BaseCoreAdmin
44
55
from .models import FossilRepository, FossilSnapshot
66
from .notifications import Notification, ProjectWatch
7
+from .releases import Release, ReleaseAsset
78
from .sync_models import GitMirror, SSHKey, SyncLog
89
from .user_keys import UserSSHKey
910
1011
1112
class FossilSnapshotInline(admin.TabularInline):
@@ -69,12 +70,30 @@
6970
list_display = ("user", "project", "event_filter", "email_enabled", "created_at")
7071
list_filter = ("event_filter", "email_enabled")
7172
search_fields = ("user__username", "project__name")
7273
raw_id_fields = ("user", "project")
7374
75
+
76
+class ReleaseAssetInline(admin.TabularInline):
77
+ model = ReleaseAsset
78
+ extra = 0
79
+
80
+
81
+@admin.register(Release)
82
+class ReleaseAdmin(BaseCoreAdmin):
83
+ list_display = ("tag_name", "name", "repository", "is_prerelease", "is_draft", "published_at")
84
+ list_filter = ("is_prerelease", "is_draft")
85
+ search_fields = ("tag_name", "name")
86
+ inlines = [ReleaseAssetInline]
87
+
88
+
89
+@admin.register(ReleaseAsset)
90
+class ReleaseAssetAdmin(BaseCoreAdmin):
91
+ list_display = ("name", "release", "file_size_bytes", "download_count")
92
+
7493
7594
@admin.register(SyncLog)
7695
class SyncLogAdmin(admin.ModelAdmin):
7796
list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by")
7897
list_filter = ("status", "triggered_by")
7998
search_fields = ("mirror__repository__filename", "message")
8099
raw_id_fields = ("mirror",)
81100
82101
ADDED fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
--- fossil/admin.py
+++ fossil/admin.py
@@ -2,10 +2,11 @@
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import FossilRepository, FossilSnapshot
6 from .notifications import Notification, ProjectWatch
 
7 from .sync_models import GitMirror, SSHKey, SyncLog
8 from .user_keys import UserSSHKey
9
10
11 class FossilSnapshotInline(admin.TabularInline):
@@ -69,12 +70,30 @@
69 list_display = ("user", "project", "event_filter", "email_enabled", "created_at")
70 list_filter = ("event_filter", "email_enabled")
71 search_fields = ("user__username", "project__name")
72 raw_id_fields = ("user", "project")
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
75 @admin.register(SyncLog)
76 class SyncLogAdmin(admin.ModelAdmin):
77 list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by")
78 list_filter = ("status", "triggered_by")
79 search_fields = ("mirror__repository__filename", "message")
80 raw_id_fields = ("mirror",)
81
82 DDED fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
--- fossil/admin.py
+++ fossil/admin.py
@@ -2,10 +2,11 @@
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import FossilRepository, FossilSnapshot
6 from .notifications import Notification, ProjectWatch
7 from .releases import Release, ReleaseAsset
8 from .sync_models import GitMirror, SSHKey, SyncLog
9 from .user_keys import UserSSHKey
10
11
12 class FossilSnapshotInline(admin.TabularInline):
@@ -69,12 +70,30 @@
70 list_display = ("user", "project", "event_filter", "email_enabled", "created_at")
71 list_filter = ("event_filter", "email_enabled")
72 search_fields = ("user__username", "project__name")
73 raw_id_fields = ("user", "project")
74
75
76 class ReleaseAssetInline(admin.TabularInline):
77 model = ReleaseAsset
78 extra = 0
79
80
81 @admin.register(Release)
82 class ReleaseAdmin(BaseCoreAdmin):
83 list_display = ("tag_name", "name", "repository", "is_prerelease", "is_draft", "published_at")
84 list_filter = ("is_prerelease", "is_draft")
85 search_fields = ("tag_name", "name")
86 inlines = [ReleaseAssetInline]
87
88
89 @admin.register(ReleaseAsset)
90 class ReleaseAssetAdmin(BaseCoreAdmin):
91 list_display = ("name", "release", "file_size_bytes", "download_count")
92
93
94 @admin.register(SyncLog)
95 class SyncLogAdmin(admin.ModelAdmin):
96 list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by")
97 list_filter = ("status", "triggered_by")
98 search_fields = ("mirror__repository__filename", "message")
99 raw_id_fields = ("mirror",)
100
101 DDED fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
--- a/fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
+++ b/fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
@@ -0,0 +1,337 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 07:16
2
+
3
+import django.db.models.deletion
4
+import simple_history.models
5
+from django.conf import settings
6
+from django.db import migrations, models
7
+
8
+
9
+class Migration(migrations.Migration):
10
+ dependencies = [
11
+ ("fossil", "0005_alter_gitmirror_auth_credential_and_more"),
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="HistoricalRelease",
18
+ fields=[
19
+ (
20
+ "id",
21
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
22
+ ),
23
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
24
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
25
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
26
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
27
+ ("tag_name", models.CharField(max_length=200)),
28
+ ("name", models.CharField(max_length=300)),
29
+ ("body", models.TextField(blank=True, default="")),
30
+ ("is_prerelease", models.BooleanField(default=False)),
31
+ ("is_draft", models.BooleanField(default=False)),
32
+ ("published_at", models.DateTimeField(blank=True, null=True)),
33
+ (
34
+ "checkin_uuid",
35
+ models.CharField(blank=True, default="", max_length=64),
36
+ ),
37
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
38
+ ("history_date", models.DateTimeField(db_index=True)),
39
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
40
+ (
41
+ "history_type",
42
+ models.CharField(
43
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
44
+ max_length=1,
45
+ ),
46
+ ),
47
+ (
48
+ "created_by",
49
+ models.ForeignKey(
50
+ blank=True,
51
+ db_constraint=False,
52
+ null=True,
53
+ on_delete=django.db.models.deletion.DO_NOTHING,
54
+ related_name="+",
55
+ to=settings.AUTH_USER_MODEL,
56
+ ),
57
+ ),
58
+ (
59
+ "deleted_by",
60
+ models.ForeignKey(
61
+ blank=True,
62
+ db_constraint=False,
63
+ null=True,
64
+ on_delete=django.db.models.deletion.DO_NOTHING,
65
+ related_name="+",
66
+ to=settings.AUTH_USER_MODEL,
67
+ ),
68
+ ),
69
+ (
70
+ "history_user",
71
+ models.ForeignKey(
72
+ null=True,
73
+ on_delete=django.db.models.deletion.SET_NULL,
74
+ related_name="+",
75
+ to=settings.AUTH_USER_MODEL,
76
+ ),
77
+ ),
78
+ (
79
+ "repository",
80
+ models.ForeignKey(
81
+ blank=True,
82
+ db_constraint=False,
83
+ null=True,
84
+ on_delete=django.db.models.deletion.DO_NOTHING,
85
+ related_name="+",
86
+ to="fossil.fossilrepository",
87
+ ),
88
+ ),
89
+ (
90
+ "updated_by",
91
+ models.ForeignKey(
92
+ blank=True,
93
+ db_constraint=False,
94
+ null=True,
95
+ on_delete=django.db.models.deletion.DO_NOTHING,
96
+ related_name="+",
97
+ to=settings.AUTH_USER_MODEL,
98
+ ),
99
+ ),
100
+ ],
101
+ options={
102
+ "verbose_name": "historical release",
103
+ "verbose_name_plural": "historical releases",
104
+ "ordering": ("-history_date", "-history_id"),
105
+ "get_latest_by": ("history_date", "history_id"),
106
+ },
107
+ bases=(simple_history.models.HistoricalChanges, models.Model),
108
+ ),
109
+ migrations.CreateModel(
110
+ name="Release",
111
+ fields=[
112
+ (
113
+ "id",
114
+ models.BigAutoField(
115
+ auto_created=True,
116
+ primary_key=True,
117
+ serialize=False,
118
+ verbose_name="ID",
119
+ ),
120
+ ),
121
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
122
+ ("created_at", models.DateTimeField(auto_now_add=True)),
123
+ ("updated_at", models.DateTimeField(auto_now=True)),
124
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
125
+ ("tag_name", models.CharField(max_length=200)),
126
+ ("name", models.CharField(max_length=300)),
127
+ ("body", models.TextField(blank=True, default="")),
128
+ ("is_prerelease", models.BooleanField(default=False)),
129
+ ("is_draft", models.BooleanField(default=False)),
130
+ ("published_at", models.DateTimeField(blank=True, null=True)),
131
+ (
132
+ "checkin_uuid",
133
+ models.CharField(blank=True, default="", max_length=64),
134
+ ),
135
+ (
136
+ "created_by",
137
+ models.ForeignKey(
138
+ blank=True,
139
+ null=True,
140
+ on_delete=django.db.models.deletion.SET_NULL,
141
+ related_name="+",
142
+ to=settings.AUTH_USER_MODEL,
143
+ ),
144
+ ),
145
+ (
146
+ "deleted_by",
147
+ models.ForeignKey(
148
+ blank=True,
149
+ null=True,
150
+ on_delete=django.db.models.deletion.SET_NULL,
151
+ related_name="+",
152
+ to=settings.AUTH_USER_MODEL,
153
+ ),
154
+ ),
155
+ (
156
+ "repository",
157
+ models.ForeignKey(
158
+ on_delete=django.db.models.deletion.CASCADE,
159
+ related_name="releases",
160
+ to="fossil.fossilrepository",
161
+ ),
162
+ ),
163
+ (
164
+ "updated_by",
165
+ models.ForeignKey(
166
+ blank=True,
167
+ null=True,
168
+ on_delete=django.db.models.deletion.SET_NULL,
169
+ related_name="+",
170
+ to=settings.AUTH_USER_MODEL,
171
+ ),
172
+ ),
173
+ ],
174
+ options={
175
+ "ordering": ["-published_at", "-created_at"],
176
+ "unique_together": {("repository", "tag_name")},
177
+ },
178
+ ),
179
+ migrations.CreateModel(
180
+ name="HistoricalReleaseAsset",
181
+ fields=[
182
+ (
183
+ "id",
184
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
185
+ ),
186
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
187
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
188
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
189
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
190
+ ("name", models.CharField(max_length=300)),
191
+ ("file", models.TextField(max_length=100)),
192
+ ("file_size_bytes", models.BigIntegerField(default=0)),
193
+ (
194
+ "content_type",
195
+ models.CharField(blank=True, default="", max_length=100),
196
+ ),
197
+ ("download_count", models.PositiveIntegerField(default=0)),
198
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
199
+ ("history_date", models.DateTimeField(db_index=True)),
200
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
201
+ (
202
+ "history_type",
203
+ models.CharField(
204
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
205
+ max_length=1,
206
+ ),
207
+ ),
208
+ (
209
+ "created_by",
210
+ models.ForeignKey(
211
+ blank=True,
212
+ db_constraint=False,
213
+ null=True,
214
+ on_delete=django.db.models.deletion.DO_NOTHING,
215
+ related_name="+",
216
+ to=settings.AUTH_USER_MODEL,
217
+ ),
218
+ ),
219
+ (
220
+ "deleted_by",
221
+ models.ForeignKey(
222
+ blank=True,
223
+ db_constraint=False,
224
+ null=True,
225
+ on_delete=django.db.models.deletion.DO_NOTHING,
226
+ related_name="+",
227
+ to=settings.AUTH_USER_MODEL,
228
+ ),
229
+ ),
230
+ (
231
+ "history_user",
232
+ models.ForeignKey(
233
+ null=True,
234
+ on_delete=django.db.models.deletion.SET_NULL,
235
+ related_name="+",
236
+ to=settings.AUTH_USER_MODEL,
237
+ ),
238
+ ),
239
+ (
240
+ "updated_by",
241
+ models.ForeignKey(
242
+ blank=True,
243
+ db_constraint=False,
244
+ null=True,
245
+ on_delete=django.db.models.deletion.DO_NOTHING,
246
+ related_name="+",
247
+ to=settings.AUTH_USER_MODEL,
248
+ ),
249
+ ),
250
+ (
251
+ "release",
252
+ models.ForeignKey(
253
+ blank=True,
254
+ db_constraint=False,
255
+ null=True,
256
+ on_delete=django.db.models.deletion.DO_NOTHING,
257
+ related_name="+",
258
+ to="fossil.release",
259
+ ),
260
+ ),
261
+ ],
262
+ options={
263
+ "verbose_name": "historical release asset",
264
+ "verbose_name_plural": "historical release assets",
265
+ "ordering": ("-history_date", "-history_id"),
266
+ "get_latest_by": ("history_date", "history_id"),
267
+ },
268
+ bases=(simple_history.models.HistoricalChanges, models.Model),
269
+ ),
270
+ migrations.CreateModel(
271
+ name="ReleaseAsset",
272
+ fields=[
273
+ (
274
+ "id",
275
+ models.BigAutoField(
276
+ auto_created=True,
277
+ primary_key=True,
278
+ serialize=False,
279
+ verbose_name="ID",
280
+ ),
281
+ ),
282
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
283
+ ("created_at", models.DateTimeField(auto_now_add=True)),
284
+ ("updated_at", models.DateTimeField(auto_now=True)),
285
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
286
+ ("name", models.CharField(max_length=300)),
287
+ ("file", models.FileField(upload_to="release_assets/%Y/%m/")),
288
+ ("file_size_bytes", models.BigIntegerField(default=0)),
289
+ (
290
+ "content_type",
291
+ models.CharField(blank=True, default="", max_length=100),
292
+ ),
293
+ ("download_count", models.PositiveIntegerField(default=0)),
294
+ (
295
+ "created_by",
296
+ models.ForeignKey(
297
+ blank=True,
298
+ null=True,
299
+ on_delete=django.db.models.deletion.SET_NULL,
300
+ related_name="+",
301
+ to=settings.AUTH_USER_MODEL,
302
+ ),
303
+ ),
304
+ (
305
+ "deleted_by",
306
+ models.ForeignKey(
307
+ blank=True,
308
+ null=True,
309
+ on_delete=django.db.models.deletion.SET_NULL,
310
+ related_name="+",
311
+ to=settings.AUTH_USER_MODEL,
312
+ ),
313
+ ),
314
+ (
315
+ "release",
316
+ models.ForeignKey(
317
+ on_delete=django.db.models.deletion.CASCADE,
318
+ related_name="assets",
319
+ to="fossil.release",
320
+ ),
321
+ ),
322
+ (
323
+ "updated_by",
324
+ models.ForeignKey(
325
+ blank=True,
326
+ null=True,
327
+ on_delete=django.db.models.deletion.SET_NULL,
328
+ related_name="+",
329
+ to=settings.AUTH_USER_MODEL,
330
+ ),
331
+ ),
332
+ ],
333
+ options={
334
+ "ordering": ["name"],
335
+ },
336
+ ),
337
+ ]
--- a/fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
+++ b/fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
+++ b/fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
@@ -0,0 +1,337 @@
1 # Generated by Django 5.2.12 on 2026-04-07 07:16
2
3 import django.db.models.deletion
4 import simple_history.models
5 from django.conf import settings
6 from django.db import migrations, models
7
8
9 class Migration(migrations.Migration):
10 dependencies = [
11 ("fossil", "0005_alter_gitmirror_auth_credential_and_more"),
12 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 ]
14
15 operations = [
16 migrations.CreateModel(
17 name="HistoricalRelease",
18 fields=[
19 (
20 "id",
21 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
22 ),
23 ("version", models.PositiveIntegerField(default=1, editable=False)),
24 ("created_at", models.DateTimeField(blank=True, editable=False)),
25 ("updated_at", models.DateTimeField(blank=True, editable=False)),
26 ("deleted_at", models.DateTimeField(blank=True, null=True)),
27 ("tag_name", models.CharField(max_length=200)),
28 ("name", models.CharField(max_length=300)),
29 ("body", models.TextField(blank=True, default="")),
30 ("is_prerelease", models.BooleanField(default=False)),
31 ("is_draft", models.BooleanField(default=False)),
32 ("published_at", models.DateTimeField(blank=True, null=True)),
33 (
34 "checkin_uuid",
35 models.CharField(blank=True, default="", max_length=64),
36 ),
37 ("history_id", models.AutoField(primary_key=True, serialize=False)),
38 ("history_date", models.DateTimeField(db_index=True)),
39 ("history_change_reason", models.CharField(max_length=100, null=True)),
40 (
41 "history_type",
42 models.CharField(
43 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
44 max_length=1,
45 ),
46 ),
47 (
48 "created_by",
49 models.ForeignKey(
50 blank=True,
51 db_constraint=False,
52 null=True,
53 on_delete=django.db.models.deletion.DO_NOTHING,
54 related_name="+",
55 to=settings.AUTH_USER_MODEL,
56 ),
57 ),
58 (
59 "deleted_by",
60 models.ForeignKey(
61 blank=True,
62 db_constraint=False,
63 null=True,
64 on_delete=django.db.models.deletion.DO_NOTHING,
65 related_name="+",
66 to=settings.AUTH_USER_MODEL,
67 ),
68 ),
69 (
70 "history_user",
71 models.ForeignKey(
72 null=True,
73 on_delete=django.db.models.deletion.SET_NULL,
74 related_name="+",
75 to=settings.AUTH_USER_MODEL,
76 ),
77 ),
78 (
79 "repository",
80 models.ForeignKey(
81 blank=True,
82 db_constraint=False,
83 null=True,
84 on_delete=django.db.models.deletion.DO_NOTHING,
85 related_name="+",
86 to="fossil.fossilrepository",
87 ),
88 ),
89 (
90 "updated_by",
91 models.ForeignKey(
92 blank=True,
93 db_constraint=False,
94 null=True,
95 on_delete=django.db.models.deletion.DO_NOTHING,
96 related_name="+",
97 to=settings.AUTH_USER_MODEL,
98 ),
99 ),
100 ],
101 options={
102 "verbose_name": "historical release",
103 "verbose_name_plural": "historical releases",
104 "ordering": ("-history_date", "-history_id"),
105 "get_latest_by": ("history_date", "history_id"),
106 },
107 bases=(simple_history.models.HistoricalChanges, models.Model),
108 ),
109 migrations.CreateModel(
110 name="Release",
111 fields=[
112 (
113 "id",
114 models.BigAutoField(
115 auto_created=True,
116 primary_key=True,
117 serialize=False,
118 verbose_name="ID",
119 ),
120 ),
121 ("version", models.PositiveIntegerField(default=1, editable=False)),
122 ("created_at", models.DateTimeField(auto_now_add=True)),
123 ("updated_at", models.DateTimeField(auto_now=True)),
124 ("deleted_at", models.DateTimeField(blank=True, null=True)),
125 ("tag_name", models.CharField(max_length=200)),
126 ("name", models.CharField(max_length=300)),
127 ("body", models.TextField(blank=True, default="")),
128 ("is_prerelease", models.BooleanField(default=False)),
129 ("is_draft", models.BooleanField(default=False)),
130 ("published_at", models.DateTimeField(blank=True, null=True)),
131 (
132 "checkin_uuid",
133 models.CharField(blank=True, default="", max_length=64),
134 ),
135 (
136 "created_by",
137 models.ForeignKey(
138 blank=True,
139 null=True,
140 on_delete=django.db.models.deletion.SET_NULL,
141 related_name="+",
142 to=settings.AUTH_USER_MODEL,
143 ),
144 ),
145 (
146 "deleted_by",
147 models.ForeignKey(
148 blank=True,
149 null=True,
150 on_delete=django.db.models.deletion.SET_NULL,
151 related_name="+",
152 to=settings.AUTH_USER_MODEL,
153 ),
154 ),
155 (
156 "repository",
157 models.ForeignKey(
158 on_delete=django.db.models.deletion.CASCADE,
159 related_name="releases",
160 to="fossil.fossilrepository",
161 ),
162 ),
163 (
164 "updated_by",
165 models.ForeignKey(
166 blank=True,
167 null=True,
168 on_delete=django.db.models.deletion.SET_NULL,
169 related_name="+",
170 to=settings.AUTH_USER_MODEL,
171 ),
172 ),
173 ],
174 options={
175 "ordering": ["-published_at", "-created_at"],
176 "unique_together": {("repository", "tag_name")},
177 },
178 ),
179 migrations.CreateModel(
180 name="HistoricalReleaseAsset",
181 fields=[
182 (
183 "id",
184 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
185 ),
186 ("version", models.PositiveIntegerField(default=1, editable=False)),
187 ("created_at", models.DateTimeField(blank=True, editable=False)),
188 ("updated_at", models.DateTimeField(blank=True, editable=False)),
189 ("deleted_at", models.DateTimeField(blank=True, null=True)),
190 ("name", models.CharField(max_length=300)),
191 ("file", models.TextField(max_length=100)),
192 ("file_size_bytes", models.BigIntegerField(default=0)),
193 (
194 "content_type",
195 models.CharField(blank=True, default="", max_length=100),
196 ),
197 ("download_count", models.PositiveIntegerField(default=0)),
198 ("history_id", models.AutoField(primary_key=True, serialize=False)),
199 ("history_date", models.DateTimeField(db_index=True)),
200 ("history_change_reason", models.CharField(max_length=100, null=True)),
201 (
202 "history_type",
203 models.CharField(
204 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
205 max_length=1,
206 ),
207 ),
208 (
209 "created_by",
210 models.ForeignKey(
211 blank=True,
212 db_constraint=False,
213 null=True,
214 on_delete=django.db.models.deletion.DO_NOTHING,
215 related_name="+",
216 to=settings.AUTH_USER_MODEL,
217 ),
218 ),
219 (
220 "deleted_by",
221 models.ForeignKey(
222 blank=True,
223 db_constraint=False,
224 null=True,
225 on_delete=django.db.models.deletion.DO_NOTHING,
226 related_name="+",
227 to=settings.AUTH_USER_MODEL,
228 ),
229 ),
230 (
231 "history_user",
232 models.ForeignKey(
233 null=True,
234 on_delete=django.db.models.deletion.SET_NULL,
235 related_name="+",
236 to=settings.AUTH_USER_MODEL,
237 ),
238 ),
239 (
240 "updated_by",
241 models.ForeignKey(
242 blank=True,
243 db_constraint=False,
244 null=True,
245 on_delete=django.db.models.deletion.DO_NOTHING,
246 related_name="+",
247 to=settings.AUTH_USER_MODEL,
248 ),
249 ),
250 (
251 "release",
252 models.ForeignKey(
253 blank=True,
254 db_constraint=False,
255 null=True,
256 on_delete=django.db.models.deletion.DO_NOTHING,
257 related_name="+",
258 to="fossil.release",
259 ),
260 ),
261 ],
262 options={
263 "verbose_name": "historical release asset",
264 "verbose_name_plural": "historical release assets",
265 "ordering": ("-history_date", "-history_id"),
266 "get_latest_by": ("history_date", "history_id"),
267 },
268 bases=(simple_history.models.HistoricalChanges, models.Model),
269 ),
270 migrations.CreateModel(
271 name="ReleaseAsset",
272 fields=[
273 (
274 "id",
275 models.BigAutoField(
276 auto_created=True,
277 primary_key=True,
278 serialize=False,
279 verbose_name="ID",
280 ),
281 ),
282 ("version", models.PositiveIntegerField(default=1, editable=False)),
283 ("created_at", models.DateTimeField(auto_now_add=True)),
284 ("updated_at", models.DateTimeField(auto_now=True)),
285 ("deleted_at", models.DateTimeField(blank=True, null=True)),
286 ("name", models.CharField(max_length=300)),
287 ("file", models.FileField(upload_to="release_assets/%Y/%m/")),
288 ("file_size_bytes", models.BigIntegerField(default=0)),
289 (
290 "content_type",
291 models.CharField(blank=True, default="", max_length=100),
292 ),
293 ("download_count", models.PositiveIntegerField(default=0)),
294 (
295 "created_by",
296 models.ForeignKey(
297 blank=True,
298 null=True,
299 on_delete=django.db.models.deletion.SET_NULL,
300 related_name="+",
301 to=settings.AUTH_USER_MODEL,
302 ),
303 ),
304 (
305 "deleted_by",
306 models.ForeignKey(
307 blank=True,
308 null=True,
309 on_delete=django.db.models.deletion.SET_NULL,
310 related_name="+",
311 to=settings.AUTH_USER_MODEL,
312 ),
313 ),
314 (
315 "release",
316 models.ForeignKey(
317 on_delete=django.db.models.deletion.CASCADE,
318 related_name="assets",
319 to="fossil.release",
320 ),
321 ),
322 (
323 "updated_by",
324 models.ForeignKey(
325 blank=True,
326 null=True,
327 on_delete=django.db.models.deletion.SET_NULL,
328 related_name="+",
329 to=settings.AUTH_USER_MODEL,
330 ),
331 ),
332 ],
333 options={
334 "ordering": ["name"],
335 },
336 ),
337 ]
--- fossil/models.py
+++ fossil/models.py
@@ -66,7 +66,8 @@
6666
return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
6767
6868
6969
# Import related models so they're discoverable by Django
7070
from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
71
+from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
7172
from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
7273
from fossil.user_keys import UserSSHKey # noqa: E402, F401
7374
--- fossil/models.py
+++ fossil/models.py
@@ -66,7 +66,8 @@
66 return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
67
68
69 # Import related models so they're discoverable by Django
70 from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
 
71 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
72 from fossil.user_keys import UserSSHKey # noqa: E402, F401
73
--- fossil/models.py
+++ fossil/models.py
@@ -66,7 +66,8 @@
66 return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
67
68
69 # Import related models so they're discoverable by Django
70 from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
71 from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
72 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
73 from fossil.user_keys import UserSSHKey # noqa: E402, F401
74
--- fossil/reader.py
+++ fossil/reader.py
@@ -22,10 +22,11 @@
2222
user: str
2323
comment: str
2424
branch: str = ""
2525
parent_rid: int = 0 # primary parent rid for DAG drawing
2626
is_merge: bool = False # has multiple parents
27
+ merge_parent_rids: list[int] = field(default_factory=list) # non-primary parent rids for merge connectors
2728
rail: int = 0 # column position for DAG graph
2829
2930
3031
@dataclass
3132
class FileEntry:
@@ -543,16 +544,19 @@
543544
branch = br[0].replace("sym-", "", 1)
544545
except sqlite3.OperationalError:
545546
pass
546547
547548
# Get parent info from plink for DAG
549
+ merge_parent_rids = []
548550
if row["type"] == "ci":
549551
try:
550552
parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall()
551553
for p in parents:
552554
if p["isprim"]:
553555
parent_rid = p["pid"]
556
+ else:
557
+ merge_parent_rids.append(p["pid"])
554558
is_merge = len(parents) > 1
555559
except sqlite3.OperationalError:
556560
pass
557561
558562
entries.append(
@@ -564,10 +568,11 @@
564568
user=row["user"] or "",
565569
comment=row["comment"] or "",
566570
branch=branch,
567571
parent_rid=parent_rid,
568572
is_merge=is_merge,
573
+ merge_parent_rids=merge_parent_rids,
569574
)
570575
)
571576
except sqlite3.OperationalError:
572577
pass
573578
574579
575580
ADDED fossil/releases.py
--- fossil/reader.py
+++ fossil/reader.py
@@ -22,10 +22,11 @@
22 user: str
23 comment: str
24 branch: str = ""
25 parent_rid: int = 0 # primary parent rid for DAG drawing
26 is_merge: bool = False # has multiple parents
 
27 rail: int = 0 # column position for DAG graph
28
29
30 @dataclass
31 class FileEntry:
@@ -543,16 +544,19 @@
543 branch = br[0].replace("sym-", "", 1)
544 except sqlite3.OperationalError:
545 pass
546
547 # Get parent info from plink for DAG
 
548 if row["type"] == "ci":
549 try:
550 parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall()
551 for p in parents:
552 if p["isprim"]:
553 parent_rid = p["pid"]
 
 
554 is_merge = len(parents) > 1
555 except sqlite3.OperationalError:
556 pass
557
558 entries.append(
@@ -564,10 +568,11 @@
564 user=row["user"] or "",
565 comment=row["comment"] or "",
566 branch=branch,
567 parent_rid=parent_rid,
568 is_merge=is_merge,
 
569 )
570 )
571 except sqlite3.OperationalError:
572 pass
573
574
575 DDED fossil/releases.py
--- fossil/reader.py
+++ fossil/reader.py
@@ -22,10 +22,11 @@
22 user: str
23 comment: str
24 branch: str = ""
25 parent_rid: int = 0 # primary parent rid for DAG drawing
26 is_merge: bool = False # has multiple parents
27 merge_parent_rids: list[int] = field(default_factory=list) # non-primary parent rids for merge connectors
28 rail: int = 0 # column position for DAG graph
29
30
31 @dataclass
32 class FileEntry:
@@ -543,16 +544,19 @@
544 branch = br[0].replace("sym-", "", 1)
545 except sqlite3.OperationalError:
546 pass
547
548 # Get parent info from plink for DAG
549 merge_parent_rids = []
550 if row["type"] == "ci":
551 try:
552 parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall()
553 for p in parents:
554 if p["isprim"]:
555 parent_rid = p["pid"]
556 else:
557 merge_parent_rids.append(p["pid"])
558 is_merge = len(parents) > 1
559 except sqlite3.OperationalError:
560 pass
561
562 entries.append(
@@ -564,10 +568,11 @@
568 user=row["user"] or "",
569 comment=row["comment"] or "",
570 branch=branch,
571 parent_rid=parent_rid,
572 is_merge=is_merge,
573 merge_parent_rids=merge_parent_rids,
574 )
575 )
576 except sqlite3.OperationalError:
577 pass
578
579
580 DDED fossil/releases.py
--- a/fossil/releases.py
+++ b/fossil/releases.py
@@ -0,0 +1,47 @@
1
+from django.db import models
2
+
3
+from core.models import ActiveManager, Tracking
4
+
5
+
6
+class Release(Tracking):
7
+ """A tagged release for a Fossil repository with changelog and downloadable assets."""
8
+
9
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="releases")
10
+ tag_name = models.CharField(max_length=200) # e.g. "v1.0.0"
11
+ name = models.CharField(max_length=300) # e.g. "Version 1.0.0 -- Initial Release"
12
+ body = models.TextField(blank=True, default="") # Markdown changelog
13
+ is_prerelease = models.BooleanField(default=False)
14
+ is_draft = models.BooleanField(default=False)
15
+ published_at = models.DateTimeField(null=True, blank=True)
16
+ # Link to Fossil checkin if available
17
+ checkin_uuid = models.CharField(max_length=64, blank=True, default="")
18
+
19
+ objects = ActiveManager()
20
+ all_objects = models.Manager()
21
+
22
+ class Meta:
23
+ ordering = ["-published_at", "-created_at"]
24
+ unique_together = [("repository", "tag_name")]
25
+
26
+ def __str__(self):
27
+ return f"{self.tag_name}: {self.name}"
28
+
29
+
30
+class ReleaseAsset(Tracking):
31
+ """A downloadable file attached to a release (binary, tarball, etc.)."""
32
+
33
+ release = models.ForeignKey(Release, on_delete=models.CASCADE, related_name="assets")
34
+ name = models.CharField(max_length=300)
35
+ file = models.FileField(upload_to="release_assets/%Y/%m/")
36
+ file_size_bytes = models.BigIntegerField(default=0)
37
+ content_type = models.CharField(max_length=100, blank=True, default="")
38
+ download_count = models.PositiveIntegerField(default=0)
39
+
40
+ objects = ActiveManager()
41
+ all_objects = models.Manager()
42
+
43
+ class Meta:
44
+ ordering = ["name"]
45
+
46
+ def __str__(self):
47
+ return self.name
--- a/fossil/releases.py
+++ b/fossil/releases.py
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/releases.py
+++ b/fossil/releases.py
@@ -0,0 +1,47 @@
1 from django.db import models
2
3 from core.models import ActiveManager, Tracking
4
5
6 class Release(Tracking):
7 """A tagged release for a Fossil repository with changelog and downloadable assets."""
8
9 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="releases")
10 tag_name = models.CharField(max_length=200) # e.g. "v1.0.0"
11 name = models.CharField(max_length=300) # e.g. "Version 1.0.0 -- Initial Release"
12 body = models.TextField(blank=True, default="") # Markdown changelog
13 is_prerelease = models.BooleanField(default=False)
14 is_draft = models.BooleanField(default=False)
15 published_at = models.DateTimeField(null=True, blank=True)
16 # Link to Fossil checkin if available
17 checkin_uuid = models.CharField(max_length=64, blank=True, default="")
18
19 objects = ActiveManager()
20 all_objects = models.Manager()
21
22 class Meta:
23 ordering = ["-published_at", "-created_at"]
24 unique_together = [("repository", "tag_name")]
25
26 def __str__(self):
27 return f"{self.tag_name}: {self.name}"
28
29
30 class ReleaseAsset(Tracking):
31 """A downloadable file attached to a release (binary, tarball, etc.)."""
32
33 release = models.ForeignKey(Release, on_delete=models.CASCADE, related_name="assets")
34 name = models.CharField(max_length=300)
35 file = models.FileField(upload_to="release_assets/%Y/%m/")
36 file_size_bytes = models.BigIntegerField(default=0)
37 content_type = models.CharField(max_length=100, blank=True, default="")
38 download_count = models.PositiveIntegerField(default=0)
39
40 objects = ActiveManager()
41 all_objects = models.Manager()
42
43 class Meta:
44 ordering = ["name"]
45
46 def __str__(self):
47 return self.name
+163 -1
--- fossil/tests.py
+++ fossil/tests.py
@@ -1,12 +1,14 @@
11
import shutil
2
+from datetime import UTC, datetime
23
from pathlib import Path
34
45
import pytest
56
67
from .models import FossilRepository
7
-from .reader import FossilReader, _apply_fossil_delta, _extract_wiki_content
8
+from .reader import FossilReader, TimelineEntry, _apply_fossil_delta, _extract_wiki_content
9
+from .views import _compute_dag_graph
810
911
# --- Reader tests ---
1012
1113
1214
@pytest.mark.django_db
@@ -125,10 +127,170 @@
125127
def test_empty_delta(self):
126128
source = b"test content"
127129
result = _apply_fossil_delta(source, b"")
128130
assert result == source
129131
132
+
133
+# --- DAG graph computation tests ---
134
+
135
+
136
+def _make_entry(rid, event_type="ci", branch="trunk", parent_rid=0, is_merge=False, merge_parent_rids=None, rail=0):
137
+ """Helper to build a TimelineEntry for DAG tests."""
138
+ return TimelineEntry(
139
+ rid=rid,
140
+ uuid=f"uuid-{rid}",
141
+ event_type=event_type,
142
+ timestamp=datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC),
143
+ user="test",
144
+ comment=f"commit {rid}",
145
+ branch=branch,
146
+ parent_rid=parent_rid,
147
+ is_merge=is_merge,
148
+ merge_parent_rids=merge_parent_rids or [],
149
+ rail=rail,
150
+ )
151
+
152
+
153
+class TestComputeDagGraph:
154
+ def test_empty_entries(self):
155
+ assert _compute_dag_graph([]) == []
156
+
157
+ def test_linear_single_branch(self):
158
+ """Linear history on one rail: no forks, no merges, no connectors."""
159
+ entries = [
160
+ _make_entry(rid=3, parent_rid=2, rail=0),
161
+ _make_entry(rid=2, parent_rid=1, rail=0),
162
+ _make_entry(rid=1, parent_rid=0, rail=0),
163
+ ]
164
+ result = _compute_dag_graph(entries)
165
+ assert len(result) == 3
166
+ for item in result:
167
+ assert item["fork_from"] is None
168
+ assert item["merge_to"] is None
169
+ assert item["is_merge"] is False
170
+ assert item["connectors"] == []
171
+
172
+ def test_linear_leaf_detection(self):
173
+ """First entry (newest) on a rail with no child is a leaf."""
174
+ entries = [
175
+ _make_entry(rid=3, parent_rid=2, rail=0),
176
+ _make_entry(rid=2, parent_rid=1, rail=0),
177
+ _make_entry(rid=1, parent_rid=0, rail=0),
178
+ ]
179
+ result = _compute_dag_graph(entries)
180
+ # rid=3 has no child in this list -> leaf
181
+ assert result[0]["is_leaf"] is True
182
+ # rid=2 has rid=3 as a child on the same rail -> not leaf
183
+ assert result[1]["is_leaf"] is False
184
+ # rid=1 has rid=2 as a child on the same rail -> not leaf
185
+ assert result[2]["is_leaf"] is False
186
+
187
+ def test_fork_detected(self):
188
+ """Branch fork: first entry on rail 1 with parent on rail 0."""
189
+ entries = [
190
+ _make_entry(rid=3, branch="feature", parent_rid=1, rail=1), # first on rail 1, parent on rail 0
191
+ _make_entry(rid=2, parent_rid=1, rail=0),
192
+ _make_entry(rid=1, parent_rid=0, rail=0),
193
+ ]
194
+ result = _compute_dag_graph(entries)
195
+ # rid=3 forks from rail 0
196
+ assert result[0]["fork_from"] == 0
197
+ # rid=3 should have a fork connector
198
+ assert len(result[0]["connectors"]) == 1
199
+ assert result[0]["connectors"][0]["type"] == "fork"
200
+ assert result[0]["connectors"][0]["from_rail"] == 0
201
+ assert result[0]["connectors"][0]["to_rail"] == 1
202
+ # Other entries have no fork/merge
203
+ assert result[1]["fork_from"] is None
204
+ assert result[2]["fork_from"] is None
205
+
206
+ def test_merge_detected(self):
207
+ """Merge commit: entry with merge_parent_rids on a different rail."""
208
+ entries = [
209
+ _make_entry(rid=4, parent_rid=3, rail=0, is_merge=True, merge_parent_rids=[2]), # merge from rail 1
210
+ _make_entry(rid=3, parent_rid=1, rail=0),
211
+ _make_entry(rid=2, branch="feature", parent_rid=1, rail=1), # feature branch
212
+ _make_entry(rid=1, parent_rid=0, rail=0),
213
+ ]
214
+ result = _compute_dag_graph(entries)
215
+ # rid=4 is a merge
216
+ assert result[0]["is_merge"] is True
217
+ assert result[0]["merge_to"] == 0
218
+ # Should have a merge connector
219
+ merge_conns = [c for c in result[0]["connectors"] if c["type"] == "merge"]
220
+ assert len(merge_conns) == 1
221
+ assert merge_conns[0]["from_rail"] == 1
222
+ assert merge_conns[0]["to_rail"] == 0
223
+
224
+ def test_non_checkin_entries_no_dag_data(self):
225
+ """Wiki/ticket/forum entries should not produce fork/merge/leaf data."""
226
+ entries = [
227
+ _make_entry(rid=2, event_type="w", rail=-1, parent_rid=0),
228
+ _make_entry(rid=1, parent_rid=0, rail=0),
229
+ ]
230
+ result = _compute_dag_graph(entries)
231
+ # Wiki entry: no fork/merge, not a leaf (only ci entries can be leaves)
232
+ assert result[0]["fork_from"] is None
233
+ assert result[0]["merge_to"] is None
234
+ assert result[0]["is_leaf"] is False
235
+ assert result[0]["is_merge"] is False
236
+
237
+ def test_rail_colors_present(self):
238
+ """Each line and node should carry a color."""
239
+ entries = [
240
+ _make_entry(rid=2, parent_rid=1, rail=0),
241
+ _make_entry(rid=1, parent_rid=0, rail=0),
242
+ ]
243
+ result = _compute_dag_graph(entries)
244
+ assert result[0]["node_color"] == "#ef4444" # rail 0 = red
245
+ # Active lines should also have color
246
+ for item in result:
247
+ for line in item["lines"]:
248
+ assert "color" in line
249
+
250
+ def test_multiple_rails_active_lines(self):
251
+ """When two branches are active, both rails should appear in lines."""
252
+ entries = [
253
+ _make_entry(rid=4, branch="feature", parent_rid=2, rail=1),
254
+ _make_entry(rid=3, parent_rid=1, rail=0),
255
+ _make_entry(rid=2, branch="feature", parent_rid=1, rail=1),
256
+ _make_entry(rid=1, parent_rid=0, rail=0),
257
+ ]
258
+ result = _compute_dag_graph(entries)
259
+ # At row index 1 (rid=3), both rail 0 and rail 1 should be active
260
+ # because rail 1 spans from index 0 (rid=4) to index 2 (rid=2)
261
+ # and rail 0 spans from index 1 (rid=3) to index 3 (rid=1)
262
+ active_xs = {line["x"] for line in result[1]["lines"]}
263
+ rail_0_x = 20 + 0 * 16 # 20
264
+ rail_1_x = 20 + 1 * 16 # 36
265
+ assert rail_0_x in active_xs
266
+ assert rail_1_x in active_xs
267
+
268
+ def test_graph_width_accommodates_rails(self):
269
+ """Graph width should be wide enough for all rails plus padding."""
270
+ entries = [
271
+ _make_entry(rid=3, branch="b2", parent_rid=1, rail=2),
272
+ _make_entry(rid=2, branch="b1", parent_rid=1, rail=1),
273
+ _make_entry(rid=1, parent_rid=0, rail=0),
274
+ ]
275
+ result = _compute_dag_graph(entries)
276
+ # max_rail=2, graph_width = 20 + (2+2)*16 = 84
277
+ assert result[0]["graph_width"] == 84
278
+
279
+ def test_connector_geometry(self):
280
+ """Fork connector left and width should span from the lower rail to the higher rail."""
281
+ entries = [
282
+ _make_entry(rid=2, branch="feature", parent_rid=1, rail=2), # fork from rail 0
283
+ _make_entry(rid=1, parent_rid=0, rail=0),
284
+ ]
285
+ result = _compute_dag_graph(entries)
286
+ conn = result[0]["connectors"][0]
287
+ rail_0_x = 20 + 0 * 16 # 20
288
+ rail_2_x = 20 + 2 * 16 # 52
289
+ assert conn["left"] == rail_0_x
290
+ assert conn["width"] == rail_2_x - rail_0_x
291
+
130292
131293
# --- Model tests ---
132294
133295
134296
@pytest.mark.django_db
135297
--- fossil/tests.py
+++ fossil/tests.py
@@ -1,12 +1,14 @@
1 import shutil
 
2 from pathlib import Path
3
4 import pytest
5
6 from .models import FossilRepository
7 from .reader import FossilReader, _apply_fossil_delta, _extract_wiki_content
 
8
9 # --- Reader tests ---
10
11
12 @pytest.mark.django_db
@@ -125,10 +127,170 @@
125 def test_empty_delta(self):
126 source = b"test content"
127 result = _apply_fossil_delta(source, b"")
128 assert result == source
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
131 # --- Model tests ---
132
133
134 @pytest.mark.django_db
135
--- fossil/tests.py
+++ fossil/tests.py
@@ -1,12 +1,14 @@
1 import shutil
2 from datetime import UTC, datetime
3 from pathlib import Path
4
5 import pytest
6
7 from .models import FossilRepository
8 from .reader import FossilReader, TimelineEntry, _apply_fossil_delta, _extract_wiki_content
9 from .views import _compute_dag_graph
10
11 # --- Reader tests ---
12
13
14 @pytest.mark.django_db
@@ -125,10 +127,170 @@
127 def test_empty_delta(self):
128 source = b"test content"
129 result = _apply_fossil_delta(source, b"")
130 assert result == source
131
132
133 # --- DAG graph computation tests ---
134
135
136 def _make_entry(rid, event_type="ci", branch="trunk", parent_rid=0, is_merge=False, merge_parent_rids=None, rail=0):
137 """Helper to build a TimelineEntry for DAG tests."""
138 return TimelineEntry(
139 rid=rid,
140 uuid=f"uuid-{rid}",
141 event_type=event_type,
142 timestamp=datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC),
143 user="test",
144 comment=f"commit {rid}",
145 branch=branch,
146 parent_rid=parent_rid,
147 is_merge=is_merge,
148 merge_parent_rids=merge_parent_rids or [],
149 rail=rail,
150 )
151
152
153 class TestComputeDagGraph:
154 def test_empty_entries(self):
155 assert _compute_dag_graph([]) == []
156
157 def test_linear_single_branch(self):
158 """Linear history on one rail: no forks, no merges, no connectors."""
159 entries = [
160 _make_entry(rid=3, parent_rid=2, rail=0),
161 _make_entry(rid=2, parent_rid=1, rail=0),
162 _make_entry(rid=1, parent_rid=0, rail=0),
163 ]
164 result = _compute_dag_graph(entries)
165 assert len(result) == 3
166 for item in result:
167 assert item["fork_from"] is None
168 assert item["merge_to"] is None
169 assert item["is_merge"] is False
170 assert item["connectors"] == []
171
172 def test_linear_leaf_detection(self):
173 """First entry (newest) on a rail with no child is a leaf."""
174 entries = [
175 _make_entry(rid=3, parent_rid=2, rail=0),
176 _make_entry(rid=2, parent_rid=1, rail=0),
177 _make_entry(rid=1, parent_rid=0, rail=0),
178 ]
179 result = _compute_dag_graph(entries)
180 # rid=3 has no child in this list -> leaf
181 assert result[0]["is_leaf"] is True
182 # rid=2 has rid=3 as a child on the same rail -> not leaf
183 assert result[1]["is_leaf"] is False
184 # rid=1 has rid=2 as a child on the same rail -> not leaf
185 assert result[2]["is_leaf"] is False
186
187 def test_fork_detected(self):
188 """Branch fork: first entry on rail 1 with parent on rail 0."""
189 entries = [
190 _make_entry(rid=3, branch="feature", parent_rid=1, rail=1), # first on rail 1, parent on rail 0
191 _make_entry(rid=2, parent_rid=1, rail=0),
192 _make_entry(rid=1, parent_rid=0, rail=0),
193 ]
194 result = _compute_dag_graph(entries)
195 # rid=3 forks from rail 0
196 assert result[0]["fork_from"] == 0
197 # rid=3 should have a fork connector
198 assert len(result[0]["connectors"]) == 1
199 assert result[0]["connectors"][0]["type"] == "fork"
200 assert result[0]["connectors"][0]["from_rail"] == 0
201 assert result[0]["connectors"][0]["to_rail"] == 1
202 # Other entries have no fork/merge
203 assert result[1]["fork_from"] is None
204 assert result[2]["fork_from"] is None
205
206 def test_merge_detected(self):
207 """Merge commit: entry with merge_parent_rids on a different rail."""
208 entries = [
209 _make_entry(rid=4, parent_rid=3, rail=0, is_merge=True, merge_parent_rids=[2]), # merge from rail 1
210 _make_entry(rid=3, parent_rid=1, rail=0),
211 _make_entry(rid=2, branch="feature", parent_rid=1, rail=1), # feature branch
212 _make_entry(rid=1, parent_rid=0, rail=0),
213 ]
214 result = _compute_dag_graph(entries)
215 # rid=4 is a merge
216 assert result[0]["is_merge"] is True
217 assert result[0]["merge_to"] == 0
218 # Should have a merge connector
219 merge_conns = [c for c in result[0]["connectors"] if c["type"] == "merge"]
220 assert len(merge_conns) == 1
221 assert merge_conns[0]["from_rail"] == 1
222 assert merge_conns[0]["to_rail"] == 0
223
224 def test_non_checkin_entries_no_dag_data(self):
225 """Wiki/ticket/forum entries should not produce fork/merge/leaf data."""
226 entries = [
227 _make_entry(rid=2, event_type="w", rail=-1, parent_rid=0),
228 _make_entry(rid=1, parent_rid=0, rail=0),
229 ]
230 result = _compute_dag_graph(entries)
231 # Wiki entry: no fork/merge, not a leaf (only ci entries can be leaves)
232 assert result[0]["fork_from"] is None
233 assert result[0]["merge_to"] is None
234 assert result[0]["is_leaf"] is False
235 assert result[0]["is_merge"] is False
236
237 def test_rail_colors_present(self):
238 """Each line and node should carry a color."""
239 entries = [
240 _make_entry(rid=2, parent_rid=1, rail=0),
241 _make_entry(rid=1, parent_rid=0, rail=0),
242 ]
243 result = _compute_dag_graph(entries)
244 assert result[0]["node_color"] == "#ef4444" # rail 0 = red
245 # Active lines should also have color
246 for item in result:
247 for line in item["lines"]:
248 assert "color" in line
249
250 def test_multiple_rails_active_lines(self):
251 """When two branches are active, both rails should appear in lines."""
252 entries = [
253 _make_entry(rid=4, branch="feature", parent_rid=2, rail=1),
254 _make_entry(rid=3, parent_rid=1, rail=0),
255 _make_entry(rid=2, branch="feature", parent_rid=1, rail=1),
256 _make_entry(rid=1, parent_rid=0, rail=0),
257 ]
258 result = _compute_dag_graph(entries)
259 # At row index 1 (rid=3), both rail 0 and rail 1 should be active
260 # because rail 1 spans from index 0 (rid=4) to index 2 (rid=2)
261 # and rail 0 spans from index 1 (rid=3) to index 3 (rid=1)
262 active_xs = {line["x"] for line in result[1]["lines"]}
263 rail_0_x = 20 + 0 * 16 # 20
264 rail_1_x = 20 + 1 * 16 # 36
265 assert rail_0_x in active_xs
266 assert rail_1_x in active_xs
267
268 def test_graph_width_accommodates_rails(self):
269 """Graph width should be wide enough for all rails plus padding."""
270 entries = [
271 _make_entry(rid=3, branch="b2", parent_rid=1, rail=2),
272 _make_entry(rid=2, branch="b1", parent_rid=1, rail=1),
273 _make_entry(rid=1, parent_rid=0, rail=0),
274 ]
275 result = _compute_dag_graph(entries)
276 # max_rail=2, graph_width = 20 + (2+2)*16 = 84
277 assert result[0]["graph_width"] == 84
278
279 def test_connector_geometry(self):
280 """Fork connector left and width should span from the lower rail to the higher rail."""
281 entries = [
282 _make_entry(rid=2, branch="feature", parent_rid=1, rail=2), # fork from rail 0
283 _make_entry(rid=1, parent_rid=0, rail=0),
284 ]
285 result = _compute_dag_graph(entries)
286 conn = result[0]["connectors"][0]
287 rail_0_x = 20 + 0 * 16 # 20
288 rail_2_x = 20 + 2 * 16 # 52
289 assert conn["left"] == rail_0_x
290 assert conn["width"] == rail_2_x - rail_0_x
291
292
293 # --- Model tests ---
294
295
296 @pytest.mark.django_db
297
--- fossil/urls.py
+++ fossil/urls.py
@@ -42,6 +42,14 @@
4242
path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
4343
path("tickets/export/", views.tickets_csv, name="tickets_csv"),
4444
path("docs/", views.fossil_docs, name="docs"),
4545
path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
4646
path("xfer", views.fossil_xfer, name="xfer"),
47
+ # Releases
48
+ path("releases/", views.release_list, name="releases"),
49
+ path("releases/create/", views.release_create, name="release_create"),
50
+ path("releases/<str:tag_name>/", views.release_detail, name="release_detail"),
51
+ path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"),
52
+ path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"),
53
+ path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"),
54
+ path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"),
4755
]
4856
--- fossil/urls.py
+++ fossil/urls.py
@@ -42,6 +42,14 @@
42 path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
43 path("tickets/export/", views.tickets_csv, name="tickets_csv"),
44 path("docs/", views.fossil_docs, name="docs"),
45 path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
46 path("xfer", views.fossil_xfer, name="xfer"),
 
 
 
 
 
 
 
 
47 ]
48
--- fossil/urls.py
+++ fossil/urls.py
@@ -42,6 +42,14 @@
42 path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
43 path("tickets/export/", views.tickets_csv, name="tickets_csv"),
44 path("docs/", views.fossil_docs, name="docs"),
45 path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"),
46 path("xfer", views.fossil_xfer, name="xfer"),
47 # Releases
48 path("releases/", views.release_list, name="releases"),
49 path("releases/create/", views.release_create, name="release_create"),
50 path("releases/<str:tag_name>/", views.release_detail, name="release_detail"),
51 path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"),
52 path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"),
53 path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"),
54 path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"),
55 ]
56
+392 -24
--- fossil/views.py
+++ fossil/views.py
@@ -1,7 +1,8 @@
11
import contextlib
22
import re
3
+from datetime import datetime
34
45
import markdown as md
56
from django.contrib.auth.decorators import login_required
67
from django.http import Http404, HttpResponse
78
from django.shortcuts import get_object_or_404, redirect, render
@@ -1566,10 +1567,39 @@
15661567
cli = FossilCLI()
15671568
blame_lines = []
15681569
if cli.is_available():
15691570
blame_lines = cli.blame(fossil_repo.full_path, filepath)
15701571
1572
+ # Compute age-based coloring for blame annotations
1573
+ if blame_lines:
1574
+ dates = []
1575
+ for line in blame_lines:
1576
+ try:
1577
+ d = datetime.strptime(line["date"], "%Y-%m-%d")
1578
+ dates.append(d)
1579
+ line["_parsed_date"] = d
1580
+ except (ValueError, KeyError):
1581
+ line["_parsed_date"] = None
1582
+
1583
+ if dates:
1584
+ min_date = min(dates)
1585
+ max_date = max(dates)
1586
+ date_range = (max_date - min_date).days or 1
1587
+
1588
+ for line in blame_lines:
1589
+ age = (line["_parsed_date"] - min_date).days / date_range if line.get("_parsed_date") else 0.5
1590
+ # Interpolate from gray-500 (#6b7280) to brand (#DC394C)
1591
+ r = int(107 + age * (220 - 107))
1592
+ g = int(114 + age * (57 - 114))
1593
+ b = int(128 + age * (76 - 128))
1594
+ line["age_color"] = f"rgb({r},{g},{b})"
1595
+ line["age_bg"] = f"rgba({r},{g},{b},0.08)"
1596
+ else:
1597
+ for line in blame_lines:
1598
+ line["age_color"] = "rgb(107,114,128)"
1599
+ line["age_bg"] = "transparent"
1600
+
15711601
parts = filepath.split("/")
15721602
file_breadcrumbs = [{"name": p, "path": "/".join(parts[: i + 1])} for i, p in enumerate(parts)]
15731603
15741604
return render(
15751605
request,
@@ -1737,61 +1767,129 @@
17371767
}
17381768
)
17391769
17401770
return entries
17411771
1772
+
1773
+_RAIL_COLORS = [
1774
+ "#ef4444", # 0: red
1775
+ "#3b82f6", # 1: blue
1776
+ "#22c55e", # 2: green
1777
+ "#f59e0b", # 3: amber
1778
+ "#8b5cf6", # 4: purple
1779
+ "#06b6d4", # 5: cyan
1780
+ "#ec4899", # 6: pink
1781
+ "#f97316", # 7: orange
1782
+]
1783
+
1784
+
1785
+def _rail_color(rail: int) -> str:
1786
+ return _RAIL_COLORS[rail % len(_RAIL_COLORS)]
1787
+
17421788
17431789
def _compute_dag_graph(entries):
17441790
"""Compute DAG graph positions for timeline entries.
17451791
17461792
Tracks active rails through each row and draws fork/merge connectors
1747
- where a child is on a different rail than its parent.
1793
+ where a child is on a different rail than its parent. Detects forks
1794
+ (first commit on a rail whose parent is on a different rail), merges
1795
+ (commits with multiple parents), and leaf tips (no child on the same rail).
17481796
"""
1797
+ if not entries:
1798
+ return []
1799
+
17491800
rail_pitch = 16
17501801
rail_offset = 20
17511802
max_rail = max((e.rail for e in entries if e.rail >= 0), default=0)
17521803
graph_width = rail_offset + (max_rail + 2) * rail_pitch
17531804
17541805
# Build rid-to-index and rid-to-rail lookups
1755
- rid_to_idx = {}
1756
- rid_to_rail = {}
1806
+ rid_to_idx: dict[int, int] = {}
1807
+ rid_to_rail: dict[int, int] = {}
17571808
for i, entry in enumerate(entries):
17581809
rid_to_idx[entry.rid] = i
17591810
if entry.event_type == "ci":
17601811
rid_to_rail[entry.rid] = max(entry.rail, 0)
17611812
1762
- # For each row, compute:
1763
- # 1. Which vertical rails are active (have a line passing through)
1764
- # 2. Whether there's a fork/merge connector to draw
1813
+ # Track which rids have a child on the same rail (for leaf detection).
1814
+ # Also track which rails have had a previous entry (for fork detection:
1815
+ # first entry on a rail whose parent is on a different rail = fork).
1816
+ has_child_on_rail: set[int] = set() # parent rids that have a same-rail child
1817
+ rail_first_seen: dict[int, int] = {} # rail -> index of first entry on that rail
17651818
1766
- # Precompute: for each checkin, the range of rows its line spans
1767
- # (from the entry's row to its parent's row)
1768
- active_spans = [] # (rail, start_idx, end_idx)
1819
+ for i, entry in enumerate(entries):
1820
+ if entry.event_type != "ci":
1821
+ continue
1822
+ rail = max(entry.rail, 0)
1823
+ if rail not in rail_first_seen:
1824
+ rail_first_seen[rail] = i
1825
+ # Mark the primary parent as having a child on this rail
1826
+ if entry.parent_rid in rid_to_rail and rid_to_rail[entry.parent_rid] == rail:
1827
+ has_child_on_rail.add(entry.parent_rid)
1828
+
1829
+ # Precompute: for each checkin, the range of rows its vertical line spans
1830
+ # (from the entry's row down to its parent's row, since entries are newest-first)
1831
+ active_spans: list[tuple[int, int, int]] = [] # (rail, start_idx, end_idx)
17691832
for i, entry in enumerate(entries):
17701833
if entry.event_type == "ci" and entry.parent_rid in rid_to_idx:
17711834
parent_idx = rid_to_idx[entry.parent_rid]
17721835
if parent_idx > i:
17731836
rail = max(entry.rail, 0)
17741837
active_spans.append((rail, i, parent_idx))
17751838
1776
- # Precompute connectors: for each row, collect all horizontal connections
1777
- # A connector appears when a child on one rail connects to a parent on a different rail
1778
- # We draw the connector at BOTH the child row (fork out) and on every row where
1779
- # a branch line needs to cross from one rail to another
1839
+ # Precompute fork and merge connectors per row.
1840
+ # Fork: first entry on a rail whose primary parent is on a different rail.
1841
+ # Merge: entry with merge_parent_rids on different rails.
17801842
row_connectors: dict[int, list[dict]] = {}
1781
- for entry in entries:
1782
- if entry.event_type != "ci" or entry.parent_rid not in rid_to_idx:
1843
+ row_fork_from: dict[int, int | None] = {}
1844
+ row_merge_to: dict[int, int | None] = {}
1845
+
1846
+ for i, entry in enumerate(entries):
1847
+ if entry.event_type != "ci":
17831848
continue
1784
- parent_idx = rid_to_idx[entry.parent_rid]
17851849
child_rail = max(entry.rail, 0)
1786
- parent_rail = rid_to_rail.get(entry.parent_rid, 0)
1787
- if child_rail != parent_rail:
1788
- child_x = rail_offset + child_rail * rail_pitch
1789
- parent_x = rail_offset + parent_rail * rail_pitch
1790
- conn = {"left": min(child_x, parent_x), "width": abs(child_x - parent_x)}
1791
- # Draw at the parent's row (where branch meets trunk)
1792
- row_connectors.setdefault(parent_idx, []).append(conn)
1850
+
1851
+ # Fork detection: this entry's primary parent is on a different rail,
1852
+ # and this is the first entry we've seen on this rail.
1853
+ if entry.parent_rid in rid_to_rail:
1854
+ parent_rail = rid_to_rail[entry.parent_rid]
1855
+ if child_rail != parent_rail and rail_first_seen.get(child_rail) == i:
1856
+ row_fork_from[i] = parent_rail
1857
+ # Draw the fork connector at this row (where the branch starts)
1858
+ left_rail = min(child_rail, parent_rail)
1859
+ right_rail = max(child_rail, parent_rail)
1860
+ left_x = rail_offset + left_rail * rail_pitch
1861
+ right_x = rail_offset + right_rail * rail_pitch
1862
+ conn = {
1863
+ "left": left_x,
1864
+ "width": right_x - left_x,
1865
+ "type": "fork",
1866
+ "from_rail": parent_rail,
1867
+ "to_rail": child_rail,
1868
+ "color": _rail_color(child_rail),
1869
+ }
1870
+ row_connectors.setdefault(i, []).append(conn)
1871
+
1872
+ # Merge detection: non-primary parents on different rails
1873
+ for merge_rid in entry.merge_parent_rids:
1874
+ if merge_rid in rid_to_rail:
1875
+ merge_rail = rid_to_rail[merge_rid]
1876
+ if merge_rail != child_rail:
1877
+ row_merge_to[i] = child_rail
1878
+ left_rail = min(child_rail, merge_rail)
1879
+ right_rail = max(child_rail, merge_rail)
1880
+ left_x = rail_offset + left_rail * rail_pitch
1881
+ right_x = rail_offset + right_rail * rail_pitch
1882
+ conn = {
1883
+ "left": left_x,
1884
+ "width": right_x - left_x,
1885
+ "type": "merge",
1886
+ "from_rail": merge_rail,
1887
+ "to_rail": child_rail,
1888
+ "color": _rail_color(merge_rail),
1889
+ }
1890
+ row_connectors.setdefault(i, []).append(conn)
17931891
17941892
result = []
17951893
for i, entry in enumerate(entries):
17961894
rail = max(entry.rail, 0) if entry.rail >= 0 else 0
17971895
node_x = rail_offset + rail * rail_pitch
@@ -1800,19 +1898,289 @@
18001898
active_rails = set()
18011899
for span_rail, span_start, span_end in active_spans:
18021900
if span_start <= i <= span_end:
18031901
active_rails.add(span_rail)
18041902
1805
- lines = [{"x": rail_offset + r * rail_pitch} for r in sorted(active_rails)]
1903
+ lines = [{"x": rail_offset + r * rail_pitch, "color": _rail_color(r)} for r in sorted(active_rails)]
18061904
connectors = row_connectors.get(i, [])
1905
+
1906
+ # A leaf is a checkin that has no child on the same rail within this page
1907
+ is_leaf = entry.event_type == "ci" and entry.rid not in has_child_on_rail
1908
+ fork_from = row_fork_from.get(i)
1909
+ merge_to = row_merge_to.get(i)
18071910
18081911
result.append(
18091912
{
18101913
"entry": entry,
18111914
"node_x": node_x,
1915
+ "node_color": _rail_color(rail),
18121916
"lines": lines,
18131917
"connectors": connectors,
18141918
"graph_width": graph_width,
1919
+ "fork_from": fork_from,
1920
+ "merge_to": merge_to,
1921
+ "is_merge": entry.is_merge,
1922
+ "is_leaf": is_leaf,
18151923
}
18161924
)
18171925
18181926
return result
1927
+
1928
+
1929
+# --- Releases ---
1930
+
1931
+
1932
+def _get_project_and_repo(slug, request=None, require="read"):
1933
+ """Return (project, fossil_repo) without opening the .fossil file.
1934
+
1935
+ Used by release views that only need Django ORM access, not Fossil SQLite queries.
1936
+ """
1937
+ from projects.access import require_project_admin, require_project_read, require_project_write
1938
+
1939
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1940
+
1941
+ if request:
1942
+ if require == "admin":
1943
+ require_project_admin(request, project)
1944
+ elif require == "write":
1945
+ require_project_write(request, project)
1946
+ else:
1947
+ require_project_read(request, project)
1948
+
1949
+ fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1950
+ return project, fossil_repo
1951
+
1952
+
1953
+def release_list(request, slug):
1954
+ from projects.access import can_write_project
1955
+
1956
+ project, fossil_repo = _get_project_and_repo(slug, request, "read")
1957
+
1958
+ from fossil.releases import Release
1959
+
1960
+ releases = Release.objects.filter(repository=fossil_repo)
1961
+
1962
+ has_write = can_write_project(request.user, project)
1963
+ if not has_write:
1964
+ releases = releases.filter(is_draft=False)
1965
+
1966
+ return render(
1967
+ request,
1968
+ "fossil/release_list.html",
1969
+ {
1970
+ "project": project,
1971
+ "fossil_repo": fossil_repo,
1972
+ "releases": releases,
1973
+ "has_write": has_write,
1974
+ "active_tab": "releases",
1975
+ },
1976
+ )
1977
+
1978
+
1979
+def release_detail(request, slug, tag_name):
1980
+ from projects.access import can_admin_project, can_write_project
1981
+
1982
+ project, fossil_repo = _get_project_and_repo(slug, request, "read")
1983
+
1984
+ from fossil.releases import Release
1985
+
1986
+ release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
1987
+
1988
+ # Drafts are only visible to writers
1989
+ if release.is_draft:
1990
+ from projects.access import require_project_write
1991
+
1992
+ require_project_write(request, project)
1993
+
1994
+ body_html = ""
1995
+ if release.body:
1996
+ body_html = mark_safe(md.markdown(release.body, extensions=["footnotes", "tables", "fenced_code"]))
1997
+
1998
+ assets = release.assets.filter(deleted_at__isnull=True)
1999
+ has_write = can_write_project(request.user, project)
2000
+ has_admin = can_admin_project(request.user, project)
2001
+
2002
+ return render(
2003
+ request,
2004
+ "fossil/release_detail.html",
2005
+ {
2006
+ "project": project,
2007
+ "fossil_repo": fossil_repo,
2008
+ "release": release,
2009
+ "body_html": body_html,
2010
+ "assets": assets,
2011
+ "has_write": has_write,
2012
+ "has_admin": has_admin,
2013
+ "active_tab": "releases",
2014
+ },
2015
+ )
2016
+
2017
+
2018
+@login_required
2019
+def release_create(request, slug):
2020
+ from django.contrib import messages
2021
+ from django.utils import timezone
2022
+
2023
+ project, fossil_repo = _get_project_and_repo(slug, request, "write")
2024
+
2025
+ # Fetch recent checkins for the optional dropdown
2026
+ recent_checkins = []
2027
+ with contextlib.suppress(Exception):
2028
+ reader = FossilReader(fossil_repo.full_path)
2029
+ with reader:
2030
+ recent_checkins = reader.get_timeline(limit=20, event_type="ci")
2031
+
2032
+ if request.method == "POST":
2033
+ from fossil.releases import Release
2034
+
2035
+ tag_name = request.POST.get("tag_name", "").strip()
2036
+ name = request.POST.get("name", "").strip()
2037
+ body = request.POST.get("body", "")
2038
+ is_prerelease = request.POST.get("is_prerelease") == "on"
2039
+ is_draft = request.POST.get("is_draft") == "on"
2040
+ checkin_uuid = request.POST.get("checkin_uuid", "").strip()
2041
+
2042
+ if tag_name and name:
2043
+ published_at = None if is_draft else timezone.now()
2044
+ release = Release.objects.create(
2045
+ repository=fossil_repo,
2046
+ tag_name=tag_name,
2047
+ name=name,
2048
+ body=body,
2049
+ is_prerelease=is_prerelease,
2050
+ is_draft=is_draft,
2051
+ published_at=published_at,
2052
+ checkin_uuid=checkin_uuid,
2053
+ created_by=request.user,
2054
+ )
2055
+ messages.success(request, f'Release "{release.tag_name}" created.')
2056
+ return redirect("fossil:release_detail", slug=slug, tag_name=release.tag_name)
2057
+
2058
+ return render(
2059
+ request,
2060
+ "fossil/release_form.html",
2061
+ {
2062
+ "project": project,
2063
+ "fossil_repo": fossil_repo,
2064
+ "recent_checkins": recent_checkins,
2065
+ "form_title": "Create Release",
2066
+ "submit_label": "Create Release",
2067
+ "active_tab": "releases",
2068
+ },
2069
+ )
2070
+
2071
+
2072
+@login_required
2073
+def release_edit(request, slug, tag_name):
2074
+ from django.contrib import messages
2075
+ from django.utils import timezone
2076
+
2077
+ project, fossil_repo = _get_project_and_repo(slug, request, "write")
2078
+
2079
+ from fossil.releases import Release
2080
+
2081
+ release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
2082
+
2083
+ # Fetch recent checkins for the optional dropdown
2084
+ recent_checkins = []
2085
+ with contextlib.suppress(Exception):
2086
+ reader = FossilReader(fossil_repo.full_path)
2087
+ with reader:
2088
+ recent_checkins = reader.get_timeline(limit=20, event_type="ci")
2089
+
2090
+ if request.method == "POST":
2091
+ new_tag_name = request.POST.get("tag_name", "").strip()
2092
+ name = request.POST.get("name", "").strip()
2093
+ body = request.POST.get("body", "")
2094
+ is_prerelease = request.POST.get("is_prerelease") == "on"
2095
+ is_draft = request.POST.get("is_draft") == "on"
2096
+ checkin_uuid = request.POST.get("checkin_uuid", "").strip()
2097
+
2098
+ if new_tag_name and name:
2099
+ was_draft = release.is_draft
2100
+ release.tag_name = new_tag_name
2101
+ release.name = name
2102
+ release.body = body
2103
+ release.is_prerelease = is_prerelease
2104
+ release.is_draft = is_draft
2105
+ release.checkin_uuid = checkin_uuid
2106
+ release.updated_by = request.user
2107
+ # Set published_at when transitioning from draft to published
2108
+ if was_draft and not is_draft and not release.published_at:
2109
+ release.published_at = timezone.now()
2110
+ release.save()
2111
+ messages.success(request, f'Release "{release.tag_name}" updated.')
2112
+ return redirect("fossil:release_detail", slug=slug, tag_name=release.tag_name)
2113
+
2114
+ return render(
2115
+ request,
2116
+ "fossil/release_form.html",
2117
+ {
2118
+ "project": project,
2119
+ "fossil_repo": fossil_repo,
2120
+ "release": release,
2121
+ "recent_checkins": recent_checkins,
2122
+ "form_title": f"Edit Release: {release.tag_name}",
2123
+ "submit_label": "Update Release",
2124
+ "active_tab": "releases",
2125
+ },
2126
+ )
2127
+
2128
+
2129
+@login_required
2130
+def release_delete(request, slug, tag_name):
2131
+ from django.contrib import messages
2132
+
2133
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
2134
+
2135
+ from fossil.releases import Release
2136
+
2137
+ release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
2138
+
2139
+ if request.method == "POST":
2140
+ release.soft_delete(user=request.user)
2141
+ messages.success(request, f'Release "{release.tag_name}" deleted.')
2142
+ return redirect("fossil:releases", slug=slug)
2143
+
2144
+ return redirect("fossil:release_detail", slug=slug, tag_name=tag_name)
2145
+
2146
+
2147
+@login_required
2148
+def release_asset_upload(request, slug, tag_name):
2149
+ from django.contrib import messages
2150
+
2151
+ project, fossil_repo = _get_project_and_repo(slug, request, "write")
2152
+
2153
+ from fossil.releases import Release, ReleaseAsset
2154
+
2155
+ release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
2156
+
2157
+ if request.method == "POST" and request.FILES.get("file"):
2158
+ uploaded = request.FILES["file"]
2159
+ asset = ReleaseAsset.objects.create(
2160
+ release=release,
2161
+ name=uploaded.name,
2162
+ file=uploaded,
2163
+ file_size_bytes=uploaded.size,
2164
+ content_type=uploaded.content_type or "",
2165
+ created_by=request.user,
2166
+ )
2167
+ messages.success(request, f'Asset "{asset.name}" uploaded.')
2168
+
2169
+ return redirect("fossil:release_detail", slug=slug, tag_name=tag_name)
2170
+
2171
+
2172
+def release_asset_download(request, slug, tag_name, asset_id):
2173
+ from django.db import models as db_models
2174
+ from django.http import FileResponse
2175
+
2176
+ project, fossil_repo = _get_project_and_repo(slug, request, "read")
2177
+
2178
+ from fossil.releases import Release, ReleaseAsset
2179
+
2180
+ release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
2181
+ asset = get_object_or_404(ReleaseAsset, pk=asset_id, release=release, deleted_at__isnull=True)
2182
+
2183
+ # Increment download count atomically
2184
+ ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
2185
+
2186
+ return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name)
18192187
--- fossil/views.py
+++ fossil/views.py
@@ -1,7 +1,8 @@
1 import contextlib
2 import re
 
3
4 import markdown as md
5 from django.contrib.auth.decorators import login_required
6 from django.http import Http404, HttpResponse
7 from django.shortcuts import get_object_or_404, redirect, render
@@ -1566,10 +1567,39 @@
1566 cli = FossilCLI()
1567 blame_lines = []
1568 if cli.is_available():
1569 blame_lines = cli.blame(fossil_repo.full_path, filepath)
1570
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1571 parts = filepath.split("/")
1572 file_breadcrumbs = [{"name": p, "path": "/".join(parts[: i + 1])} for i, p in enumerate(parts)]
1573
1574 return render(
1575 request,
@@ -1737,61 +1767,129 @@
1737 }
1738 )
1739
1740 return entries
1741
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1742
1743 def _compute_dag_graph(entries):
1744 """Compute DAG graph positions for timeline entries.
1745
1746 Tracks active rails through each row and draws fork/merge connectors
1747 where a child is on a different rail than its parent.
 
 
1748 """
 
 
 
1749 rail_pitch = 16
1750 rail_offset = 20
1751 max_rail = max((e.rail for e in entries if e.rail >= 0), default=0)
1752 graph_width = rail_offset + (max_rail + 2) * rail_pitch
1753
1754 # Build rid-to-index and rid-to-rail lookups
1755 rid_to_idx = {}
1756 rid_to_rail = {}
1757 for i, entry in enumerate(entries):
1758 rid_to_idx[entry.rid] = i
1759 if entry.event_type == "ci":
1760 rid_to_rail[entry.rid] = max(entry.rail, 0)
1761
1762 # For each row, compute:
1763 # 1. Which vertical rails are active (have a line passing through)
1764 # 2. Whether there's a fork/merge connector to draw
 
 
1765
1766 # Precompute: for each checkin, the range of rows its line spans
1767 # (from the entry's row to its parent's row)
1768 active_spans = [] # (rail, start_idx, end_idx)
 
 
 
 
 
 
 
 
 
 
1769 for i, entry in enumerate(entries):
1770 if entry.event_type == "ci" and entry.parent_rid in rid_to_idx:
1771 parent_idx = rid_to_idx[entry.parent_rid]
1772 if parent_idx > i:
1773 rail = max(entry.rail, 0)
1774 active_spans.append((rail, i, parent_idx))
1775
1776 # Precompute connectors: for each row, collect all horizontal connections
1777 # A connector appears when a child on one rail connects to a parent on a different rail
1778 # We draw the connector at BOTH the child row (fork out) and on every row where
1779 # a branch line needs to cross from one rail to another
1780 row_connectors: dict[int, list[dict]] = {}
1781 for entry in entries:
1782 if entry.event_type != "ci" or entry.parent_rid not in rid_to_idx:
 
 
 
1783 continue
1784 parent_idx = rid_to_idx[entry.parent_rid]
1785 child_rail = max(entry.rail, 0)
1786 parent_rail = rid_to_rail.get(entry.parent_rid, 0)
1787 if child_rail != parent_rail:
1788 child_x = rail_offset + child_rail * rail_pitch
1789 parent_x = rail_offset + parent_rail * rail_pitch
1790 conn = {"left": min(child_x, parent_x), "width": abs(child_x - parent_x)}
1791 # Draw at the parent's row (where branch meets trunk)
1792 row_connectors.setdefault(parent_idx, []).append(conn)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1793
1794 result = []
1795 for i, entry in enumerate(entries):
1796 rail = max(entry.rail, 0) if entry.rail >= 0 else 0
1797 node_x = rail_offset + rail * rail_pitch
@@ -1800,19 +1898,289 @@
1800 active_rails = set()
1801 for span_rail, span_start, span_end in active_spans:
1802 if span_start <= i <= span_end:
1803 active_rails.add(span_rail)
1804
1805 lines = [{"x": rail_offset + r * rail_pitch} for r in sorted(active_rails)]
1806 connectors = row_connectors.get(i, [])
 
 
 
 
 
1807
1808 result.append(
1809 {
1810 "entry": entry,
1811 "node_x": node_x,
 
1812 "lines": lines,
1813 "connectors": connectors,
1814 "graph_width": graph_width,
 
 
 
 
1815 }
1816 )
1817
1818 return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1819
--- fossil/views.py
+++ fossil/views.py
@@ -1,7 +1,8 @@
1 import contextlib
2 import re
3 from datetime import datetime
4
5 import markdown as md
6 from django.contrib.auth.decorators import login_required
7 from django.http import Http404, HttpResponse
8 from django.shortcuts import get_object_or_404, redirect, render
@@ -1566,10 +1567,39 @@
1567 cli = FossilCLI()
1568 blame_lines = []
1569 if cli.is_available():
1570 blame_lines = cli.blame(fossil_repo.full_path, filepath)
1571
1572 # Compute age-based coloring for blame annotations
1573 if blame_lines:
1574 dates = []
1575 for line in blame_lines:
1576 try:
1577 d = datetime.strptime(line["date"], "%Y-%m-%d")
1578 dates.append(d)
1579 line["_parsed_date"] = d
1580 except (ValueError, KeyError):
1581 line["_parsed_date"] = None
1582
1583 if dates:
1584 min_date = min(dates)
1585 max_date = max(dates)
1586 date_range = (max_date - min_date).days or 1
1587
1588 for line in blame_lines:
1589 age = (line["_parsed_date"] - min_date).days / date_range if line.get("_parsed_date") else 0.5
1590 # Interpolate from gray-500 (#6b7280) to brand (#DC394C)
1591 r = int(107 + age * (220 - 107))
1592 g = int(114 + age * (57 - 114))
1593 b = int(128 + age * (76 - 128))
1594 line["age_color"] = f"rgb({r},{g},{b})"
1595 line["age_bg"] = f"rgba({r},{g},{b},0.08)"
1596 else:
1597 for line in blame_lines:
1598 line["age_color"] = "rgb(107,114,128)"
1599 line["age_bg"] = "transparent"
1600
1601 parts = filepath.split("/")
1602 file_breadcrumbs = [{"name": p, "path": "/".join(parts[: i + 1])} for i, p in enumerate(parts)]
1603
1604 return render(
1605 request,
@@ -1737,61 +1767,129 @@
1767 }
1768 )
1769
1770 return entries
1771
1772
1773 _RAIL_COLORS = [
1774 "#ef4444", # 0: red
1775 "#3b82f6", # 1: blue
1776 "#22c55e", # 2: green
1777 "#f59e0b", # 3: amber
1778 "#8b5cf6", # 4: purple
1779 "#06b6d4", # 5: cyan
1780 "#ec4899", # 6: pink
1781 "#f97316", # 7: orange
1782 ]
1783
1784
1785 def _rail_color(rail: int) -> str:
1786 return _RAIL_COLORS[rail % len(_RAIL_COLORS)]
1787
1788
1789 def _compute_dag_graph(entries):
1790 """Compute DAG graph positions for timeline entries.
1791
1792 Tracks active rails through each row and draws fork/merge connectors
1793 where a child is on a different rail than its parent. Detects forks
1794 (first commit on a rail whose parent is on a different rail), merges
1795 (commits with multiple parents), and leaf tips (no child on the same rail).
1796 """
1797 if not entries:
1798 return []
1799
1800 rail_pitch = 16
1801 rail_offset = 20
1802 max_rail = max((e.rail for e in entries if e.rail >= 0), default=0)
1803 graph_width = rail_offset + (max_rail + 2) * rail_pitch
1804
1805 # Build rid-to-index and rid-to-rail lookups
1806 rid_to_idx: dict[int, int] = {}
1807 rid_to_rail: dict[int, int] = {}
1808 for i, entry in enumerate(entries):
1809 rid_to_idx[entry.rid] = i
1810 if entry.event_type == "ci":
1811 rid_to_rail[entry.rid] = max(entry.rail, 0)
1812
1813 # Track which rids have a child on the same rail (for leaf detection).
1814 # Also track which rails have had a previous entry (for fork detection:
1815 # first entry on a rail whose parent is on a different rail = fork).
1816 has_child_on_rail: set[int] = set() # parent rids that have a same-rail child
1817 rail_first_seen: dict[int, int] = {} # rail -> index of first entry on that rail
1818
1819 for i, entry in enumerate(entries):
1820 if entry.event_type != "ci":
1821 continue
1822 rail = max(entry.rail, 0)
1823 if rail not in rail_first_seen:
1824 rail_first_seen[rail] = i
1825 # Mark the primary parent as having a child on this rail
1826 if entry.parent_rid in rid_to_rail and rid_to_rail[entry.parent_rid] == rail:
1827 has_child_on_rail.add(entry.parent_rid)
1828
1829 # Precompute: for each checkin, the range of rows its vertical line spans
1830 # (from the entry's row down to its parent's row, since entries are newest-first)
1831 active_spans: list[tuple[int, int, int]] = [] # (rail, start_idx, end_idx)
1832 for i, entry in enumerate(entries):
1833 if entry.event_type == "ci" and entry.parent_rid in rid_to_idx:
1834 parent_idx = rid_to_idx[entry.parent_rid]
1835 if parent_idx > i:
1836 rail = max(entry.rail, 0)
1837 active_spans.append((rail, i, parent_idx))
1838
1839 # Precompute fork and merge connectors per row.
1840 # Fork: first entry on a rail whose primary parent is on a different rail.
1841 # Merge: entry with merge_parent_rids on different rails.
 
1842 row_connectors: dict[int, list[dict]] = {}
1843 row_fork_from: dict[int, int | None] = {}
1844 row_merge_to: dict[int, int | None] = {}
1845
1846 for i, entry in enumerate(entries):
1847 if entry.event_type != "ci":
1848 continue
 
1849 child_rail = max(entry.rail, 0)
1850
1851 # Fork detection: this entry's primary parent is on a different rail,
1852 # and this is the first entry we've seen on this rail.
1853 if entry.parent_rid in rid_to_rail:
1854 parent_rail = rid_to_rail[entry.parent_rid]
1855 if child_rail != parent_rail and rail_first_seen.get(child_rail) == i:
1856 row_fork_from[i] = parent_rail
1857 # Draw the fork connector at this row (where the branch starts)
1858 left_rail = min(child_rail, parent_rail)
1859 right_rail = max(child_rail, parent_rail)
1860 left_x = rail_offset + left_rail * rail_pitch
1861 right_x = rail_offset + right_rail * rail_pitch
1862 conn = {
1863 "left": left_x,
1864 "width": right_x - left_x,
1865 "type": "fork",
1866 "from_rail": parent_rail,
1867 "to_rail": child_rail,
1868 "color": _rail_color(child_rail),
1869 }
1870 row_connectors.setdefault(i, []).append(conn)
1871
1872 # Merge detection: non-primary parents on different rails
1873 for merge_rid in entry.merge_parent_rids:
1874 if merge_rid in rid_to_rail:
1875 merge_rail = rid_to_rail[merge_rid]
1876 if merge_rail != child_rail:
1877 row_merge_to[i] = child_rail
1878 left_rail = min(child_rail, merge_rail)
1879 right_rail = max(child_rail, merge_rail)
1880 left_x = rail_offset + left_rail * rail_pitch
1881 right_x = rail_offset + right_rail * rail_pitch
1882 conn = {
1883 "left": left_x,
1884 "width": right_x - left_x,
1885 "type": "merge",
1886 "from_rail": merge_rail,
1887 "to_rail": child_rail,
1888 "color": _rail_color(merge_rail),
1889 }
1890 row_connectors.setdefault(i, []).append(conn)
1891
1892 result = []
1893 for i, entry in enumerate(entries):
1894 rail = max(entry.rail, 0) if entry.rail >= 0 else 0
1895 node_x = rail_offset + rail * rail_pitch
@@ -1800,19 +1898,289 @@
1898 active_rails = set()
1899 for span_rail, span_start, span_end in active_spans:
1900 if span_start <= i <= span_end:
1901 active_rails.add(span_rail)
1902
1903 lines = [{"x": rail_offset + r * rail_pitch, "color": _rail_color(r)} for r in sorted(active_rails)]
1904 connectors = row_connectors.get(i, [])
1905
1906 # A leaf is a checkin that has no child on the same rail within this page
1907 is_leaf = entry.event_type == "ci" and entry.rid not in has_child_on_rail
1908 fork_from = row_fork_from.get(i)
1909 merge_to = row_merge_to.get(i)
1910
1911 result.append(
1912 {
1913 "entry": entry,
1914 "node_x": node_x,
1915 "node_color": _rail_color(rail),
1916 "lines": lines,
1917 "connectors": connectors,
1918 "graph_width": graph_width,
1919 "fork_from": fork_from,
1920 "merge_to": merge_to,
1921 "is_merge": entry.is_merge,
1922 "is_leaf": is_leaf,
1923 }
1924 )
1925
1926 return result
1927
1928
1929 # --- Releases ---
1930
1931
1932 def _get_project_and_repo(slug, request=None, require="read"):
1933 """Return (project, fossil_repo) without opening the .fossil file.
1934
1935 Used by release views that only need Django ORM access, not Fossil SQLite queries.
1936 """
1937 from projects.access import require_project_admin, require_project_read, require_project_write
1938
1939 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
1940
1941 if request:
1942 if require == "admin":
1943 require_project_admin(request, project)
1944 elif require == "write":
1945 require_project_write(request, project)
1946 else:
1947 require_project_read(request, project)
1948
1949 fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
1950 return project, fossil_repo
1951
1952
1953 def release_list(request, slug):
1954 from projects.access import can_write_project
1955
1956 project, fossil_repo = _get_project_and_repo(slug, request, "read")
1957
1958 from fossil.releases import Release
1959
1960 releases = Release.objects.filter(repository=fossil_repo)
1961
1962 has_write = can_write_project(request.user, project)
1963 if not has_write:
1964 releases = releases.filter(is_draft=False)
1965
1966 return render(
1967 request,
1968 "fossil/release_list.html",
1969 {
1970 "project": project,
1971 "fossil_repo": fossil_repo,
1972 "releases": releases,
1973 "has_write": has_write,
1974 "active_tab": "releases",
1975 },
1976 )
1977
1978
1979 def release_detail(request, slug, tag_name):
1980 from projects.access import can_admin_project, can_write_project
1981
1982 project, fossil_repo = _get_project_and_repo(slug, request, "read")
1983
1984 from fossil.releases import Release
1985
1986 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
1987
1988 # Drafts are only visible to writers
1989 if release.is_draft:
1990 from projects.access import require_project_write
1991
1992 require_project_write(request, project)
1993
1994 body_html = ""
1995 if release.body:
1996 body_html = mark_safe(md.markdown(release.body, extensions=["footnotes", "tables", "fenced_code"]))
1997
1998 assets = release.assets.filter(deleted_at__isnull=True)
1999 has_write = can_write_project(request.user, project)
2000 has_admin = can_admin_project(request.user, project)
2001
2002 return render(
2003 request,
2004 "fossil/release_detail.html",
2005 {
2006 "project": project,
2007 "fossil_repo": fossil_repo,
2008 "release": release,
2009 "body_html": body_html,
2010 "assets": assets,
2011 "has_write": has_write,
2012 "has_admin": has_admin,
2013 "active_tab": "releases",
2014 },
2015 )
2016
2017
2018 @login_required
2019 def release_create(request, slug):
2020 from django.contrib import messages
2021 from django.utils import timezone
2022
2023 project, fossil_repo = _get_project_and_repo(slug, request, "write")
2024
2025 # Fetch recent checkins for the optional dropdown
2026 recent_checkins = []
2027 with contextlib.suppress(Exception):
2028 reader = FossilReader(fossil_repo.full_path)
2029 with reader:
2030 recent_checkins = reader.get_timeline(limit=20, event_type="ci")
2031
2032 if request.method == "POST":
2033 from fossil.releases import Release
2034
2035 tag_name = request.POST.get("tag_name", "").strip()
2036 name = request.POST.get("name", "").strip()
2037 body = request.POST.get("body", "")
2038 is_prerelease = request.POST.get("is_prerelease") == "on"
2039 is_draft = request.POST.get("is_draft") == "on"
2040 checkin_uuid = request.POST.get("checkin_uuid", "").strip()
2041
2042 if tag_name and name:
2043 published_at = None if is_draft else timezone.now()
2044 release = Release.objects.create(
2045 repository=fossil_repo,
2046 tag_name=tag_name,
2047 name=name,
2048 body=body,
2049 is_prerelease=is_prerelease,
2050 is_draft=is_draft,
2051 published_at=published_at,
2052 checkin_uuid=checkin_uuid,
2053 created_by=request.user,
2054 )
2055 messages.success(request, f'Release "{release.tag_name}" created.')
2056 return redirect("fossil:release_detail", slug=slug, tag_name=release.tag_name)
2057
2058 return render(
2059 request,
2060 "fossil/release_form.html",
2061 {
2062 "project": project,
2063 "fossil_repo": fossil_repo,
2064 "recent_checkins": recent_checkins,
2065 "form_title": "Create Release",
2066 "submit_label": "Create Release",
2067 "active_tab": "releases",
2068 },
2069 )
2070
2071
2072 @login_required
2073 def release_edit(request, slug, tag_name):
2074 from django.contrib import messages
2075 from django.utils import timezone
2076
2077 project, fossil_repo = _get_project_and_repo(slug, request, "write")
2078
2079 from fossil.releases import Release
2080
2081 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
2082
2083 # Fetch recent checkins for the optional dropdown
2084 recent_checkins = []
2085 with contextlib.suppress(Exception):
2086 reader = FossilReader(fossil_repo.full_path)
2087 with reader:
2088 recent_checkins = reader.get_timeline(limit=20, event_type="ci")
2089
2090 if request.method == "POST":
2091 new_tag_name = request.POST.get("tag_name", "").strip()
2092 name = request.POST.get("name", "").strip()
2093 body = request.POST.get("body", "")
2094 is_prerelease = request.POST.get("is_prerelease") == "on"
2095 is_draft = request.POST.get("is_draft") == "on"
2096 checkin_uuid = request.POST.get("checkin_uuid", "").strip()
2097
2098 if new_tag_name and name:
2099 was_draft = release.is_draft
2100 release.tag_name = new_tag_name
2101 release.name = name
2102 release.body = body
2103 release.is_prerelease = is_prerelease
2104 release.is_draft = is_draft
2105 release.checkin_uuid = checkin_uuid
2106 release.updated_by = request.user
2107 # Set published_at when transitioning from draft to published
2108 if was_draft and not is_draft and not release.published_at:
2109 release.published_at = timezone.now()
2110 release.save()
2111 messages.success(request, f'Release "{release.tag_name}" updated.')
2112 return redirect("fossil:release_detail", slug=slug, tag_name=release.tag_name)
2113
2114 return render(
2115 request,
2116 "fossil/release_form.html",
2117 {
2118 "project": project,
2119 "fossil_repo": fossil_repo,
2120 "release": release,
2121 "recent_checkins": recent_checkins,
2122 "form_title": f"Edit Release: {release.tag_name}",
2123 "submit_label": "Update Release",
2124 "active_tab": "releases",
2125 },
2126 )
2127
2128
2129 @login_required
2130 def release_delete(request, slug, tag_name):
2131 from django.contrib import messages
2132
2133 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
2134
2135 from fossil.releases import Release
2136
2137 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
2138
2139 if request.method == "POST":
2140 release.soft_delete(user=request.user)
2141 messages.success(request, f'Release "{release.tag_name}" deleted.')
2142 return redirect("fossil:releases", slug=slug)
2143
2144 return redirect("fossil:release_detail", slug=slug, tag_name=tag_name)
2145
2146
2147 @login_required
2148 def release_asset_upload(request, slug, tag_name):
2149 from django.contrib import messages
2150
2151 project, fossil_repo = _get_project_and_repo(slug, request, "write")
2152
2153 from fossil.releases import Release, ReleaseAsset
2154
2155 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
2156
2157 if request.method == "POST" and request.FILES.get("file"):
2158 uploaded = request.FILES["file"]
2159 asset = ReleaseAsset.objects.create(
2160 release=release,
2161 name=uploaded.name,
2162 file=uploaded,
2163 file_size_bytes=uploaded.size,
2164 content_type=uploaded.content_type or "",
2165 created_by=request.user,
2166 )
2167 messages.success(request, f'Asset "{asset.name}" uploaded.')
2168
2169 return redirect("fossil:release_detail", slug=slug, tag_name=tag_name)
2170
2171
2172 def release_asset_download(request, slug, tag_name, asset_id):
2173 from django.db import models as db_models
2174 from django.http import FileResponse
2175
2176 project, fossil_repo = _get_project_and_repo(slug, request, "read")
2177
2178 from fossil.releases import Release, ReleaseAsset
2179
2180 release = get_object_or_404(Release, repository=fossil_repo, tag_name=tag_name, deleted_at__isnull=True)
2181 asset = get_object_or_404(ReleaseAsset, pk=asset_id, release=release, deleted_at__isnull=True)
2182
2183 # Increment download count atomically
2184 ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
2185
2186 return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name)
2187
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -24,10 +24,14 @@
2424
Wiki
2525
</a>
2626
<a href="{% url 'fossil:forum' slug=project.slug %}"
2727
class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
2828
Forum
29
+ </a>
30
+ <a href="{% url 'fossil:releases' slug=project.slug %}"
31
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'releases' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
32
+ Releases
2933
</a>
3034
{% if perms.projects.change_project %}
3135
<a href="{% url 'fossil:sync' slug=project.slug %}"
3236
class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'sync' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
3337
{% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
3438
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -24,10 +24,14 @@
24 Wiki
25 </a>
26 <a href="{% url 'fossil:forum' slug=project.slug %}"
27 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
28 Forum
 
 
 
 
29 </a>
30 {% if perms.projects.change_project %}
31 <a href="{% url 'fossil:sync' slug=project.slug %}"
32 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'sync' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
33 {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
34
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -24,10 +24,14 @@
24 Wiki
25 </a>
26 <a href="{% url 'fossil:forum' slug=project.slug %}"
27 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'forum' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
28 Forum
29 </a>
30 <a href="{% url 'fossil:releases' slug=project.slug %}"
31 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'releases' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
32 Releases
33 </a>
34 {% if perms.projects.change_project %}
35 <a href="{% url 'fossil:sync' slug=project.slug %}"
36 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'sync' %}bg-gray-800 text-gray-100 border-b-2 border-brand{% else %}text-gray-400 hover:text-gray-200 hover:bg-gray-800/50{% endif %}">
37 {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
38
--- templates/fossil/code_blame.html
+++ templates/fossil/code_blame.html
@@ -4,12 +4,12 @@
44
{% block extra_head %}
55
<style>
66
.blame-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; }
77
.blame-table td { padding: 0; vertical-align: top; line-height: 1.5rem; }
88
.blame-meta { width: 1%; white-space: nowrap; padding: 0 8px !important; color: #6b7280; border-right: 1px solid #374151; }
9
- .blame-meta a { color: inherit; text-decoration: none; }
10
- .blame-meta a:hover { color: #DC394C; }
9
+ .blame-meta a { text-decoration: none; }
10
+ .blame-meta a:hover { color: #DC394C !important; }
1111
.blame-num { width: 1%; white-space: nowrap; padding: 0 10px !important; text-align: right; color: #4b5563; border-right: 1px solid #374151; }
1212
.blame-code { white-space: pre; padding: 0 16px !important; font-size: 0.8125rem; }
1313
.blame-row:hover { background: rgba(220, 57, 76, 0.04); }
1414
</style>
1515
{% endblock %}
@@ -42,15 +42,15 @@
4242
{% if blame_lines %}
4343
<table class="blame-table">
4444
<tbody>
4545
{% for bl in blame_lines %}
4646
<tr class="blame-row">
47
- <td class="blame-meta">
48
- <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=bl.uuid %}">{{ bl.uuid|truncatechars:8 }}</a>
47
+ <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user }}">
48
+ <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=bl.uuid %}" style="color: inherit;">{{ bl.uuid|truncatechars:8 }}</a>
4949
</td>
50
- <td class="blame-meta">
51
- <a href="{% url 'fossil:user_activity' slug=project.slug username=bl.user %}">{{ bl.user }}</a>
50
+ <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user }}">
51
+ <a href="{% url 'fossil:user_activity' slug=project.slug username=bl.user %}" style="color: inherit;">{{ bl.user }}</a>
5252
</td>
5353
<td class="blame-num">{{ forloop.counter }}</td>
5454
<td class="blame-code">{{ bl.text }}</td>
5555
</tr>
5656
{% endfor %}
5757
--- templates/fossil/code_blame.html
+++ templates/fossil/code_blame.html
@@ -4,12 +4,12 @@
4 {% block extra_head %}
5 <style>
6 .blame-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; }
7 .blame-table td { padding: 0; vertical-align: top; line-height: 1.5rem; }
8 .blame-meta { width: 1%; white-space: nowrap; padding: 0 8px !important; color: #6b7280; border-right: 1px solid #374151; }
9 .blame-meta a { color: inherit; text-decoration: none; }
10 .blame-meta a:hover { color: #DC394C; }
11 .blame-num { width: 1%; white-space: nowrap; padding: 0 10px !important; text-align: right; color: #4b5563; border-right: 1px solid #374151; }
12 .blame-code { white-space: pre; padding: 0 16px !important; font-size: 0.8125rem; }
13 .blame-row:hover { background: rgba(220, 57, 76, 0.04); }
14 </style>
15 {% endblock %}
@@ -42,15 +42,15 @@
42 {% if blame_lines %}
43 <table class="blame-table">
44 <tbody>
45 {% for bl in blame_lines %}
46 <tr class="blame-row">
47 <td class="blame-meta">
48 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=bl.uuid %}">{{ bl.uuid|truncatechars:8 }}</a>
49 </td>
50 <td class="blame-meta">
51 <a href="{% url 'fossil:user_activity' slug=project.slug username=bl.user %}">{{ bl.user }}</a>
52 </td>
53 <td class="blame-num">{{ forloop.counter }}</td>
54 <td class="blame-code">{{ bl.text }}</td>
55 </tr>
56 {% endfor %}
57
--- templates/fossil/code_blame.html
+++ templates/fossil/code_blame.html
@@ -4,12 +4,12 @@
4 {% block extra_head %}
5 <style>
6 .blame-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; }
7 .blame-table td { padding: 0; vertical-align: top; line-height: 1.5rem; }
8 .blame-meta { width: 1%; white-space: nowrap; padding: 0 8px !important; color: #6b7280; border-right: 1px solid #374151; }
9 .blame-meta a { text-decoration: none; }
10 .blame-meta a:hover { color: #DC394C !important; }
11 .blame-num { width: 1%; white-space: nowrap; padding: 0 10px !important; text-align: right; color: #4b5563; border-right: 1px solid #374151; }
12 .blame-code { white-space: pre; padding: 0 16px !important; font-size: 0.8125rem; }
13 .blame-row:hover { background: rgba(220, 57, 76, 0.04); }
14 </style>
15 {% endblock %}
@@ -42,15 +42,15 @@
42 {% if blame_lines %}
43 <table class="blame-table">
44 <tbody>
45 {% for bl in blame_lines %}
46 <tr class="blame-row">
47 <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user }}">
48 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=bl.uuid %}" style="color: inherit;">{{ bl.uuid|truncatechars:8 }}</a>
49 </td>
50 <td class="blame-meta" style="color: {{ bl.age_color }}; background: {{ bl.age_bg }};" title="{{ bl.date }} by {{ bl.user }}">
51 <a href="{% url 'fossil:user_activity' slug=project.slug username=bl.user %}" style="color: inherit;">{{ bl.user }}</a>
52 </td>
53 <td class="blame-num">{{ forloop.counter }}</td>
54 <td class="blame-code">{{ bl.text }}</td>
55 </tr>
56 {% endfor %}
57
--- templates/fossil/partials/timeline_entries.html
+++ templates/fossil/partials/timeline_entries.html
@@ -2,19 +2,33 @@
22
.tl-dag { position: relative; flex-shrink: 0; }
33
.tl-node {
44
position: absolute; top: 50%; z-index: 2; border-radius: 50%;
55
transform: translate(-50%, -50%); width: 10px; height: 10px;
66
}
7
- .tl-node-ci { background: #DC394C; border: 2px solid #e8677a; }
8
- .tl-node-merge { background: #DC394C; border: 2px solid #e8677a; border-radius: 2px; transform: translate(-50%, -50%) rotate(45deg); }
7
+ .tl-node-ci { border: 2px solid; }
8
+ .tl-node-merge {
9
+ border: 2px solid; border-radius: 2px;
10
+ transform: translate(-50%, -50%) rotate(45deg);
11
+ }
12
+ .tl-node-leaf {
13
+ border: 2px solid; background: transparent !important;
14
+ }
915
.tl-node-w { background: #3b82f6; border: 2px solid #60a5fa; width: 8px; height: 8px; }
1016
.tl-node-t { background: #eab308; border: 2px solid #facc15; width: 8px; height: 8px; }
1117
.tl-node-f { background: #a855f7; border: 2px solid #c084fc; width: 8px; height: 8px; }
1218
.tl-node-other { background: #6b7280; border: 2px solid #9ca3af; width: 8px; height: 8px; }
13
- .tl-vline { position: absolute; width: 2px; top: 0; bottom: 0; transform: translateX(-50%); }
14
- .tl-vline-ci { background: rgba(220,57,76,0.3); }
15
- .tl-vline-other { background: rgba(107,114,128,0.2); }
19
+ .tl-vline { position: absolute; width: 2px; top: 0; bottom: 0; transform: translateX(-50%); opacity: 0.4; }
20
+ .tl-connector {
21
+ position: absolute; top: 40%; z-index: 1; border-radius: 0 0 4px 4px;
22
+ border-bottom: 2px solid; border-left: 2px solid; border-right: 2px solid;
23
+ height: 30%; opacity: 0.5;
24
+ }
25
+ .tl-connector-merge {
26
+ top: 20%; border-radius: 4px 4px 0 0;
27
+ border-top: 2px solid; border-left: 2px solid; border-right: 2px solid;
28
+ border-bottom: none; height: 30%; opacity: 0.5;
29
+ }
1630
.tl-date { font-size: 0.8rem; font-weight: 700; color: #d1d5db; padding: 8px 0 4px; border-bottom: 1px solid #374151; margin-bottom: 2px; }
1731
.tl-row { display: flex; min-height: 28px; align-items: center; }
1832
.tl-row:hover { background: rgba(255,255,255,0.02); }
1933
.tl-time { width: 42px; flex-shrink: 0; text-align: right; font-size: 0.75rem; color: #6b7280; font-variant-numeric: tabular-nums; }
2034
.tl-msg { flex: 1; min-width: 0; font-size: 0.8125rem; color: #e5e5e5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -44,18 +58,32 @@
4458
{% endifchanged %}
4559
4660
<div class="tl-row">
4761
{# DAG column #}
4862
<div class="tl-dag" style="width: {{ item.graph_width }}px;">
63
+ {# Vertical rail lines — colored per rail #}
4964
{% for line in item.lines %}
50
- <div class="tl-vline tl-vline-ci" style="left: {{ line.x }}px;"></div>
65
+ <div class="tl-vline" style="left: {{ line.x }}px; background: {{ line.color }};"></div>
5166
{% endfor %}
52
- <div class="tl-node {% if e.is_merge %}tl-node-merge{% elif e.event_type == 'ci' %}tl-node-ci{% elif e.event_type == 'w' %}tl-node-w{% elif e.event_type == 't' %}tl-node-t{% elif e.event_type == 'f' %}tl-node-f{% else %}tl-node-other{% endif %}"
53
- style="left: {{ item.node_x }}px;"></div>
67
+ {# Horizontal connectors — fork (branch-out) and merge (branch-in) #}
5468
{% for conn in item.connectors %}
55
- <div style="position:absolute; top:40%; height:30%; left:{{ conn.left }}px; width:{{ conn.width }}px; border-bottom:2px solid rgba(220,57,76,0.35); border-left:2px solid rgba(220,57,76,0.35); border-right:2px solid rgba(220,57,76,0.35); border-radius:0 0 4px 4px; z-index:1;"></div>
69
+ <div class="{% if conn.type == 'merge' %}tl-connector-merge{% else %}tl-connector{% endif %}"
70
+ style="left:{{ conn.left }}px; width:{{ conn.width }}px; border-color:{{ conn.color }};"></div>
5671
{% endfor %}
72
+ {# Node — style varies: merge diamond, leaf open circle, normal filled circle #}
73
+ {% if e.event_type == 'ci' %}
74
+ <div class="tl-node {% if item.is_merge %}tl-node-merge{% elif item.is_leaf %}tl-node-ci tl-node-leaf{% else %}tl-node-ci{% endif %}"
75
+ style="left: {{ item.node_x }}px; {% if item.is_merge %}background: {{ item.node_color }}; border-color: {{ item.node_color }};{% elif item.is_leaf %}border-color: {{ item.node_color }};{% else %}background: {{ item.node_color }}; border-color: {{ item.node_color }};{% endif %}"></div>
76
+ {% elif e.event_type == 'w' %}
77
+ <div class="tl-node tl-node-w" style="left: {{ item.node_x }}px;"></div>
78
+ {% elif e.event_type == 't' %}
79
+ <div class="tl-node tl-node-t" style="left: {{ item.node_x }}px;"></div>
80
+ {% elif e.event_type == 'f' %}
81
+ <div class="tl-node tl-node-f" style="left: {{ item.node_x }}px;"></div>
82
+ {% else %}
83
+ <div class="tl-node tl-node-other" style="left: {{ item.node_x }}px;"></div>
84
+ {% endif %}
5785
</div>
5886
5987
{# Time #}
6088
<div class="tl-time">{{ e.timestamp|date:"H:i" }}</div>
6189
6290
6391
ADDED templates/fossil/release_detail.html
6492
ADDED templates/fossil/release_form.html
6593
ADDED templates/fossil/release_list.html
6694
ADDED tests/test_releases.py
--- templates/fossil/partials/timeline_entries.html
+++ templates/fossil/partials/timeline_entries.html
@@ -2,19 +2,33 @@
2 .tl-dag { position: relative; flex-shrink: 0; }
3 .tl-node {
4 position: absolute; top: 50%; z-index: 2; border-radius: 50%;
5 transform: translate(-50%, -50%); width: 10px; height: 10px;
6 }
7 .tl-node-ci { background: #DC394C; border: 2px solid #e8677a; }
8 .tl-node-merge { background: #DC394C; border: 2px solid #e8677a; border-radius: 2px; transform: translate(-50%, -50%) rotate(45deg); }
 
 
 
 
 
 
9 .tl-node-w { background: #3b82f6; border: 2px solid #60a5fa; width: 8px; height: 8px; }
10 .tl-node-t { background: #eab308; border: 2px solid #facc15; width: 8px; height: 8px; }
11 .tl-node-f { background: #a855f7; border: 2px solid #c084fc; width: 8px; height: 8px; }
12 .tl-node-other { background: #6b7280; border: 2px solid #9ca3af; width: 8px; height: 8px; }
13 .tl-vline { position: absolute; width: 2px; top: 0; bottom: 0; transform: translateX(-50%); }
14 .tl-vline-ci { background: rgba(220,57,76,0.3); }
15 .tl-vline-other { background: rgba(107,114,128,0.2); }
 
 
 
 
 
 
 
 
16 .tl-date { font-size: 0.8rem; font-weight: 700; color: #d1d5db; padding: 8px 0 4px; border-bottom: 1px solid #374151; margin-bottom: 2px; }
17 .tl-row { display: flex; min-height: 28px; align-items: center; }
18 .tl-row:hover { background: rgba(255,255,255,0.02); }
19 .tl-time { width: 42px; flex-shrink: 0; text-align: right; font-size: 0.75rem; color: #6b7280; font-variant-numeric: tabular-nums; }
20 .tl-msg { flex: 1; min-width: 0; font-size: 0.8125rem; color: #e5e5e5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -44,18 +58,32 @@
44 {% endifchanged %}
45
46 <div class="tl-row">
47 {# DAG column #}
48 <div class="tl-dag" style="width: {{ item.graph_width }}px;">
 
49 {% for line in item.lines %}
50 <div class="tl-vline tl-vline-ci" style="left: {{ line.x }}px;"></div>
51 {% endfor %}
52 <div class="tl-node {% if e.is_merge %}tl-node-merge{% elif e.event_type == 'ci' %}tl-node-ci{% elif e.event_type == 'w' %}tl-node-w{% elif e.event_type == 't' %}tl-node-t{% elif e.event_type == 'f' %}tl-node-f{% else %}tl-node-other{% endif %}"
53 style="left: {{ item.node_x }}px;"></div>
54 {% for conn in item.connectors %}
55 <div style="position:absolute; top:40%; height:30%; left:{{ conn.left }}px; width:{{ conn.width }}px; border-bottom:2px solid rgba(220,57,76,0.35); border-left:2px solid rgba(220,57,76,0.35); border-right:2px solid rgba(220,57,76,0.35); border-radius:0 0 4px 4px; z-index:1;"></div>
 
56 {% endfor %}
 
 
 
 
 
 
 
 
 
 
 
 
 
57 </div>
58
59 {# Time #}
60 <div class="tl-time">{{ e.timestamp|date:"H:i" }}</div>
61
62
63 DDED templates/fossil/release_detail.html
64 DDED templates/fossil/release_form.html
65 DDED templates/fossil/release_list.html
66 DDED tests/test_releases.py
--- templates/fossil/partials/timeline_entries.html
+++ templates/fossil/partials/timeline_entries.html
@@ -2,19 +2,33 @@
2 .tl-dag { position: relative; flex-shrink: 0; }
3 .tl-node {
4 position: absolute; top: 50%; z-index: 2; border-radius: 50%;
5 transform: translate(-50%, -50%); width: 10px; height: 10px;
6 }
7 .tl-node-ci { border: 2px solid; }
8 .tl-node-merge {
9 border: 2px solid; border-radius: 2px;
10 transform: translate(-50%, -50%) rotate(45deg);
11 }
12 .tl-node-leaf {
13 border: 2px solid; background: transparent !important;
14 }
15 .tl-node-w { background: #3b82f6; border: 2px solid #60a5fa; width: 8px; height: 8px; }
16 .tl-node-t { background: #eab308; border: 2px solid #facc15; width: 8px; height: 8px; }
17 .tl-node-f { background: #a855f7; border: 2px solid #c084fc; width: 8px; height: 8px; }
18 .tl-node-other { background: #6b7280; border: 2px solid #9ca3af; width: 8px; height: 8px; }
19 .tl-vline { position: absolute; width: 2px; top: 0; bottom: 0; transform: translateX(-50%); opacity: 0.4; }
20 .tl-connector {
21 position: absolute; top: 40%; z-index: 1; border-radius: 0 0 4px 4px;
22 border-bottom: 2px solid; border-left: 2px solid; border-right: 2px solid;
23 height: 30%; opacity: 0.5;
24 }
25 .tl-connector-merge {
26 top: 20%; border-radius: 4px 4px 0 0;
27 border-top: 2px solid; border-left: 2px solid; border-right: 2px solid;
28 border-bottom: none; height: 30%; opacity: 0.5;
29 }
30 .tl-date { font-size: 0.8rem; font-weight: 700; color: #d1d5db; padding: 8px 0 4px; border-bottom: 1px solid #374151; margin-bottom: 2px; }
31 .tl-row { display: flex; min-height: 28px; align-items: center; }
32 .tl-row:hover { background: rgba(255,255,255,0.02); }
33 .tl-time { width: 42px; flex-shrink: 0; text-align: right; font-size: 0.75rem; color: #6b7280; font-variant-numeric: tabular-nums; }
34 .tl-msg { flex: 1; min-width: 0; font-size: 0.8125rem; color: #e5e5e5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -44,18 +58,32 @@
58 {% endifchanged %}
59
60 <div class="tl-row">
61 {# DAG column #}
62 <div class="tl-dag" style="width: {{ item.graph_width }}px;">
63 {# Vertical rail lines — colored per rail #}
64 {% for line in item.lines %}
65 <div class="tl-vline" style="left: {{ line.x }}px; background: {{ line.color }};"></div>
66 {% endfor %}
67 {# Horizontal connectors — fork (branch-out) and merge (branch-in) #}
 
68 {% for conn in item.connectors %}
69 <div class="{% if conn.type == 'merge' %}tl-connector-merge{% else %}tl-connector{% endif %}"
70 style="left:{{ conn.left }}px; width:{{ conn.width }}px; border-color:{{ conn.color }};"></div>
71 {% endfor %}
72 {# Node — style varies: merge diamond, leaf open circle, normal filled circle #}
73 {% if e.event_type == 'ci' %}
74 <div class="tl-node {% if item.is_merge %}tl-node-merge{% elif item.is_leaf %}tl-node-ci tl-node-leaf{% else %}tl-node-ci{% endif %}"
75 style="left: {{ item.node_x }}px; {% if item.is_merge %}background: {{ item.node_color }}; border-color: {{ item.node_color }};{% elif item.is_leaf %}border-color: {{ item.node_color }};{% else %}background: {{ item.node_color }}; border-color: {{ item.node_color }};{% endif %}"></div>
76 {% elif e.event_type == 'w' %}
77 <div class="tl-node tl-node-w" style="left: {{ item.node_x }}px;"></div>
78 {% elif e.event_type == 't' %}
79 <div class="tl-node tl-node-t" style="left: {{ item.node_x }}px;"></div>
80 {% elif e.event_type == 'f' %}
81 <div class="tl-node tl-node-f" style="left: {{ item.node_x }}px;"></div>
82 {% else %}
83 <div class="tl-node tl-node-other" style="left: {{ item.node_x }}px;"></div>
84 {% endif %}
85 </div>
86
87 {# Time #}
88 <div class="tl-time">{{ e.timestamp|date:"H:i" }}</div>
89
90
91 DDED templates/fossil/release_detail.html
92 DDED templates/fossil/release_form.html
93 DDED templates/fossil/release_list.html
94 DDED tests/test_releases.py
--- a/templates/fossil/release_detail.html
+++ b/templates/fossil/release_detail.html
@@ -0,0 +1,27 @@
1
+{% extends "base.html" %}
2
+{% load humanize %}
3
+{% block title %}{{ release.tag_name }} — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+{% include "fossil/_project_nav.html" %}
8
+
9
+<div class="mb-4">
10
+ <a href="{% url 'fossil:releases' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to releases</a>
11
+</div>
12
+
13
+<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
14
+ <!-- Header -->
15
+ <div class="px-6 py-5 border-b border-gray-700">
16
+ <div class="flex items-start justify-between gap-4">
17
+ <div class="flex-1">
18
+ <div class="flex items-center gap-3 mb-1">
19
+ <h2 class="text-xl font-bold text-gray-100">{{ release.tag_name }}</h2>
20
+ {% if release.is_prerelease %}
21
+ <span class="inline-flex rounded-full bg-yellow-900/50 px-2.5 py-0.5 text-xs font-semibold text-yellow-300">Pre-release</span>
22
+ {% endif %}
23
+ {% if release.is_draft %}
24
+ <span class="inline-flex rounded-full bg-gray-700 px-2.5 py-0.5 text-xs font-semibold text-gray-400">Draft</span>
25
+ {% endif %}
26
+ </div>
27
+ <p class="text-sm text-
--- a/templates/fossil/release_detail.html
+++ b/templates/fossil/release_detail.html
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/release_detail.html
+++ b/templates/fossil/release_detail.html
@@ -0,0 +1,27 @@
1 {% extends "base.html" %}
2 {% load humanize %}
3 {% block title %}{{ release.tag_name }} — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="mb-4">
10 <a href="{% url 'fossil:releases' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to releases</a>
11 </div>
12
13 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
14 <!-- Header -->
15 <div class="px-6 py-5 border-b border-gray-700">
16 <div class="flex items-start justify-between gap-4">
17 <div class="flex-1">
18 <div class="flex items-center gap-3 mb-1">
19 <h2 class="text-xl font-bold text-gray-100">{{ release.tag_name }}</h2>
20 {% if release.is_prerelease %}
21 <span class="inline-flex rounded-full bg-yellow-900/50 px-2.5 py-0.5 text-xs font-semibold text-yellow-300">Pre-release</span>
22 {% endif %}
23 {% if release.is_draft %}
24 <span class="inline-flex rounded-full bg-gray-700 px-2.5 py-0.5 text-xs font-semibold text-gray-400">Draft</span>
25 {% endif %}
26 </div>
27 <p class="text-sm text-
--- a/templates/fossil/release_form.html
+++ b/templates/fossil/release_form.html
@@ -0,0 +1,62 @@
1
+{% extends "base.html" %}
2
+
3
+{% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block extra_head %}
6
+<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
7
+{% endblock %}
8
+
9
+{% block content %}
10
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
11
+{% include "fossil/_project_nav.html" %}
12
+
13
+<div class="mb-4">
14
+ <a href="{% url 'fossil:releases' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Releases</a>
15
+</div>
16
+
17
+<div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }">
18
+ <div class="flex items-center justify-between mb-4">
19
+ <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2>
20
+ <div class="flex items-center gap-1 text-xs">
21
+ <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button>
22
+ <button @click="tab = 'preview'; document.getElementById('marked.parse(document.getEleme" :classy-700 bg-gray-800 t ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button>
23
+ </div>
24
+ </div>
25
+
26
+ <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
27
+ {% csrf_token %}
28
+
29
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
30
+ <div>
31
+ <label class="block text-sm font-medium text-gray-300 mb-1">Tag Name <span class="text-red-400">*</span></label>
32
+ <input type="text" name="tag_name" required placeholder="v1.0.0"
33
+ value="{% if release %}{{ release.tag_name }}{% endif %}"
34
+ class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono" />
35
+ </div>
36
+ <div>
37
+ <label class="block text-sm font-medium text-gray-300 mb-1">Release Name <span class="text-red-400">*</span></label>
38
+ <input type="text" name="name" required placeholder="Version 1.0.0 -- Initial Release"
39
+ value="{% if release %}{{ release.name }}{% endif %}"
40
+ class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" />
41
+ </div>
42
+ </div>
43
+
44
+ {% if recent_checkins %}
45
+ <div>
46
+ <label class="block text-sm font-medium text-gray-300 mb-1">Target Checkin (optional)</label>
47
+ <select name="checkin_uuid"
48
+ class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
49
+ <option value="">-- None --</option>
50
+ {% for ci in recent_checkins %}
51
+ <option value="{{ ci.uuid }}"
52
+ {% if release and release.checkin_uuid == ci.uuid %}selected{% endif %}>
53
+ {{ ci.uuid|truncatechars:12 }} -- {{ ci.comment|truncatechars:60 }}
54
+ </option>
55
+ {% endfor %}
56
+ </select>
57
+ </div>
58
+ {% endif %}
59
+
60
+ <div x-show="tab === 'write'">
61
+ <label class="block text-sm font-medium text-gray-300 mb-1">Changelog (Markdown)</label>
62
+ <textarea id="body-input" name="body" rows="14" placeholder="## What's new&#10;&#10;- Feature A&#10;- Bug fix
--- a/templates/fossil/release_form.html
+++ b/templates/fossil/release_form.html
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/release_form.html
+++ b/templates/fossil/release_form.html
@@ -0,0 +1,62 @@
1 {% extends "base.html" %}
2
3 {% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block extra_head %}
6 <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
7 {% endblock %}
8
9 {% block content %}
10 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
11 {% include "fossil/_project_nav.html" %}
12
13 <div class="mb-4">
14 <a href="{% url 'fossil:releases' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Releases</a>
15 </div>
16
17 <div class="mx-auto max-w-3xl" x-data="{ tab: 'write' }">
18 <div class="flex items-center justify-between mb-4">
19 <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2>
20 <div class="flex items-center gap-1 text-xs">
21 <button @click="tab = 'write'" :class="tab === 'write' ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Write</button>
22 <button @click="tab = 'preview'; document.getElementById('marked.parse(document.getEleme" :classy-700 bg-gray-800 t ? 'bg-brand text-white' : 'text-gray-500 hover:text-white'" class="px-3 py-1 rounded">Preview</button>
23 </div>
24 </div>
25
26 <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
27 {% csrf_token %}
28
29 <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
30 <div>
31 <label class="block text-sm font-medium text-gray-300 mb-1">Tag Name <span class="text-red-400">*</span></label>
32 <input type="text" name="tag_name" required placeholder="v1.0.0"
33 value="{% if release %}{{ release.tag_name }}{% endif %}"
34 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono" />
35 </div>
36 <div>
37 <label class="block text-sm font-medium text-gray-300 mb-1">Release Name <span class="text-red-400">*</span></label>
38 <input type="text" name="name" required placeholder="Version 1.0.0 -- Initial Release"
39 value="{% if release %}{{ release.name }}{% endif %}"
40 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" />
41 </div>
42 </div>
43
44 {% if recent_checkins %}
45 <div>
46 <label class="block text-sm font-medium text-gray-300 mb-1">Target Checkin (optional)</label>
47 <select name="checkin_uuid"
48 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm">
49 <option value="">-- None --</option>
50 {% for ci in recent_checkins %}
51 <option value="{{ ci.uuid }}"
52 {% if release and release.checkin_uuid == ci.uuid %}selected{% endif %}>
53 {{ ci.uuid|truncatechars:12 }} -- {{ ci.comment|truncatechars:60 }}
54 </option>
55 {% endfor %}
56 </select>
57 </div>
58 {% endif %}
59
60 <div x-show="tab === 'write'">
61 <label class="block text-sm font-medium text-gray-300 mb-1">Changelog (Markdown)</label>
62 <textarea id="body-input" name="body" rows="14" placeholder="## What's new&#10;&#10;- Feature A&#10;- Bug fix
--- a/templates/fossil/release_list.html
+++ b/templates/fossil/release_list.html
@@ -0,0 +1,25 @@
1
+{% extends "base.html" %}
2
+{% load humanize %}
3
+{% block title %}Releases — {{ project.name }} — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7
+{% include "fossil/_project_nav.html" %}
8
+
9
+<div class="flex items-center justify-between mb-6">
10
+ <h2 class="text-lg font-semibold text{% if has_writel" %}
11
+{% load humanize %}
12
+{% block title %}Releases — {{ project.name }} — F{% extends "base.html" %}
13
+{% load humanize %}
14
+{% block title %}Releases — {{ project.name }} — Fossilrepo{% endblock %}
15
+
16
+{% block Rer gap-3 mb-1">
17
+ <a href
18
+{% if releases %}
19
+<div class="space-y-4">
20
+ {% for release in releases %}
21
+ <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
22
+ <div class="px-6 py-4">
23
+ <div class="flex items-start justify-between gap-4">
24
+ <div class="flex-1 min-w-0">
25
+ <d
--- a/templates/fossil/release_list.html
+++ b/templates/fossil/release_list.html
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/release_list.html
+++ b/templates/fossil/release_list.html
@@ -0,0 +1,25 @@
1 {% extends "base.html" %}
2 {% load humanize %}
3 {% block title %}Releases — {{ project.name }} — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="flex items-center justify-between mb-6">
10 <h2 class="text-lg font-semibold text{% if has_writel" %}
11 {% load humanize %}
12 {% block title %}Releases — {{ project.name }} — F{% extends "base.html" %}
13 {% load humanize %}
14 {% block title %}Releases — {{ project.name }} — Fossilrepo{% endblock %}
15
16 {% block Rer gap-3 mb-1">
17 <a href
18 {% if releases %}
19 <div class="space-y-4">
20 {% for release in releases %}
21 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
22 <div class="px-6 py-4">
23 <div class="flex items-start justify-between gap-4">
24 <div class="flex-1 min-w-0">
25 <d
--- a/tests/test_releases.py
+++ b/tests/test_releases.py
@@ -0,0 +1,318 @@
1
+import pytest
2
+from django.core.files.uploadedfile import SimpleUploadedFile
3
+
4
+from fossil.models import FossilRepository
5
+from fossil.releases import Release, ReleaseAsset
6
+
7
+# File storage settings for tests -- the project only configures STORAGES["default"]
8
+# when USE_S3=true, so tests that use FileField need a local filesystem backend.
9
+_TEST_STORAGES = {
10
+ "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
11
+ "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
12
+}
13
+
14
+
15
+@pytest.fixture
16
+def fossil_repo_obj(sample_project):
17
+ """Return the auto-created FossilRepository for sample_project."""
18
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
19
+
20
+
21
+@pytest.fixture
22
+def release(fossil_repo_obj, admin_user):
23
+ return Release.objects.create(
24
+ repository=fossil_repo_obj,
25
+ tag_name="v1.0.0",
26
+ name="Version 1.0.0",
27
+ body="## Changelog\n\n- Initial release",
28
+ is_prerelease=False,
29
+ is_draft=False,
30
+ published_at="2026-04-01T00:00:00Z",
31
+ created_by=admin_user,
32
+ )
33
+
34
+
35
+@pytest.fixture
36
+def draft_release(fossil_repo_obj, admin_user):
37
+ return Release.objects.create(
38
+ repository=fossil_repo_obj,
39
+ tag_name="v2.0.0-beta",
40
+ name="Version 2.0.0 Beta",
41
+ body="Draft notes",
42
+ is_prerelease=True,
43
+ is_draft=True,
44
+ published_at=None,
45
+ created_by=admin_user,
46
+ )
47
+
48
+
49
+@pytest.fixture
50
+def release_asset(release, admin_user, tmp_path, settings):
51
+ settings.STORAGES = _TEST_STORAGES
52
+ settings.MEDIA_ROOT = str(tmp_path / "media")
53
+ uploaded = SimpleUploadedFile("app-v1.0.0.tar.gz", b"fake-tarball-content", content_type="application/gzip")
54
+ return ReleaseAsset.objects.create(
55
+ release=release,
56
+ name="app-v1.0.0.tar.gz",
57
+ file=uploaded,
58
+ file_size_bytes=len(b"fake-tarball-content"),
59
+ content_type="application/gzip",
60
+ created_by=admin_user,
61
+ )
62
+
63
+
64
+@pytest.mark.django_db
65
+class TestReleaseository=fossil_repo_obj,
66
+ tag_name="v1.0.0",
67
+ name="Version 1.0.0",
68
+ body="## Changelog\n\n- Initial release",
69
+ is_prerelease=False,
70
+ is_draft=False,
71
+ published_at="2026-04-01T00:00:00Z",
72
+ created_by=admin_user,
73
+ )
74
+
75
+
76
+@pytest.fixture
77
+def draft_release(fossil_repo_obj, admin_user):
78
+ return Release.objects.create(
79
+ repository=fossil_repo_obj,
80
+ tag_name="v2.0.0-beta",
81
+ name="Version 2.0.0 Beta",
82
+ body="Draft notes",
83
+ is_prerelease=True,
84
+ is_draft=True,
85
+ published_at=None,
86
+ created_by=admin_user,
87
+ )
88
+
89
+
90
+@pytest.fixture
91
+def release_asset(release, admin_user, tmp_path, settings):
92
+ settings.STORAGES = _TEST_STORAGES
93
+ settings.MEDIA_ROOT = str(tmp_path / "media")
94
+ uploaded = SimpleUploadedFile("app-v1.0.0.tar.gz", b"fake-tarball-content", content_type="application/gzip")
95
+ return ReleaseAsset.objects.create(
96
+ release=release,
97
+ name="app-v1.0.0.tar.gz",
98
+ file=uploaded,
99
+ file_size_bytes=len(b"fake-tarball-content"),
100
+ content_type="application/gzip",
101
+ created_by=admin_user,
102
+ )
103
+
104
+
105
+@pytest.fixture
106
+def draft_release_asset(draft_release, admin_user, tmp_path, settings):
107
+ settings.STORAGES = _TEST_STORAGES
108
+ settings.MEDIA_ROOT = str(tmp_path / "media")
109
+ uploaded = SimpleUploadedFile("beta-build.tar.gz", b"draft-content", content_type="application/gzip")
110
+ return ReleaseAsset.objects.create(
111
+ release=draft_release,
112
+ name="beta-build.tar.gz",
113
+ file=uploaded,
114
+ file_size_bytes=len(b"draft-content"),
115
+ content_type="application/gzip",
116
+ created_by=admin_user,
117
+ )
118
+
119
+
120
+@pytest.mark.django_db
121
+class TestReleaseModel:
122
+ def test_create_release(self, release):
123
+ assert release.pk is not None
124
+ assert str(release) == "v1.0.0: Version 1.0.0"
125
+
126
+ def test_unique_tag_per_repo(self, fossil_repo_obj, admin_user):
127
+ Release.objects.create(
128
+ repository=fossil_repo_obj,
129
+ tag_name="v3.0.0",
130
+ name="First",
131
+ created_by=admin_user,
132
+ )
133
+ from django.db import IntegrityError
134
+
135
+ with pytest.raises(IntegrityError):
136
+ Release.objects.create(
137
+ repository=fossil_repo_obj,
138
+ tag_name="v3.0.0",
139
+ name="Duplicate",
140
+ created_by=admin_user,
141
+ )
142
+
143
+ def test_soft_delete(self, release, admin_user):
144
+ release.soft_delete(user=admin_user)
145
+ assert release.is_deleted
146
+ assert Release.objects.filter(pk=release.pk).count() == 0
147
+ assert Release.all_objects.filter(pk=release.pk).count() == 1
148
+
149
+ def test_ordering(self, fossil_repo_obj, admin_user):
150
+ r1 = Release.objects.create(
151
+ repository=fossil_repo_obj,
152
+ tag_name="v0.1.0",
153
+ name="Old",
154
+ published_at="2025-01-01T00:00:00Z",
155
+ created_by=admin_user,
156
+ )
157
+ r2 = Release.objects.create(
158
+ repository=fossil_repo_obj,
159
+ tag_name="v0.2.0",
160
+ name="Newer",
161
+ published_at="2026-06-01T00:00:00Z",
162
+ created_by=admin_user,
163
+ )
164
+ releases = list(Release.objects.filter(repository=fossil_repo_obj))
165
+ assert releases[0] == r2
166
+ assert releases[-1] == r1
167
+
168
+
169
+@pytest.mark.django_db
170
+class TestReleaseAssetModel:
171
+ def test_create_asset(self, release_asset):
172
+ assert release_asset.pk is not None
173
+ assert str(release_asset) == "app-v1.0.0.tar.gz"
174
+ assert release_asset.file_size_bytes == len(b"fake-tarball-content")
175
+
176
+ def test_soft_delete(self, release_asset, admin_user):
177
+ release_asset.soft_delete(user=admin_user)
178
+ assert release_asset.is_deleted
179
+ assert ReleaseAsset.objects.filter(pk=release_asset.pk).count() == 0
180
+ assert ReleaseAsset.all_objects.filter(pk=release_asset.pk).count() == 1
181
+
182
+
183
+@pytest.mark.django_db
184
+class TestReleaseListView:
185
+ def test_list_releases(self, admin_client, sample_project, release):
186
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
187
+ assert response.status_code == 200
188
+ content = response.content.decode()
189
+ assert "v1.0.0" in content
190
+ assert "Version 1.0.0" in content
191
+
192
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
193
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
194
+ assert response.status_code == 200
195
+ assert "No releases yet" in response.content.decode()
196
+
197
+ def test_drafts_hidden_from_non_writers(self, no_perm_client, sample_project, draft_release):
198
+ # Make project public so no_perm_user can read it
199
+ sample_project.visibility = "public"
200
+ sample_project.save()
201
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
202
+ assert response.status_code == 200
203
+ assert "v2.0.0-beta" not in response.content.decode()
204
+
205
+ def test_drafts_visible_to_writers(self, admin_client, sample_project, draft_release):
206
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
207
+ assert response.status_code == 200
208
+ assert "v2.0.0-beta" in response.content.decode()
209
+
210
+ def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
211
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
212
+ assert response.status_code == 403
213
+
214
+
215
+@pytest.mark.django_db
216
+class TestReleaseDetailView:
217
+ def test_detail(self, admin_client, sample_project, release):
218
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/")
219
+ assert response.status_code == 200
220
+ content = response.content.decode()
221
+ assert "v1.0.0" in content
222
+ assert "Changelog" in content
223
+
224
+ def test_detail_with_assets(self, admin_client, sample_project, release, release_asset):
225
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/")
226
+ assert response.status_code == 200
227
+ content = response.content.decode()
228
+ assert "app-v1.0.0.tar.gz" in content
229
+ assert "Download" in content
230
+
231
+ def test_draft_detail_denied_for_non_writer(self, no_perm_client, sample_project, draft_release):
232
+ sample_project.visibility = "public"
233
+ sample_project.save()
234
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/")
235
+ assert response.status_code == 403
236
+
237
+ def test_detail_denied_for_no_perm_on_private(self, no_perm_client, sample_project, release):
238
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/")
239
+ assert response.status_code == 403
240
+
241
+
242
+@pytest.mark.django_db
243
+class TestReleaseCreateView:
244
+ def test_get_form(self, admin_client, sample_project):
245
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/create/")
246
+ assert response.status_code == 200
247
+ assert "Create Release" in response.content.decode()
248
+
249
+ def test_create_release(self, admin_client, sample_project, fossil_repo_obj):
250
+ response = admin_client.post(
251
+ f"/projects/{sample_project.slug}/fossil/releases/create/",
252
+ {"tag_name": "v5.0.0", "name": "Big Release", "body": "notes", "is_prerelease": "", "is_draft": ""},
253
+ )
254
+ assert response.status_code == 302
255
+ release = Release.objects.get(tag_name="v5.0.0")
256
+ assert release.name == "Big Release"
257
+ assert release.published_at is not None
258
+ assert release.is_draft is False
259
+
260
+ def test_create_draft_release(self, admin_client, sample_project, fossil_repo_obj):
261
+ response = admin_client.post(
262
+ f"/projects/{sample_project.slug}/fossil/releases/create/",
263
+ {"tag_name": "v6.0.0-rc1", "name": "RC", "body": "", "is_draft": "on"},
264
+ )
265
+ assert response.status_code == 302
266
+ release = Release.objects.get(tag_name="v6.0.0-rc1")
267
+ assert release.is_draft is True
268
+ assert release.published_at is None
269
+
270
+ def test_create_denied_for_no_perm(self, no_perm_client, sample_project):
271
+ response = no_perm_client.post(
272
+ f"/projects/{sample_project.slug}/fossil/releases/create/",
273
+ {"tag_name": "v9.0.0", "name": "Nope"},
274
+ )
275
+ assert response.status_code == 403
276
+
277
+ def test_create_denied_for_anon(self, client, sample_project):
278
+ response = client.get(f"/projects/{sample_project.slug}/fossil/releases/create/")
279
+ assert response.status_code == 302 # redirect to login
280
+
281
+
282
+@pytest.mark.django_db
283
+class TestReleaseEditView:
284
+ def test_get_edit_form(self, admin_client, sample_project, release):
285
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/edit/")
286
+ assert response.status_code == 200
287
+ content = response.content.decode()
288
+ assert "v1.0.0" in content
289
+ assert "Update Release" in content
290
+
291
+ def test_edit_release(self, admin_client, sample_project, release):
292
+ response = admin_client.post(
293
+ f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/edit/",
294
+ {"tag_name": "v1.0.1", "name": "Patched", "body": "fix", "is_prerelease": ""},
295
+ )
296
+ assert response.status_code == 302
297
+ release.refresh_from_db()
298
+ assert release.tag_name == "v1.0.1"
299
+ assert release.name == "Patched"
300
+
301
+ def test_edit_denied_for_no_perm(self, no_perm_client, sample_project, release):
302
+ response = no_perm_client.post(
303
+ f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/edit/",
304
+ {"tag_name": "v1.0.1", "name": "Hacked"},
305
+ )
306
+ assert response.status_code == 403
307
+
308
+
309
+@pytest.mark.django_db
310
+class TestReleaseDeleteView:
311
+ def test_delete_release(self, admin_client, sample_project, release):
312
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/delete/")
313
+ assert response.status_code == 302
314
+ release.refresh_from_db()
315
+ assert release.is_deleted
316
+
317
+ def test_delete_get_redirects(self, admin_client, sample_project, release):
318
+ response = admin_client.get(f"/project
--- a/tests/test_releases.py
+++ b/tests/test_releases.py
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_releases.py
+++ b/tests/test_releases.py
@@ -0,0 +1,318 @@
1 import pytest
2 from django.core.files.uploadedfile import SimpleUploadedFile
3
4 from fossil.models import FossilRepository
5 from fossil.releases import Release, ReleaseAsset
6
7 # File storage settings for tests -- the project only configures STORAGES["default"]
8 # when USE_S3=true, so tests that use FileField need a local filesystem backend.
9 _TEST_STORAGES = {
10 "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
11 "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
12 }
13
14
15 @pytest.fixture
16 def fossil_repo_obj(sample_project):
17 """Return the auto-created FossilRepository for sample_project."""
18 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
19
20
21 @pytest.fixture
22 def release(fossil_repo_obj, admin_user):
23 return Release.objects.create(
24 repository=fossil_repo_obj,
25 tag_name="v1.0.0",
26 name="Version 1.0.0",
27 body="## Changelog\n\n- Initial release",
28 is_prerelease=False,
29 is_draft=False,
30 published_at="2026-04-01T00:00:00Z",
31 created_by=admin_user,
32 )
33
34
35 @pytest.fixture
36 def draft_release(fossil_repo_obj, admin_user):
37 return Release.objects.create(
38 repository=fossil_repo_obj,
39 tag_name="v2.0.0-beta",
40 name="Version 2.0.0 Beta",
41 body="Draft notes",
42 is_prerelease=True,
43 is_draft=True,
44 published_at=None,
45 created_by=admin_user,
46 )
47
48
49 @pytest.fixture
50 def release_asset(release, admin_user, tmp_path, settings):
51 settings.STORAGES = _TEST_STORAGES
52 settings.MEDIA_ROOT = str(tmp_path / "media")
53 uploaded = SimpleUploadedFile("app-v1.0.0.tar.gz", b"fake-tarball-content", content_type="application/gzip")
54 return ReleaseAsset.objects.create(
55 release=release,
56 name="app-v1.0.0.tar.gz",
57 file=uploaded,
58 file_size_bytes=len(b"fake-tarball-content"),
59 content_type="application/gzip",
60 created_by=admin_user,
61 )
62
63
64 @pytest.mark.django_db
65 class TestReleaseository=fossil_repo_obj,
66 tag_name="v1.0.0",
67 name="Version 1.0.0",
68 body="## Changelog\n\n- Initial release",
69 is_prerelease=False,
70 is_draft=False,
71 published_at="2026-04-01T00:00:00Z",
72 created_by=admin_user,
73 )
74
75
76 @pytest.fixture
77 def draft_release(fossil_repo_obj, admin_user):
78 return Release.objects.create(
79 repository=fossil_repo_obj,
80 tag_name="v2.0.0-beta",
81 name="Version 2.0.0 Beta",
82 body="Draft notes",
83 is_prerelease=True,
84 is_draft=True,
85 published_at=None,
86 created_by=admin_user,
87 )
88
89
90 @pytest.fixture
91 def release_asset(release, admin_user, tmp_path, settings):
92 settings.STORAGES = _TEST_STORAGES
93 settings.MEDIA_ROOT = str(tmp_path / "media")
94 uploaded = SimpleUploadedFile("app-v1.0.0.tar.gz", b"fake-tarball-content", content_type="application/gzip")
95 return ReleaseAsset.objects.create(
96 release=release,
97 name="app-v1.0.0.tar.gz",
98 file=uploaded,
99 file_size_bytes=len(b"fake-tarball-content"),
100 content_type="application/gzip",
101 created_by=admin_user,
102 )
103
104
105 @pytest.fixture
106 def draft_release_asset(draft_release, admin_user, tmp_path, settings):
107 settings.STORAGES = _TEST_STORAGES
108 settings.MEDIA_ROOT = str(tmp_path / "media")
109 uploaded = SimpleUploadedFile("beta-build.tar.gz", b"draft-content", content_type="application/gzip")
110 return ReleaseAsset.objects.create(
111 release=draft_release,
112 name="beta-build.tar.gz",
113 file=uploaded,
114 file_size_bytes=len(b"draft-content"),
115 content_type="application/gzip",
116 created_by=admin_user,
117 )
118
119
120 @pytest.mark.django_db
121 class TestReleaseModel:
122 def test_create_release(self, release):
123 assert release.pk is not None
124 assert str(release) == "v1.0.0: Version 1.0.0"
125
126 def test_unique_tag_per_repo(self, fossil_repo_obj, admin_user):
127 Release.objects.create(
128 repository=fossil_repo_obj,
129 tag_name="v3.0.0",
130 name="First",
131 created_by=admin_user,
132 )
133 from django.db import IntegrityError
134
135 with pytest.raises(IntegrityError):
136 Release.objects.create(
137 repository=fossil_repo_obj,
138 tag_name="v3.0.0",
139 name="Duplicate",
140 created_by=admin_user,
141 )
142
143 def test_soft_delete(self, release, admin_user):
144 release.soft_delete(user=admin_user)
145 assert release.is_deleted
146 assert Release.objects.filter(pk=release.pk).count() == 0
147 assert Release.all_objects.filter(pk=release.pk).count() == 1
148
149 def test_ordering(self, fossil_repo_obj, admin_user):
150 r1 = Release.objects.create(
151 repository=fossil_repo_obj,
152 tag_name="v0.1.0",
153 name="Old",
154 published_at="2025-01-01T00:00:00Z",
155 created_by=admin_user,
156 )
157 r2 = Release.objects.create(
158 repository=fossil_repo_obj,
159 tag_name="v0.2.0",
160 name="Newer",
161 published_at="2026-06-01T00:00:00Z",
162 created_by=admin_user,
163 )
164 releases = list(Release.objects.filter(repository=fossil_repo_obj))
165 assert releases[0] == r2
166 assert releases[-1] == r1
167
168
169 @pytest.mark.django_db
170 class TestReleaseAssetModel:
171 def test_create_asset(self, release_asset):
172 assert release_asset.pk is not None
173 assert str(release_asset) == "app-v1.0.0.tar.gz"
174 assert release_asset.file_size_bytes == len(b"fake-tarball-content")
175
176 def test_soft_delete(self, release_asset, admin_user):
177 release_asset.soft_delete(user=admin_user)
178 assert release_asset.is_deleted
179 assert ReleaseAsset.objects.filter(pk=release_asset.pk).count() == 0
180 assert ReleaseAsset.all_objects.filter(pk=release_asset.pk).count() == 1
181
182
183 @pytest.mark.django_db
184 class TestReleaseListView:
185 def test_list_releases(self, admin_client, sample_project, release):
186 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
187 assert response.status_code == 200
188 content = response.content.decode()
189 assert "v1.0.0" in content
190 assert "Version 1.0.0" in content
191
192 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
193 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
194 assert response.status_code == 200
195 assert "No releases yet" in response.content.decode()
196
197 def test_drafts_hidden_from_non_writers(self, no_perm_client, sample_project, draft_release):
198 # Make project public so no_perm_user can read it
199 sample_project.visibility = "public"
200 sample_project.save()
201 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
202 assert response.status_code == 200
203 assert "v2.0.0-beta" not in response.content.decode()
204
205 def test_drafts_visible_to_writers(self, admin_client, sample_project, draft_release):
206 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
207 assert response.status_code == 200
208 assert "v2.0.0-beta" in response.content.decode()
209
210 def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
211 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/")
212 assert response.status_code == 403
213
214
215 @pytest.mark.django_db
216 class TestReleaseDetailView:
217 def test_detail(self, admin_client, sample_project, release):
218 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/")
219 assert response.status_code == 200
220 content = response.content.decode()
221 assert "v1.0.0" in content
222 assert "Changelog" in content
223
224 def test_detail_with_assets(self, admin_client, sample_project, release, release_asset):
225 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/")
226 assert response.status_code == 200
227 content = response.content.decode()
228 assert "app-v1.0.0.tar.gz" in content
229 assert "Download" in content
230
231 def test_draft_detail_denied_for_non_writer(self, no_perm_client, sample_project, draft_release):
232 sample_project.visibility = "public"
233 sample_project.save()
234 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{draft_release.tag_name}/")
235 assert response.status_code == 403
236
237 def test_detail_denied_for_no_perm_on_private(self, no_perm_client, sample_project, release):
238 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/")
239 assert response.status_code == 403
240
241
242 @pytest.mark.django_db
243 class TestReleaseCreateView:
244 def test_get_form(self, admin_client, sample_project):
245 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/create/")
246 assert response.status_code == 200
247 assert "Create Release" in response.content.decode()
248
249 def test_create_release(self, admin_client, sample_project, fossil_repo_obj):
250 response = admin_client.post(
251 f"/projects/{sample_project.slug}/fossil/releases/create/",
252 {"tag_name": "v5.0.0", "name": "Big Release", "body": "notes", "is_prerelease": "", "is_draft": ""},
253 )
254 assert response.status_code == 302
255 release = Release.objects.get(tag_name="v5.0.0")
256 assert release.name == "Big Release"
257 assert release.published_at is not None
258 assert release.is_draft is False
259
260 def test_create_draft_release(self, admin_client, sample_project, fossil_repo_obj):
261 response = admin_client.post(
262 f"/projects/{sample_project.slug}/fossil/releases/create/",
263 {"tag_name": "v6.0.0-rc1", "name": "RC", "body": "", "is_draft": "on"},
264 )
265 assert response.status_code == 302
266 release = Release.objects.get(tag_name="v6.0.0-rc1")
267 assert release.is_draft is True
268 assert release.published_at is None
269
270 def test_create_denied_for_no_perm(self, no_perm_client, sample_project):
271 response = no_perm_client.post(
272 f"/projects/{sample_project.slug}/fossil/releases/create/",
273 {"tag_name": "v9.0.0", "name": "Nope"},
274 )
275 assert response.status_code == 403
276
277 def test_create_denied_for_anon(self, client, sample_project):
278 response = client.get(f"/projects/{sample_project.slug}/fossil/releases/create/")
279 assert response.status_code == 302 # redirect to login
280
281
282 @pytest.mark.django_db
283 class TestReleaseEditView:
284 def test_get_edit_form(self, admin_client, sample_project, release):
285 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/edit/")
286 assert response.status_code == 200
287 content = response.content.decode()
288 assert "v1.0.0" in content
289 assert "Update Release" in content
290
291 def test_edit_release(self, admin_client, sample_project, release):
292 response = admin_client.post(
293 f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/edit/",
294 {"tag_name": "v1.0.1", "name": "Patched", "body": "fix", "is_prerelease": ""},
295 )
296 assert response.status_code == 302
297 release.refresh_from_db()
298 assert release.tag_name == "v1.0.1"
299 assert release.name == "Patched"
300
301 def test_edit_denied_for_no_perm(self, no_perm_client, sample_project, release):
302 response = no_perm_client.post(
303 f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/edit/",
304 {"tag_name": "v1.0.1", "name": "Hacked"},
305 )
306 assert response.status_code == 403
307
308
309 @pytest.mark.django_db
310 class TestReleaseDeleteView:
311 def test_delete_release(self, admin_client, sample_project, release):
312 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/releases/{release.tag_name}/delete/")
313 assert response.status_code == 302
314 release.refresh_from_db()
315 assert release.is_deleted
316
317 def test_delete_get_redirects(self, admin_client, sample_project, release):
318 response = admin_client.get(f"/project

Keyboard Shortcuts

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