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.
Commit
dbe2a0b51386eaadff4cc86470e4ce34fe2bcdf4be533800a13ad29017b469c0
Parent
f46f192e71b8a95…
16 files changed
+6
-39
+19
+337
+1
+5
+47
+163
-1
+8
+392
-24
+4
+6
-6
+37
-9
+27
+62
+25
+318
~
.github/workflows/deploy.yaml
~
fossil/admin.py
+
fossil/migrations/0006_historicalrelease_release_historicalreleaseasset_and_more.py
~
fossil/models.py
~
fossil/reader.py
+
fossil/releases.py
~
fossil/tests.py
~
fossil/urls.py
~
fossil/views.py
~
templates/fossil/_project_nav.html
~
templates/fossil/code_blame.html
~
templates/fossil/partials/timeline_entries.html
+
templates/fossil/release_detail.html
+
templates/fossil/release_form.html
+
templates/fossil/release_list.html
+
tests/test_releases.py
+6
-39
| --- .github/workflows/deploy.yaml | ||
| +++ .github/workflows/deploy.yaml | ||
| @@ -6,62 +6,29 @@ | ||
| 6 | 6 | paths-ignore: |
| 7 | 7 | - 'docs/**' |
| 8 | 8 | - 'mkdocs.yml' |
| 9 | 9 | - '*.md' |
| 10 | 10 | |
| 11 | -env: | |
| 12 | - AWS_REGION: ${{ secrets.AWS_REGION }} | |
| 13 | - ECR_REPO: fossilrepo | |
| 14 | - INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }} | |
| 15 | - | |
| 16 | 11 | jobs: |
| 17 | 12 | ci: |
| 18 | 13 | uses: ./.github/workflows/ci.yaml |
| 19 | 14 | |
| 20 | 15 | deploy: |
| 21 | 16 | needs: [ci] |
| 22 | 17 | runs-on: ubuntu-latest |
| 23 | - permissions: | |
| 24 | - id-token: write | |
| 25 | - contents: read | |
| 26 | 18 | |
| 27 | 19 | steps: |
| 28 | - - uses: actions/checkout@v4 | |
| 29 | - | |
| 30 | 20 | - uses: aws-actions/configure-aws-credentials@v4 |
| 31 | 21 | with: |
| 32 | 22 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} |
| 33 | 23 | 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 | |
| 56 | 27 | run: | |
| 57 | 28 | aws ssm send-command \ |
| 58 | - --instance-ids "$INSTANCE_ID" \ | |
| 29 | + --instance-ids "${{ secrets.EC2_INSTANCE_ID }}" \ | |
| 59 | 30 | --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"]' \ | |
| 65 | 32 | --timeout-seconds 300 \ |
| 66 | 33 | --output text |
| 67 | - echo "Deploy command sent to $INSTANCE_ID" | |
| 34 | + echo "Deploy sent" | |
| 68 | 35 |
| --- .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 |
+19
| --- fossil/admin.py | ||
| +++ fossil/admin.py | ||
| @@ -2,10 +2,11 @@ | ||
| 2 | 2 | |
| 3 | 3 | from core.admin import BaseCoreAdmin |
| 4 | 4 | |
| 5 | 5 | from .models import FossilRepository, FossilSnapshot |
| 6 | 6 | from .notifications import Notification, ProjectWatch |
| 7 | +from .releases import Release, ReleaseAsset | |
| 7 | 8 | from .sync_models import GitMirror, SSHKey, SyncLog |
| 8 | 9 | from .user_keys import UserSSHKey |
| 9 | 10 | |
| 10 | 11 | |
| 11 | 12 | class FossilSnapshotInline(admin.TabularInline): |
| @@ -69,12 +70,30 @@ | ||
| 69 | 70 | list_display = ("user", "project", "event_filter", "email_enabled", "created_at") |
| 70 | 71 | list_filter = ("event_filter", "email_enabled") |
| 71 | 72 | search_fields = ("user__username", "project__name") |
| 72 | 73 | raw_id_fields = ("user", "project") |
| 73 | 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 | + | |
| 74 | 93 | |
| 75 | 94 | @admin.register(SyncLog) |
| 76 | 95 | class SyncLogAdmin(admin.ModelAdmin): |
| 77 | 96 | list_display = ("mirror", "status", "started_at", "completed_at", "artifacts_synced", "triggered_by") |
| 78 | 97 | list_filter = ("status", "triggered_by") |
| 79 | 98 | search_fields = ("mirror__repository__filename", "message") |
| 80 | 99 | raw_id_fields = ("mirror",) |
| 81 | 100 | |
| 82 | 101 | 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 | ] |
+1
| --- fossil/models.py | ||
| +++ fossil/models.py | ||
| @@ -66,7 +66,8 @@ | ||
| 66 | 66 | return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename |
| 67 | 67 | |
| 68 | 68 | |
| 69 | 69 | # Import related models so they're discoverable by Django |
| 70 | 70 | from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401 |
| 71 | +from fossil.releases import Release, ReleaseAsset # noqa: E402, F401 | |
| 71 | 72 | from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401 |
| 72 | 73 | from fossil.user_keys import UserSSHKey # noqa: E402, F401 |
| 73 | 74 |
| --- 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 |
+5
| --- fossil/reader.py | ||
| +++ fossil/reader.py | ||
| @@ -22,10 +22,11 @@ | ||
| 22 | 22 | user: str |
| 23 | 23 | comment: str |
| 24 | 24 | branch: str = "" |
| 25 | 25 | parent_rid: int = 0 # primary parent rid for DAG drawing |
| 26 | 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 | |
| 27 | 28 | rail: int = 0 # column position for DAG graph |
| 28 | 29 | |
| 29 | 30 | |
| 30 | 31 | @dataclass |
| 31 | 32 | class FileEntry: |
| @@ -543,16 +544,19 @@ | ||
| 543 | 544 | branch = br[0].replace("sym-", "", 1) |
| 544 | 545 | except sqlite3.OperationalError: |
| 545 | 546 | pass |
| 546 | 547 | |
| 547 | 548 | # Get parent info from plink for DAG |
| 549 | + merge_parent_rids = [] | |
| 548 | 550 | if row["type"] == "ci": |
| 549 | 551 | try: |
| 550 | 552 | parents = self.conn.execute("SELECT pid, isprim FROM plink WHERE cid=?", (row["rid"],)).fetchall() |
| 551 | 553 | for p in parents: |
| 552 | 554 | if p["isprim"]: |
| 553 | 555 | parent_rid = p["pid"] |
| 556 | + else: | |
| 557 | + merge_parent_rids.append(p["pid"]) | |
| 554 | 558 | is_merge = len(parents) > 1 |
| 555 | 559 | except sqlite3.OperationalError: |
| 556 | 560 | pass |
| 557 | 561 | |
| 558 | 562 | entries.append( |
| @@ -564,10 +568,11 @@ | ||
| 564 | 568 | user=row["user"] or "", |
| 565 | 569 | comment=row["comment"] or "", |
| 566 | 570 | branch=branch, |
| 567 | 571 | parent_rid=parent_rid, |
| 568 | 572 | is_merge=is_merge, |
| 573 | + merge_parent_rids=merge_parent_rids, | |
| 569 | 574 | ) |
| 570 | 575 | ) |
| 571 | 576 | except sqlite3.OperationalError: |
| 572 | 577 | pass |
| 573 | 578 | |
| 574 | 579 | |
| 575 | 580 | 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 |
+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 |
| --- 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 @@ | ||
| 1 | 1 | import shutil |
| 2 | +from datetime import UTC, datetime | |
| 2 | 3 | from pathlib import Path |
| 3 | 4 | |
| 4 | 5 | import pytest |
| 5 | 6 | |
| 6 | 7 | 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 | |
| 8 | 10 | |
| 9 | 11 | # --- Reader tests --- |
| 10 | 12 | |
| 11 | 13 | |
| 12 | 14 | @pytest.mark.django_db |
| @@ -125,10 +127,170 @@ | ||
| 125 | 127 | def test_empty_delta(self): |
| 126 | 128 | source = b"test content" |
| 127 | 129 | result = _apply_fossil_delta(source, b"") |
| 128 | 130 | assert result == source |
| 129 | 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 | + | |
| 130 | 292 | |
| 131 | 293 | # --- Model tests --- |
| 132 | 294 | |
| 133 | 295 | |
| 134 | 296 | @pytest.mark.django_db |
| 135 | 297 |
| --- 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 |
+8
| --- fossil/urls.py | ||
| +++ fossil/urls.py | ||
| @@ -42,6 +42,14 @@ | ||
| 42 | 42 | path("timeline/rss/", views.timeline_rss, name="timeline_rss"), |
| 43 | 43 | path("tickets/export/", views.tickets_csv, name="tickets_csv"), |
| 44 | 44 | path("docs/", views.fossil_docs, name="docs"), |
| 45 | 45 | path("docs/<path:doc_path>", views.fossil_doc_page, name="doc_page"), |
| 46 | 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"), | |
| 47 | 55 | ] |
| 48 | 56 |
| --- 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 @@ | ||
| 1 | 1 | import contextlib |
| 2 | 2 | import re |
| 3 | +from datetime import datetime | |
| 3 | 4 | |
| 4 | 5 | import markdown as md |
| 5 | 6 | from django.contrib.auth.decorators import login_required |
| 6 | 7 | from django.http import Http404, HttpResponse |
| 7 | 8 | from django.shortcuts import get_object_or_404, redirect, render |
| @@ -1566,10 +1567,39 @@ | ||
| 1566 | 1567 | cli = FossilCLI() |
| 1567 | 1568 | blame_lines = [] |
| 1568 | 1569 | if cli.is_available(): |
| 1569 | 1570 | blame_lines = cli.blame(fossil_repo.full_path, filepath) |
| 1570 | 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 | + | |
| 1571 | 1601 | parts = filepath.split("/") |
| 1572 | 1602 | file_breadcrumbs = [{"name": p, "path": "/".join(parts[: i + 1])} for i, p in enumerate(parts)] |
| 1573 | 1603 | |
| 1574 | 1604 | return render( |
| 1575 | 1605 | request, |
| @@ -1737,61 +1767,129 @@ | ||
| 1737 | 1767 | } |
| 1738 | 1768 | ) |
| 1739 | 1769 | |
| 1740 | 1770 | return entries |
| 1741 | 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 | + | |
| 1742 | 1788 | |
| 1743 | 1789 | def _compute_dag_graph(entries): |
| 1744 | 1790 | """Compute DAG graph positions for timeline entries. |
| 1745 | 1791 | |
| 1746 | 1792 | 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). | |
| 1748 | 1796 | """ |
| 1797 | + if not entries: | |
| 1798 | + return [] | |
| 1799 | + | |
| 1749 | 1800 | rail_pitch = 16 |
| 1750 | 1801 | rail_offset = 20 |
| 1751 | 1802 | max_rail = max((e.rail for e in entries if e.rail >= 0), default=0) |
| 1752 | 1803 | graph_width = rail_offset + (max_rail + 2) * rail_pitch |
| 1753 | 1804 | |
| 1754 | 1805 | # 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] = {} | |
| 1757 | 1808 | for i, entry in enumerate(entries): |
| 1758 | 1809 | rid_to_idx[entry.rid] = i |
| 1759 | 1810 | if entry.event_type == "ci": |
| 1760 | 1811 | rid_to_rail[entry.rid] = max(entry.rail, 0) |
| 1761 | 1812 | |
| 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 | |
| 1765 | 1818 | |
| 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) | |
| 1769 | 1832 | for i, entry in enumerate(entries): |
| 1770 | 1833 | if entry.event_type == "ci" and entry.parent_rid in rid_to_idx: |
| 1771 | 1834 | parent_idx = rid_to_idx[entry.parent_rid] |
| 1772 | 1835 | if parent_idx > i: |
| 1773 | 1836 | rail = max(entry.rail, 0) |
| 1774 | 1837 | active_spans.append((rail, i, parent_idx)) |
| 1775 | 1838 | |
| 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. | |
| 1780 | 1842 | 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": | |
| 1783 | 1848 | continue |
| 1784 | - parent_idx = rid_to_idx[entry.parent_rid] | |
| 1785 | 1849 | 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) | |
| 1793 | 1891 | |
| 1794 | 1892 | result = [] |
| 1795 | 1893 | for i, entry in enumerate(entries): |
| 1796 | 1894 | rail = max(entry.rail, 0) if entry.rail >= 0 else 0 |
| 1797 | 1895 | node_x = rail_offset + rail * rail_pitch |
| @@ -1800,19 +1898,289 @@ | ||
| 1800 | 1898 | active_rails = set() |
| 1801 | 1899 | for span_rail, span_start, span_end in active_spans: |
| 1802 | 1900 | if span_start <= i <= span_end: |
| 1803 | 1901 | active_rails.add(span_rail) |
| 1804 | 1902 | |
| 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)] | |
| 1806 | 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) | |
| 1807 | 1910 | |
| 1808 | 1911 | result.append( |
| 1809 | 1912 | { |
| 1810 | 1913 | "entry": entry, |
| 1811 | 1914 | "node_x": node_x, |
| 1915 | + "node_color": _rail_color(rail), | |
| 1812 | 1916 | "lines": lines, |
| 1813 | 1917 | "connectors": connectors, |
| 1814 | 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, | |
| 1815 | 1923 | } |
| 1816 | 1924 | ) |
| 1817 | 1925 | |
| 1818 | 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) | |
| 1819 | 2187 |
| --- 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 @@ | ||
| 24 | 24 | Wiki |
| 25 | 25 | </a> |
| 26 | 26 | <a href="{% url 'fossil:forum' slug=project.slug %}" |
| 27 | 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 | 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 | |
| 29 | 33 | </a> |
| 30 | 34 | {% if perms.projects.change_project %} |
| 31 | 35 | <a href="{% url 'fossil:sync' slug=project.slug %}" |
| 32 | 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 %}"> |
| 33 | 37 | {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %} |
| 34 | 38 |
| --- 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 |
+6
-6
| --- templates/fossil/code_blame.html | ||
| +++ templates/fossil/code_blame.html | ||
| @@ -4,12 +4,12 @@ | ||
| 4 | 4 | {% block extra_head %} |
| 5 | 5 | <style> |
| 6 | 6 | .blame-table { border-collapse: collapse; width: 100%; font-size: 0.75rem; font-family: ui-monospace, monospace; } |
| 7 | 7 | .blame-table td { padding: 0; vertical-align: top; line-height: 1.5rem; } |
| 8 | 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; } | |
| 9 | + .blame-meta a { text-decoration: none; } | |
| 10 | + .blame-meta a:hover { color: #DC394C !important; } | |
| 11 | 11 | .blame-num { width: 1%; white-space: nowrap; padding: 0 10px !important; text-align: right; color: #4b5563; border-right: 1px solid #374151; } |
| 12 | 12 | .blame-code { white-space: pre; padding: 0 16px !important; font-size: 0.8125rem; } |
| 13 | 13 | .blame-row:hover { background: rgba(220, 57, 76, 0.04); } |
| 14 | 14 | </style> |
| 15 | 15 | {% endblock %} |
| @@ -42,15 +42,15 @@ | ||
| 42 | 42 | {% if blame_lines %} |
| 43 | 43 | <table class="blame-table"> |
| 44 | 44 | <tbody> |
| 45 | 45 | {% for bl in blame_lines %} |
| 46 | 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> | |
| 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 | 49 | </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> | |
| 52 | 52 | </td> |
| 53 | 53 | <td class="blame-num">{{ forloop.counter }}</td> |
| 54 | 54 | <td class="blame-code">{{ bl.text }}</td> |
| 55 | 55 | </tr> |
| 56 | 56 | {% endfor %} |
| 57 | 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 { 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 @@ | ||
| 2 | 2 | .tl-dag { position: relative; flex-shrink: 0; } |
| 3 | 3 | .tl-node { |
| 4 | 4 | position: absolute; top: 50%; z-index: 2; border-radius: 50%; |
| 5 | 5 | transform: translate(-50%, -50%); width: 10px; height: 10px; |
| 6 | 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); } | |
| 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 | + } | |
| 9 | 15 | .tl-node-w { background: #3b82f6; border: 2px solid #60a5fa; width: 8px; height: 8px; } |
| 10 | 16 | .tl-node-t { background: #eab308; border: 2px solid #facc15; width: 8px; height: 8px; } |
| 11 | 17 | .tl-node-f { background: #a855f7; border: 2px solid #c084fc; width: 8px; height: 8px; } |
| 12 | 18 | .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 | + } | |
| 16 | 30 | .tl-date { font-size: 0.8rem; font-weight: 700; color: #d1d5db; padding: 8px 0 4px; border-bottom: 1px solid #374151; margin-bottom: 2px; } |
| 17 | 31 | .tl-row { display: flex; min-height: 28px; align-items: center; } |
| 18 | 32 | .tl-row:hover { background: rgba(255,255,255,0.02); } |
| 19 | 33 | .tl-time { width: 42px; flex-shrink: 0; text-align: right; font-size: 0.75rem; color: #6b7280; font-variant-numeric: tabular-nums; } |
| 20 | 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 @@ | ||
| 44 | 58 | {% endifchanged %} |
| 45 | 59 | |
| 46 | 60 | <div class="tl-row"> |
| 47 | 61 | {# DAG column #} |
| 48 | 62 | <div class="tl-dag" style="width: {{ item.graph_width }}px;"> |
| 63 | + {# Vertical rail lines — colored per rail #} | |
| 49 | 64 | {% 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> | |
| 51 | 66 | {% 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) #} | |
| 54 | 68 | {% 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> | |
| 56 | 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 %} | |
| 57 | 85 | </div> |
| 58 | 86 | |
| 59 | 87 | {# Time #} |
| 60 | 88 | <div class="tl-time">{{ e.timestamp|date:"H:i" }}</div> |
| 61 | 89 | |
| 62 | 90 | |
| 63 | 91 | ADDED templates/fossil/release_detail.html |
| 64 | 92 | ADDED templates/fossil/release_form.html |
| 65 | 93 | ADDED templates/fossil/release_list.html |
| 66 | 94 | 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">← 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">← 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">← 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 - Feature A - 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">← 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 - Feature A - 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 |
+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 |
| --- 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 |