FossilRepo

Add technotes CRUD, unversioned files, starring, explore page, audit log, CI status checks, API tokens, branch protection Technotes: create/edit via fossil CLI, detail view with markdown rendering. Unversioned content: file list, download, admin upload via fossil uv CLI. Files tab added to project nav. Starring: toggle star per project, star count on cards. Explore page with public project discovery, sort by stars/recent/name, search. Works for anonymous users (public repos only). Audit log: unified view of all model changes via django-simple-history, filterable by model type. Superuser/org-admin access. CI status checks: external API (Bearer token auth) for posting build status per checkin. Badges (SVG), status display on checkin detail. API tokens: project-scoped tokens with SHA-256 hashed storage, one-time raw token display on creation. Revoke UI. Branch protection: advisory rules per branch pattern with required CI contexts and push restrictions. CRUD management UI. 156 new tests across 8 test files.

lmata 2026-04-07 15:14 trunk
Commit 93c2492bfaff7336d58e4976312406b76ce97c4f5100ab1d722be7c46c583fec
--- config/urls.py
+++ config/urls.py
@@ -235,14 +235,21 @@
235235
</body>
236236
</html>"""
237237
238238
return HttpResponse(html)
239239
240
+
241
+def _explore_view(request):
242
+ from projects.views import explore
243
+
244
+ return explore(request)
245
+
240246
241247
urlpatterns = [
242248
path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
243249
path("status/", status_page, name="status"),
250
+ path("explore/", _explore_view, name="explore"),
244251
path("dashboard/", include("core.urls")),
245252
path("auth/", include("accounts.urls")),
246253
path("settings/", include("organization.urls")),
247254
path("projects/", include("projects.urls")),
248255
path("projects/<slug:slug>/fossil/", include("fossil.urls")),
249256
--- config/urls.py
+++ config/urls.py
@@ -235,14 +235,21 @@
235 </body>
236 </html>"""
237
238 return HttpResponse(html)
239
 
 
 
 
 
 
240
241 urlpatterns = [
242 path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
243 path("status/", status_page, name="status"),
 
244 path("dashboard/", include("core.urls")),
245 path("auth/", include("accounts.urls")),
246 path("settings/", include("organization.urls")),
247 path("projects/", include("projects.urls")),
248 path("projects/<slug:slug>/fossil/", include("fossil.urls")),
249
--- config/urls.py
+++ config/urls.py
@@ -235,14 +235,21 @@
235 </body>
236 </html>"""
237
238 return HttpResponse(html)
239
240
241 def _explore_view(request):
242 from projects.views import explore
243
244 return explore(request)
245
246
247 urlpatterns = [
248 path("", RedirectView.as_view(pattern_name="dashboard", permanent=False)),
249 path("status/", status_page, name="status"),
250 path("explore/", _explore_view, name="explore"),
251 path("dashboard/", include("core.urls")),
252 path("auth/", include("accounts.urls")),
253 path("settings/", include("organization.urls")),
254 path("projects/", include("projects.urls")),
255 path("projects/<slug:slug>/fossil/", include("fossil.urls")),
256
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,9 +1,12 @@
11
from django.contrib import admin
22
33
from core.admin import BaseCoreAdmin
44
5
+from .api_tokens import APIToken
6
+from .branch_protection import BranchProtection
7
+from .ci import StatusCheck
58
from .forum import ForumPost
69
from .models import FossilRepository, FossilSnapshot
710
from .notifications import Notification, ProjectWatch
811
from .releases import Release, ReleaseAsset
912
from .sync_models import GitMirror, SSHKey, SyncLog
@@ -126,5 +129,29 @@
126129
@admin.register(WebhookDelivery)
127130
class WebhookDeliveryAdmin(admin.ModelAdmin):
128131
list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms")
129132
list_filter = ("success", "event_type")
130133
raw_id_fields = ("webhook",)
134
+
135
+
136
+@admin.register(StatusCheck)
137
+class StatusCheckAdmin(BaseCoreAdmin):
138
+ list_display = ("context", "state", "checkin_uuid", "repository", "created_at")
139
+ list_filter = ("state",)
140
+ search_fields = ("context", "checkin_uuid")
141
+ raw_id_fields = ("repository",)
142
+
143
+
144
+@admin.register(APIToken)
145
+class APITokenAdmin(BaseCoreAdmin):
146
+ list_display = ("name", "token_prefix", "repository", "permissions", "last_used_at", "expires_at", "created_at")
147
+ search_fields = ("name", "token_prefix")
148
+ raw_id_fields = ("repository",)
149
+ readonly_fields = ("token_hash", "token_prefix")
150
+
151
+
152
+@admin.register(BranchProtection)
153
+class BranchProtectionAdmin(BaseCoreAdmin):
154
+ list_display = ("branch_pattern", "repository", "require_status_checks", "restrict_push", "created_at")
155
+ list_filter = ("require_status_checks", "restrict_push")
156
+ search_fields = ("branch_pattern",)
157
+ raw_id_fields = ("repository",)
131158
132159
ADDED fossil/api_tokens.py
133160
ADDED fossil/branch_protection.py
134161
ADDED fossil/ci.py
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,9 +1,12 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
 
 
 
5 from .forum import ForumPost
6 from .models import FossilRepository, FossilSnapshot
7 from .notifications import Notification, ProjectWatch
8 from .releases import Release, ReleaseAsset
9 from .sync_models import GitMirror, SSHKey, SyncLog
@@ -126,5 +129,29 @@
126 @admin.register(WebhookDelivery)
127 class WebhookDeliveryAdmin(admin.ModelAdmin):
128 list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms")
129 list_filter = ("success", "event_type")
130 raw_id_fields = ("webhook",)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
132 DDED fossil/api_tokens.py
133 DDED fossil/branch_protection.py
134 DDED fossil/ci.py
--- fossil/admin.py
+++ fossil/admin.py
@@ -1,9 +1,12 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .api_tokens import APIToken
6 from .branch_protection import BranchProtection
7 from .ci import StatusCheck
8 from .forum import ForumPost
9 from .models import FossilRepository, FossilSnapshot
10 from .notifications import Notification, ProjectWatch
11 from .releases import Release, ReleaseAsset
12 from .sync_models import GitMirror, SSHKey, SyncLog
@@ -126,5 +129,29 @@
129 @admin.register(WebhookDelivery)
130 class WebhookDeliveryAdmin(admin.ModelAdmin):
131 list_display = ("webhook", "event_type", "response_status", "success", "delivered_at", "duration_ms")
132 list_filter = ("success", "event_type")
133 raw_id_fields = ("webhook",)
134
135
136 @admin.register(StatusCheck)
137 class StatusCheckAdmin(BaseCoreAdmin):
138 list_display = ("context", "state", "checkin_uuid", "repository", "created_at")
139 list_filter = ("state",)
140 search_fields = ("context", "checkin_uuid")
141 raw_id_fields = ("repository",)
142
143
144 @admin.register(APIToken)
145 class APITokenAdmin(BaseCoreAdmin):
146 list_display = ("name", "token_prefix", "repository", "permissions", "last_used_at", "expires_at", "created_at")
147 search_fields = ("name", "token_prefix")
148 raw_id_fields = ("repository",)
149 readonly_fields = ("token_hash", "token_prefix")
150
151
152 @admin.register(BranchProtection)
153 class BranchProtectionAdmin(BaseCoreAdmin):
154 list_display = ("branch_pattern", "repository", "require_status_checks", "restrict_push", "created_at")
155 list_filter = ("require_status_checks", "restrict_push")
156 search_fields = ("branch_pattern",)
157 raw_id_fields = ("repository",)
158
159 DDED fossil/api_tokens.py
160 DDED fossil/branch_protection.py
161 DDED fossil/ci.py
--- a/fossil/api_tokens.py
+++ b/fossil/api_tokens.py
@@ -0,0 +1,70 @@
1
+"""API tokens scoped to a repository for CI/CD and automation.
2
+
3
+Tokens are stored as SHA-256 hashes -- the raw value is shown once on creation
4
+and never stored in plaintext.
5
+"""
6
+
7
+import hashlib
8
+import secrets
9
+
10
+from django.db import models
11
+from django.utils import timezone
12
+
13
+from core.models import ActiveManager, Tracking
14
+
15
+
16
+class APIToken(Tracking):
17
+ """API token scoped to a repository for CI/CD and automation."""
18
+
19
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="api_tokens")
20
+ name = models.CharField(max_length=200)
21
+ token_hash = models.CharField(max_length=64, unique=True, help_text="SHA-256 hash of the token")
22
+ token_prefix = models.CharField(max_length=12, help_text="First 12 chars for identification")
23
+ permissions = models.CharField(max_length=200, default="status:write", help_text="Comma-separated permissions")
24
+ expires_at = models.DateTimeField(null=True, blank=True)
25
+ last_used_at = models.DateTimeField(null=True, blank=True)
26
+
27
+ objects = ActiveManager()
28
+ all_objects = models.Manager()
29
+
30
+ class Meta:
31
+ ordering = ["-created_at"]
32
+
33
+ @staticmethod
34
+ def generate():
35
+ """Generate a new token. Returns (raw_token, token_hash, prefix)."""
36
+ raw = f"frp_{secrets.token_urlsafe(32)}"
37
+ hash_val = hashlib.sha256(raw.encode()).hexdigest()
38
+ prefix = raw[:12]
39
+ return raw, hash_val, prefix
40
+
41
+ @staticmethod
42
+ def hash_token(raw_token):
43
+ return hashlib.sha256(raw_token.encode()).hexdigest()
44
+
45
+ def has_permission(self, permission):
46
+ """Check if this token has a specific permission."""
47
+ perms = [p.strip() for p in self.permissions.split(",")]
48
+ return permission in perms or "*" in perms
49
+
50
+ def __str__(self):
51
+ return f"{self.name} ({self.token_prefix}...)"
52
+
53
+
54
+def authenticate_api_token(request, repository):
55
+ """Check Bearer token auth. Returns APIToken or None."""
56
+ auth = request.META.get("HTTP_AUTHORIZATION", "")
57
+ if not auth.startswith("Bearer "):
58
+ return None
59
+ raw_token = auth[7:]
60
+ token_hash = APIToken.hash_token(raw_token)
61
+ try:
62
+ token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True)
63
+ # Check expiry
64
+ if token.expires_at and token.expires_at < timezone.now():
65
+ return None
66
+ token.last_used_at = timezone.now()
67
+ token.save(update_fields=["last_used_at"])
68
+ return token
69
+ except APIToken.DoesNotExist:
70
+ return None
--- a/fossil/api_tokens.py
+++ b/fossil/api_tokens.py
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/api_tokens.py
+++ b/fossil/api_tokens.py
@@ -0,0 +1,70 @@
1 """API tokens scoped to a repository for CI/CD and automation.
2
3 Tokens are stored as SHA-256 hashes -- the raw value is shown once on creation
4 and never stored in plaintext.
5 """
6
7 import hashlib
8 import secrets
9
10 from django.db import models
11 from django.utils import timezone
12
13 from core.models import ActiveManager, Tracking
14
15
16 class APIToken(Tracking):
17 """API token scoped to a repository for CI/CD and automation."""
18
19 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="api_tokens")
20 name = models.CharField(max_length=200)
21 token_hash = models.CharField(max_length=64, unique=True, help_text="SHA-256 hash of the token")
22 token_prefix = models.CharField(max_length=12, help_text="First 12 chars for identification")
23 permissions = models.CharField(max_length=200, default="status:write", help_text="Comma-separated permissions")
24 expires_at = models.DateTimeField(null=True, blank=True)
25 last_used_at = models.DateTimeField(null=True, blank=True)
26
27 objects = ActiveManager()
28 all_objects = models.Manager()
29
30 class Meta:
31 ordering = ["-created_at"]
32
33 @staticmethod
34 def generate():
35 """Generate a new token. Returns (raw_token, token_hash, prefix)."""
36 raw = f"frp_{secrets.token_urlsafe(32)}"
37 hash_val = hashlib.sha256(raw.encode()).hexdigest()
38 prefix = raw[:12]
39 return raw, hash_val, prefix
40
41 @staticmethod
42 def hash_token(raw_token):
43 return hashlib.sha256(raw_token.encode()).hexdigest()
44
45 def has_permission(self, permission):
46 """Check if this token has a specific permission."""
47 perms = [p.strip() for p in self.permissions.split(",")]
48 return permission in perms or "*" in perms
49
50 def __str__(self):
51 return f"{self.name} ({self.token_prefix}...)"
52
53
54 def authenticate_api_token(request, repository):
55 """Check Bearer token auth. Returns APIToken or None."""
56 auth = request.META.get("HTTP_AUTHORIZATION", "")
57 if not auth.startswith("Bearer "):
58 return None
59 raw_token = auth[7:]
60 token_hash = APIToken.hash_token(raw_token)
61 try:
62 token = APIToken.objects.get(token_hash=token_hash, repository=repository, deleted_at__isnull=True)
63 # Check expiry
64 if token.expires_at and token.expires_at < timezone.now():
65 return None
66 token.last_used_at = timezone.now()
67 token.save(update_fields=["last_used_at"])
68 return token
69 except APIToken.DoesNotExist:
70 return None
--- a/fossil/branch_protection.py
+++ b/fossil/branch_protection.py
@@ -0,0 +1,33 @@
1
+"""Branch protection rules for Fossil repositories.
2
+
3
+Advisory for now -- the model and UI are ready, but enforcement via push hooks
4
+is not yet implemented.
5
+"""
6
+
7
+from django.db import models
8
+
9
+from core.models import ActiveManager, Tracking
10
+
11
+
12
+class BranchProtection(Tracking):
13
+ """Branch protection rules for a repository."""
14
+
15
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="branch_protections")
16
+ branch_pattern = models.CharField(max_length=200, help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')")
17
+ require_status_checks = models.BooleanField(default=False)
18
+ required_contexts = models.TextField(blank=True, default="", help_text="Required CI contexts, one per line")
19
+ restrict_push = models.BooleanField(default=True, help_text="Only admins can push")
20
+
21
+ objects = ActiveManager()
22
+ all_objects = models.Manager()
23
+
24
+ class Meta:
25
+ ordering = ["branch_pattern"]
26
+ unique_together = [("repository", "branch_pattern")]
27
+
28
+ def get_required_contexts_list(self):
29
+ """Return required_contexts as a list, filtering blanks."""
30
+ return [c.strip() for c in self.required_contexts.splitlines() if c.strip()]
31
+
32
+ def __str__(self):
33
+ return f"{self.branch_pattern} ({self.repository})"
--- a/fossil/branch_protection.py
+++ b/fossil/branch_protection.py
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/branch_protection.py
+++ b/fossil/branch_protection.py
@@ -0,0 +1,33 @@
1 """Branch protection rules for Fossil repositories.
2
3 Advisory for now -- the model and UI are ready, but enforcement via push hooks
4 is not yet implemented.
5 """
6
7 from django.db import models
8
9 from core.models import ActiveManager, Tracking
10
11
12 class BranchProtection(Tracking):
13 """Branch protection rules for a repository."""
14
15 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="branch_protections")
16 branch_pattern = models.CharField(max_length=200, help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')")
17 require_status_checks = models.BooleanField(default=False)
18 required_contexts = models.TextField(blank=True, default="", help_text="Required CI contexts, one per line")
19 restrict_push = models.BooleanField(default=True, help_text="Only admins can push")
20
21 objects = ActiveManager()
22 all_objects = models.Manager()
23
24 class Meta:
25 ordering = ["branch_pattern"]
26 unique_together = [("repository", "branch_pattern")]
27
28 def get_required_contexts_list(self):
29 """Return required_contexts as a list, filtering blanks."""
30 return [c.strip() for c in self.required_contexts.splitlines() if c.strip()]
31
32 def __str__(self):
33 return f"{self.branch_pattern} ({self.repository})"
+36
--- a/fossil/ci.py
+++ b/fossil/ci.py
@@ -0,0 +1,36 @@
1
+"""CI status checks for Fossil checkins.
2
+
3
+External CI systems (GitHub Actions, Jenkins, etc.) POST status results
4
+for specific checkins. Results are displayed as badges on the checkin detail view.
5
+"""
6
+
7
+from django.db import models
8
+
9
+from core.models import ActiveManager, Tracking
10
+
11
+
12
+class StatusCheck(Tracking):
13
+ """CI status check result for a specific checkin."""
14
+
15
+ class State(models.TextChoices):
16
+ PENDING = "pending", "Pending"
17
+ SUCCESS = "success", "Success"
18
+ FAILURE = "failure", "Failure"
19
+ ERROR = "error", "Error"
20
+
21
+ repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="status_checks")
22
+ checkin_uuid = models.CharField(max_length=64, db_index=True)
23
+ context = models.CharField(max_length=200, help_text="CI context name (e.g., 'ci/tests', 'ci/lint')")
24
+ state = models.CharField(max_length=20, choices=State.choices, default=State.PENDING)
25
+ description = models.CharField(max_length=500, blank=True, default="")
26
+ target_url = models.URLField(blank=True, default="", help_text="Link to CI build details")
27
+
28
+ objects = ActiveManager()
29
+ all_objects = models.Manager()
30
+
31
+ class Meta:
32
+ ordering = ["-created_at"]
33
+ unique_together = [("repository", "checkin_uuid", "context")]
34
+
35
+ def __str__(self):
36
+ return f"{self.context}: {self.state} @ {self.checkin_uuid[:10]}"
--- a/fossil/ci.py
+++ b/fossil/ci.py
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/ci.py
+++ b/fossil/ci.py
@@ -0,0 +1,36 @@
1 """CI status checks for Fossil checkins.
2
3 External CI systems (GitHub Actions, Jenkins, etc.) POST status results
4 for specific checkins. Results are displayed as badges on the checkin detail view.
5 """
6
7 from django.db import models
8
9 from core.models import ActiveManager, Tracking
10
11
12 class StatusCheck(Tracking):
13 """CI status check result for a specific checkin."""
14
15 class State(models.TextChoices):
16 PENDING = "pending", "Pending"
17 SUCCESS = "success", "Success"
18 FAILURE = "failure", "Failure"
19 ERROR = "error", "Error"
20
21 repository = models.ForeignKey("fossil.FossilRepository", on_delete=models.CASCADE, related_name="status_checks")
22 checkin_uuid = models.CharField(max_length=64, db_index=True)
23 context = models.CharField(max_length=200, help_text="CI context name (e.g., 'ci/tests', 'ci/lint')")
24 state = models.CharField(max_length=20, choices=State.choices, default=State.PENDING)
25 description = models.CharField(max_length=500, blank=True, default="")
26 target_url = models.URLField(blank=True, default="", help_text="Link to CI build details")
27
28 objects = ActiveManager()
29 all_objects = models.Manager()
30
31 class Meta:
32 ordering = ["-created_at"]
33 unique_together = [("repository", "checkin_uuid", "context")]
34
35 def __str__(self):
36 return f"{self.context}: {self.state} @ {self.checkin_uuid[:10]}"
--- fossil/cli.py
+++ fossil/cli.py
@@ -201,10 +201,57 @@
201201
for key, value in fields.items():
202202
cmd.append(f"{key}")
203203
cmd.append(f"{value}")
204204
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
205205
return result.returncode == 0
206
+
207
+ def technote_create(self, repo_path: Path, title: str, body: str, timestamp: str | None = None, user: str = "") -> bool:
208
+ """Create a new technote.
209
+
210
+ Uses: fossil wiki create --technote <timestamp> <title> -R <repo>
211
+ with the body piped via stdin.
212
+ """
213
+ if not timestamp:
214
+ from datetime import UTC, datetime
215
+
216
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
217
+
218
+ cmd = [self.binary, "wiki", "create", title, "--technote", timestamp, "-R", str(repo_path)]
219
+ if user:
220
+ cmd.extend(["--technote-user", user])
221
+ result = subprocess.run(cmd, input=body, capture_output=True, text=True, timeout=30, env=self._env)
222
+ return result.returncode == 0
223
+
224
+ def technote_edit(self, repo_path: Path, technote_id: str, body: str, user: str = "") -> bool:
225
+ """Edit an existing technote body.
226
+
227
+ Uses: fossil wiki commit <comment> --technote <technote_id> -R <repo>
228
+ with the new body piped via stdin.
229
+ """
230
+ cmd = [self.binary, "wiki", "commit", "update", "--technote", technote_id, "-R", str(repo_path)]
231
+ if user:
232
+ cmd.extend(["--technote-user", user])
233
+ result = subprocess.run(cmd, input=body, capture_output=True, text=True, timeout=30, env=self._env)
234
+ return result.returncode == 0
235
+
236
+ def uv_add(self, repo_path: Path, name: str, filepath: Path) -> bool:
237
+ """Add an unversioned file: fossil uv add <filepath> --as <name> -R <repo>."""
238
+ cmd = [self.binary, "uv", "add", str(filepath), "--as", name, "-R", str(repo_path)]
239
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=self._env)
240
+ return result.returncode == 0
241
+
242
+ def uv_cat(self, repo_path: Path, name: str) -> bytes:
243
+ """Get unversioned file content: fossil uv cat <name> -R <repo>.
244
+
245
+ Returns raw bytes. Raises FileNotFoundError if the file doesn't exist
246
+ or the command fails.
247
+ """
248
+ cmd = [self.binary, "uv", "cat", name, "-R", str(repo_path)]
249
+ result = subprocess.run(cmd, capture_output=True, timeout=60, env=self._env)
250
+ if result.returncode != 0:
251
+ raise FileNotFoundError(f"Unversioned file not found: {name}")
252
+ return result.stdout
206253
207254
def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict:
208255
"""Export Fossil repo to a Git mirror directory. Incremental.
209256
210257
When auth_token is provided, credentials are passed via Git environment
211258
212259
ADDED fossil/migrations/0008_apitoken_historicalapitoken_and_more.py
--- fossil/cli.py
+++ fossil/cli.py
@@ -201,10 +201,57 @@
201 for key, value in fields.items():
202 cmd.append(f"{key}")
203 cmd.append(f"{value}")
204 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
205 return result.returncode == 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
207 def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict:
208 """Export Fossil repo to a Git mirror directory. Incremental.
209
210 When auth_token is provided, credentials are passed via Git environment
211
212 DDED fossil/migrations/0008_apitoken_historicalapitoken_and_more.py
--- fossil/cli.py
+++ fossil/cli.py
@@ -201,10 +201,57 @@
201 for key, value in fields.items():
202 cmd.append(f"{key}")
203 cmd.append(f"{value}")
204 result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env)
205 return result.returncode == 0
206
207 def technote_create(self, repo_path: Path, title: str, body: str, timestamp: str | None = None, user: str = "") -> bool:
208 """Create a new technote.
209
210 Uses: fossil wiki create --technote <timestamp> <title> -R <repo>
211 with the body piped via stdin.
212 """
213 if not timestamp:
214 from datetime import UTC, datetime
215
216 timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
217
218 cmd = [self.binary, "wiki", "create", title, "--technote", timestamp, "-R", str(repo_path)]
219 if user:
220 cmd.extend(["--technote-user", user])
221 result = subprocess.run(cmd, input=body, capture_output=True, text=True, timeout=30, env=self._env)
222 return result.returncode == 0
223
224 def technote_edit(self, repo_path: Path, technote_id: str, body: str, user: str = "") -> bool:
225 """Edit an existing technote body.
226
227 Uses: fossil wiki commit <comment> --technote <technote_id> -R <repo>
228 with the new body piped via stdin.
229 """
230 cmd = [self.binary, "wiki", "commit", "update", "--technote", technote_id, "-R", str(repo_path)]
231 if user:
232 cmd.extend(["--technote-user", user])
233 result = subprocess.run(cmd, input=body, capture_output=True, text=True, timeout=30, env=self._env)
234 return result.returncode == 0
235
236 def uv_add(self, repo_path: Path, name: str, filepath: Path) -> bool:
237 """Add an unversioned file: fossil uv add <filepath> --as <name> -R <repo>."""
238 cmd = [self.binary, "uv", "add", str(filepath), "--as", name, "-R", str(repo_path)]
239 result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=self._env)
240 return result.returncode == 0
241
242 def uv_cat(self, repo_path: Path, name: str) -> bytes:
243 """Get unversioned file content: fossil uv cat <name> -R <repo>.
244
245 Returns raw bytes. Raises FileNotFoundError if the file doesn't exist
246 or the command fails.
247 """
248 cmd = [self.binary, "uv", "cat", name, "-R", str(repo_path)]
249 result = subprocess.run(cmd, capture_output=True, timeout=60, env=self._env)
250 if result.returncode != 0:
251 raise FileNotFoundError(f"Unversioned file not found: {name}")
252 return result.stdout
253
254 def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict:
255 """Export Fossil repo to a Git mirror directory. Incremental.
256
257 When auth_token is provided, credentials are passed via Git environment
258
259 DDED fossil/migrations/0008_apitoken_historicalapitoken_and_more.py
--- a/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py
+++ b/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py
@@ -0,0 +1,588 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 15:02
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", "0007_forumpost_historicalforumpost_historicalwebhook_and_more"),
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="APIToken",
18
+ fields=[
19
+ (
20
+ "id",
21
+ models.BigAutoField(
22
+ auto_created=True,
23
+ primary_key=True,
24
+ serialize=False,
25
+ verbose_name="ID",
26
+ ),
27
+ ),
28
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
29
+ ("created_at", models.DateTimeField(auto_now_add=True)),
30
+ ("updated_at", models.DateTimeField(auto_now=True)),
31
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
32
+ ("name", models.CharField(max_length=200)),
33
+ (
34
+ "token_hash",
35
+ models.CharField(
36
+ help_text="SHA-256 hash of the token",
37
+ max_length=64,
38
+ unique=True,
39
+ ),
40
+ ),
41
+ (
42
+ "token_prefix",
43
+ models.CharField(help_text="First 12 chars for identification", max_length=12),
44
+ ),
45
+ (
46
+ "permissions",
47
+ models.CharField(
48
+ default="status:write",
49
+ help_text="Comma-separated permissions",
50
+ max_length=200,
51
+ ),
52
+ ),
53
+ ("expires_at", models.DateTimeField(blank=True, null=True)),
54
+ ("last_used_at", models.DateTimeField(blank=True, null=True)),
55
+ (
56
+ "created_by",
57
+ models.ForeignKey(
58
+ blank=True,
59
+ null=True,
60
+ on_delete=django.db.models.deletion.SET_NULL,
61
+ related_name="+",
62
+ to=settings.AUTH_USER_MODEL,
63
+ ),
64
+ ),
65
+ (
66
+ "deleted_by",
67
+ models.ForeignKey(
68
+ blank=True,
69
+ null=True,
70
+ on_delete=django.db.models.deletion.SET_NULL,
71
+ related_name="+",
72
+ to=settings.AUTH_USER_MODEL,
73
+ ),
74
+ ),
75
+ (
76
+ "repository",
77
+ models.ForeignKey(
78
+ on_delete=django.db.models.deletion.CASCADE,
79
+ related_name="api_tokens",
80
+ to="fossil.fossilrepository",
81
+ ),
82
+ ),
83
+ (
84
+ "updated_by",
85
+ models.ForeignKey(
86
+ blank=True,
87
+ null=True,
88
+ on_delete=django.db.models.deletion.SET_NULL,
89
+ related_name="+",
90
+ to=settings.AUTH_USER_MODEL,
91
+ ),
92
+ ),
93
+ ],
94
+ options={
95
+ "ordering": ["-created_at"],
96
+ },
97
+ ),
98
+ migrations.CreateModel(
99
+ name="HistoricalAPIToken",
100
+ fields=[
101
+ (
102
+ "id",
103
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
104
+ ),
105
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
106
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
107
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
108
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
109
+ ("name", models.CharField(max_length=200)),
110
+ (
111
+ "token_hash",
112
+ models.CharField(
113
+ db_index=True,
114
+ help_text="SHA-256 hash of the token",
115
+ max_length=64,
116
+ ),
117
+ ),
118
+ (
119
+ "token_prefix",
120
+ models.CharField(help_text="First 12 chars for identification", max_length=12),
121
+ ),
122
+ (
123
+ "permissions",
124
+ models.CharField(
125
+ default="status:write",
126
+ help_text="Comma-separated permissions",
127
+ max_length=200,
128
+ ),
129
+ ),
130
+ ("expires_at", models.DateTimeField(blank=True, null=True)),
131
+ ("last_used_at", models.DateTimeField(blank=True, null=True)),
132
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
133
+ ("history_date", models.DateTimeField(db_index=True)),
134
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
135
+ (
136
+ "history_type",
137
+ models.CharField(
138
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
139
+ max_length=1,
140
+ ),
141
+ ),
142
+ (
143
+ "created_by",
144
+ models.ForeignKey(
145
+ blank=True,
146
+ db_constraint=False,
147
+ null=True,
148
+ on_delete=django.db.models.deletion.DO_NOTHING,
149
+ related_name="+",
150
+ to=settings.AUTH_USER_MODEL,
151
+ ),
152
+ ),
153
+ (
154
+ "deleted_by",
155
+ models.ForeignKey(
156
+ blank=True,
157
+ db_constraint=False,
158
+ null=True,
159
+ on_delete=django.db.models.deletion.DO_NOTHING,
160
+ related_name="+",
161
+ to=settings.AUTH_USER_MODEL,
162
+ ),
163
+ ),
164
+ (
165
+ "history_user",
166
+ models.ForeignKey(
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
+ "repository",
175
+ models.ForeignKey(
176
+ blank=True,
177
+ db_constraint=False,
178
+ null=True,
179
+ on_delete=django.db.models.deletion.DO_NOTHING,
180
+ related_name="+",
181
+ to="fossil.fossilrepository",
182
+ ),
183
+ ),
184
+ (
185
+ "updated_by",
186
+ models.ForeignKey(
187
+ blank=True,
188
+ db_constraint=False,
189
+ null=True,
190
+ on_delete=django.db.models.deletion.DO_NOTHING,
191
+ related_name="+",
192
+ to=settings.AUTH_USER_MODEL,
193
+ ),
194
+ ),
195
+ ],
196
+ options={
197
+ "verbose_name": "historical api token",
198
+ "verbose_name_plural": "historical api tokens",
199
+ "ordering": ("-history_date", "-history_id"),
200
+ "get_latest_by": ("history_date", "history_id"),
201
+ },
202
+ bases=(simple_history.models.HistoricalChanges, models.Model),
203
+ ),
204
+ migrations.CreateModel(
205
+ name="HistoricalBranchProtection",
206
+ fields=[
207
+ (
208
+ "id",
209
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
210
+ ),
211
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
212
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
213
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
214
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
215
+ (
216
+ "branch_pattern",
217
+ models.CharField(
218
+ help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')",
219
+ max_length=200,
220
+ ),
221
+ ),
222
+ ("require_status_checks", models.BooleanField(default=False)),
223
+ (
224
+ "required_contexts",
225
+ models.TextField(
226
+ blank=True,
227
+ default="",
228
+ help_text="Required CI contexts, one per line",
229
+ ),
230
+ ),
231
+ (
232
+ "restrict_push",
233
+ models.BooleanField(default=True, help_text="Only admins can push"),
234
+ ),
235
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
236
+ ("history_date", models.DateTimeField(db_index=True)),
237
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
238
+ (
239
+ "history_type",
240
+ models.CharField(
241
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
242
+ max_length=1,
243
+ ),
244
+ ),
245
+ (
246
+ "created_by",
247
+ models.ForeignKey(
248
+ blank=True,
249
+ db_constraint=False,
250
+ null=True,
251
+ on_delete=django.db.models.deletion.DO_NOTHING,
252
+ related_name="+",
253
+ to=settings.AUTH_USER_MODEL,
254
+ ),
255
+ ),
256
+ (
257
+ "deleted_by",
258
+ models.ForeignKey(
259
+ blank=True,
260
+ db_constraint=False,
261
+ null=True,
262
+ on_delete=django.db.models.deletion.DO_NOTHING,
263
+ related_name="+",
264
+ to=settings.AUTH_USER_MODEL,
265
+ ),
266
+ ),
267
+ (
268
+ "history_user",
269
+ models.ForeignKey(
270
+ null=True,
271
+ on_delete=django.db.models.deletion.SET_NULL,
272
+ related_name="+",
273
+ to=settings.AUTH_USER_MODEL,
274
+ ),
275
+ ),
276
+ (
277
+ "repository",
278
+ models.ForeignKey(
279
+ blank=True,
280
+ db_constraint=False,
281
+ null=True,
282
+ on_delete=django.db.models.deletion.DO_NOTHING,
283
+ related_name="+",
284
+ to="fossil.fossilrepository",
285
+ ),
286
+ ),
287
+ (
288
+ "updated_by",
289
+ models.ForeignKey(
290
+ blank=True,
291
+ db_constraint=False,
292
+ null=True,
293
+ on_delete=django.db.models.deletion.DO_NOTHING,
294
+ related_name="+",
295
+ to=settings.AUTH_USER_MODEL,
296
+ ),
297
+ ),
298
+ ],
299
+ options={
300
+ "verbose_name": "historical branch protection",
301
+ "verbose_name_plural": "historical branch protections",
302
+ "ordering": ("-history_date", "-history_id"),
303
+ "get_latest_by": ("history_date", "history_id"),
304
+ },
305
+ bases=(simple_history.models.HistoricalChanges, models.Model),
306
+ ),
307
+ migrations.CreateModel(
308
+ name="HistoricalStatusCheck",
309
+ fields=[
310
+ (
311
+ "id",
312
+ models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
313
+ ),
314
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
315
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
316
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
317
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
318
+ ("checkin_uuid", models.CharField(db_index=True, max_length=64)),
319
+ (
320
+ "context",
321
+ models.CharField(
322
+ help_text="CI context name (e.g., 'ci/tests', 'ci/lint')",
323
+ max_length=200,
324
+ ),
325
+ ),
326
+ (
327
+ "state",
328
+ models.CharField(
329
+ choices=[
330
+ ("pending", "Pending"),
331
+ ("success", "Success"),
332
+ ("failure", "Failure"),
333
+ ("error", "Error"),
334
+ ],
335
+ default="pending",
336
+ max_length=20,
337
+ ),
338
+ ),
339
+ (
340
+ "description",
341
+ models.CharField(blank=True, default="", max_length=500),
342
+ ),
343
+ (
344
+ "target_url",
345
+ models.URLField(blank=True, default="", help_text="Link to CI build details"),
346
+ ),
347
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
348
+ ("history_date", models.DateTimeField(db_index=True)),
349
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
350
+ (
351
+ "history_type",
352
+ models.CharField(
353
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
354
+ max_length=1,
355
+ ),
356
+ ),
357
+ (
358
+ "created_by",
359
+ models.ForeignKey(
360
+ blank=True,
361
+ db_constraint=False,
362
+ null=True,
363
+ on_delete=django.db.models.deletion.DO_NOTHING,
364
+ related_name="+",
365
+ to=settings.AUTH_USER_MODEL,
366
+ ),
367
+ ),
368
+ (
369
+ "deleted_by",
370
+ models.ForeignKey(
371
+ blank=True,
372
+ db_constraint=False,
373
+ null=True,
374
+ on_delete=django.db.models.deletion.DO_NOTHING,
375
+ related_name="+",
376
+ to=settings.AUTH_USER_MODEL,
377
+ ),
378
+ ),
379
+ (
380
+ "history_user",
381
+ models.ForeignKey(
382
+ null=True,
383
+ on_delete=django.db.models.deletion.SET_NULL,
384
+ related_name="+",
385
+ to=settings.AUTH_USER_MODEL,
386
+ ),
387
+ ),
388
+ (
389
+ "repository",
390
+ models.ForeignKey(
391
+ blank=True,
392
+ db_constraint=False,
393
+ null=True,
394
+ on_delete=django.db.models.deletion.DO_NOTHING,
395
+ related_name="+",
396
+ to="fossil.fossilrepository",
397
+ ),
398
+ ),
399
+ (
400
+ "updated_by",
401
+ models.ForeignKey(
402
+ blank=True,
403
+ db_constraint=False,
404
+ null=True,
405
+ on_delete=django.db.models.deletion.DO_NOTHING,
406
+ related_name="+",
407
+ to=settings.AUTH_USER_MODEL,
408
+ ),
409
+ ),
410
+ ],
411
+ options={
412
+ "verbose_name": "historical status check",
413
+ "verbose_name_plural": "historical status checks",
414
+ "ordering": ("-history_date", "-history_id"),
415
+ "get_latest_by": ("history_date", "history_id"),
416
+ },
417
+ bases=(simple_history.models.HistoricalChanges, models.Model),
418
+ ),
419
+ migrations.CreateModel(
420
+ name="BranchProtection",
421
+ fields=[
422
+ (
423
+ "id",
424
+ models.BigAutoField(
425
+ auto_created=True,
426
+ primary_key=True,
427
+ serialize=False,
428
+ verbose_name="ID",
429
+ ),
430
+ ),
431
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
432
+ ("created_at", models.DateTimeField(auto_now_add=True)),
433
+ ("updated_at", models.DateTimeField(auto_now=True)),
434
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
435
+ (
436
+ "branch_pattern",
437
+ models.CharField(
438
+ help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')",
439
+ max_length=200,
440
+ ),
441
+ ),
442
+ ("require_status_checks", models.BooleanField(default=False)),
443
+ (
444
+ "required_contexts",
445
+ models.TextField(
446
+ blank=True,
447
+ default="",
448
+ help_text="Required CI contexts, one per line",
449
+ ),
450
+ ),
451
+ (
452
+ "restrict_push",
453
+ models.BooleanField(default=True, help_text="Only admins can push"),
454
+ ),
455
+ (
456
+ "created_by",
457
+ models.ForeignKey(
458
+ blank=True,
459
+ null=True,
460
+ on_delete=django.db.models.deletion.SET_NULL,
461
+ related_name="+",
462
+ to=settings.AUTH_USER_MODEL,
463
+ ),
464
+ ),
465
+ (
466
+ "deleted_by",
467
+ models.ForeignKey(
468
+ blank=True,
469
+ null=True,
470
+ on_delete=django.db.models.deletion.SET_NULL,
471
+ related_name="+",
472
+ to=settings.AUTH_USER_MODEL,
473
+ ),
474
+ ),
475
+ (
476
+ "repository",
477
+ models.ForeignKey(
478
+ on_delete=django.db.models.deletion.CASCADE,
479
+ related_name="branch_protections",
480
+ to="fossil.fossilrepository",
481
+ ),
482
+ ),
483
+ (
484
+ "updated_by",
485
+ models.ForeignKey(
486
+ blank=True,
487
+ null=True,
488
+ on_delete=django.db.models.deletion.SET_NULL,
489
+ related_name="+",
490
+ to=settings.AUTH_USER_MODEL,
491
+ ),
492
+ ),
493
+ ],
494
+ options={
495
+ "ordering": ["branch_pattern"],
496
+ "unique_together": {("repository", "branch_pattern")},
497
+ },
498
+ ),
499
+ migrations.CreateModel(
500
+ name="StatusCheck",
501
+ fields=[
502
+ (
503
+ "id",
504
+ models.BigAutoField(
505
+ auto_created=True,
506
+ primary_key=True,
507
+ serialize=False,
508
+ verbose_name="ID",
509
+ ),
510
+ ),
511
+ ("version", models.PositiveIntegerField(default=1, editable=False)),
512
+ ("created_at", models.DateTimeField(auto_now_add=True)),
513
+ ("updated_at", models.DateTimeField(auto_now=True)),
514
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
515
+ ("checkin_uuid", models.CharField(db_index=True, max_length=64)),
516
+ (
517
+ "context",
518
+ models.CharField(
519
+ help_text="CI context name (e.g., 'ci/tests', 'ci/lint')",
520
+ max_length=200,
521
+ ),
522
+ ),
523
+ (
524
+ "state",
525
+ models.CharField(
526
+ choices=[
527
+ ("pending", "Pending"),
528
+ ("success", "Success"),
529
+ ("failure", "Failure"),
530
+ ("error", "Error"),
531
+ ],
532
+ default="pending",
533
+ max_length=20,
534
+ ),
535
+ ),
536
+ (
537
+ "description",
538
+ models.CharField(blank=True, default="", max_length=500),
539
+ ),
540
+ (
541
+ "target_url",
542
+ models.URLField(blank=True, default="", help_text="Link to CI build details"),
543
+ ),
544
+ (
545
+ "created_by",
546
+ models.ForeignKey(
547
+ blank=True,
548
+ null=True,
549
+ on_delete=django.db.models.deletion.SET_NULL,
550
+ related_name="+",
551
+ to=settings.AUTH_USER_MODEL,
552
+ ),
553
+ ),
554
+ (
555
+ "deleted_by",
556
+ models.ForeignKey(
557
+ blank=True,
558
+ null=True,
559
+ on_delete=django.db.models.deletion.SET_NULL,
560
+ related_name="+",
561
+ to=settings.AUTH_USER_MODEL,
562
+ ),
563
+ ),
564
+ (
565
+ "repository",
566
+ models.ForeignKey(
567
+ on_delete=django.db.models.deletion.CASCADE,
568
+ related_name="status_checks",
569
+ to="fossil.fossilrepository",
570
+ ),
571
+ ),
572
+ (
573
+ "updated_by",
574
+ models.ForeignKey(
575
+ blank=True,
576
+ null=True,
577
+ on_delete=django.db.models.deletion.SET_NULL,
578
+ related_name="+",
579
+ to=settings.AUTH_USER_MODEL,
580
+ ),
581
+ ),
582
+ ],
583
+ options={
584
+ "ordering": ["-created_at"],
585
+ "unique_together": {("repository", "checkin_uuid", "context")},
586
+ },
587
+ ),
588
+ ]
--- a/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py
+++ b/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py
+++ b/fossil/migrations/0008_apitoken_historicalapitoken_and_more.py
@@ -0,0 +1,588 @@
1 # Generated by Django 5.2.12 on 2026-04-07 15:02
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", "0007_forumpost_historicalforumpost_historicalwebhook_and_more"),
12 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 ]
14
15 operations = [
16 migrations.CreateModel(
17 name="APIToken",
18 fields=[
19 (
20 "id",
21 models.BigAutoField(
22 auto_created=True,
23 primary_key=True,
24 serialize=False,
25 verbose_name="ID",
26 ),
27 ),
28 ("version", models.PositiveIntegerField(default=1, editable=False)),
29 ("created_at", models.DateTimeField(auto_now_add=True)),
30 ("updated_at", models.DateTimeField(auto_now=True)),
31 ("deleted_at", models.DateTimeField(blank=True, null=True)),
32 ("name", models.CharField(max_length=200)),
33 (
34 "token_hash",
35 models.CharField(
36 help_text="SHA-256 hash of the token",
37 max_length=64,
38 unique=True,
39 ),
40 ),
41 (
42 "token_prefix",
43 models.CharField(help_text="First 12 chars for identification", max_length=12),
44 ),
45 (
46 "permissions",
47 models.CharField(
48 default="status:write",
49 help_text="Comma-separated permissions",
50 max_length=200,
51 ),
52 ),
53 ("expires_at", models.DateTimeField(blank=True, null=True)),
54 ("last_used_at", models.DateTimeField(blank=True, null=True)),
55 (
56 "created_by",
57 models.ForeignKey(
58 blank=True,
59 null=True,
60 on_delete=django.db.models.deletion.SET_NULL,
61 related_name="+",
62 to=settings.AUTH_USER_MODEL,
63 ),
64 ),
65 (
66 "deleted_by",
67 models.ForeignKey(
68 blank=True,
69 null=True,
70 on_delete=django.db.models.deletion.SET_NULL,
71 related_name="+",
72 to=settings.AUTH_USER_MODEL,
73 ),
74 ),
75 (
76 "repository",
77 models.ForeignKey(
78 on_delete=django.db.models.deletion.CASCADE,
79 related_name="api_tokens",
80 to="fossil.fossilrepository",
81 ),
82 ),
83 (
84 "updated_by",
85 models.ForeignKey(
86 blank=True,
87 null=True,
88 on_delete=django.db.models.deletion.SET_NULL,
89 related_name="+",
90 to=settings.AUTH_USER_MODEL,
91 ),
92 ),
93 ],
94 options={
95 "ordering": ["-created_at"],
96 },
97 ),
98 migrations.CreateModel(
99 name="HistoricalAPIToken",
100 fields=[
101 (
102 "id",
103 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
104 ),
105 ("version", models.PositiveIntegerField(default=1, editable=False)),
106 ("created_at", models.DateTimeField(blank=True, editable=False)),
107 ("updated_at", models.DateTimeField(blank=True, editable=False)),
108 ("deleted_at", models.DateTimeField(blank=True, null=True)),
109 ("name", models.CharField(max_length=200)),
110 (
111 "token_hash",
112 models.CharField(
113 db_index=True,
114 help_text="SHA-256 hash of the token",
115 max_length=64,
116 ),
117 ),
118 (
119 "token_prefix",
120 models.CharField(help_text="First 12 chars for identification", max_length=12),
121 ),
122 (
123 "permissions",
124 models.CharField(
125 default="status:write",
126 help_text="Comma-separated permissions",
127 max_length=200,
128 ),
129 ),
130 ("expires_at", models.DateTimeField(blank=True, null=True)),
131 ("last_used_at", models.DateTimeField(blank=True, null=True)),
132 ("history_id", models.AutoField(primary_key=True, serialize=False)),
133 ("history_date", models.DateTimeField(db_index=True)),
134 ("history_change_reason", models.CharField(max_length=100, null=True)),
135 (
136 "history_type",
137 models.CharField(
138 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
139 max_length=1,
140 ),
141 ),
142 (
143 "created_by",
144 models.ForeignKey(
145 blank=True,
146 db_constraint=False,
147 null=True,
148 on_delete=django.db.models.deletion.DO_NOTHING,
149 related_name="+",
150 to=settings.AUTH_USER_MODEL,
151 ),
152 ),
153 (
154 "deleted_by",
155 models.ForeignKey(
156 blank=True,
157 db_constraint=False,
158 null=True,
159 on_delete=django.db.models.deletion.DO_NOTHING,
160 related_name="+",
161 to=settings.AUTH_USER_MODEL,
162 ),
163 ),
164 (
165 "history_user",
166 models.ForeignKey(
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 "repository",
175 models.ForeignKey(
176 blank=True,
177 db_constraint=False,
178 null=True,
179 on_delete=django.db.models.deletion.DO_NOTHING,
180 related_name="+",
181 to="fossil.fossilrepository",
182 ),
183 ),
184 (
185 "updated_by",
186 models.ForeignKey(
187 blank=True,
188 db_constraint=False,
189 null=True,
190 on_delete=django.db.models.deletion.DO_NOTHING,
191 related_name="+",
192 to=settings.AUTH_USER_MODEL,
193 ),
194 ),
195 ],
196 options={
197 "verbose_name": "historical api token",
198 "verbose_name_plural": "historical api tokens",
199 "ordering": ("-history_date", "-history_id"),
200 "get_latest_by": ("history_date", "history_id"),
201 },
202 bases=(simple_history.models.HistoricalChanges, models.Model),
203 ),
204 migrations.CreateModel(
205 name="HistoricalBranchProtection",
206 fields=[
207 (
208 "id",
209 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
210 ),
211 ("version", models.PositiveIntegerField(default=1, editable=False)),
212 ("created_at", models.DateTimeField(blank=True, editable=False)),
213 ("updated_at", models.DateTimeField(blank=True, editable=False)),
214 ("deleted_at", models.DateTimeField(blank=True, null=True)),
215 (
216 "branch_pattern",
217 models.CharField(
218 help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')",
219 max_length=200,
220 ),
221 ),
222 ("require_status_checks", models.BooleanField(default=False)),
223 (
224 "required_contexts",
225 models.TextField(
226 blank=True,
227 default="",
228 help_text="Required CI contexts, one per line",
229 ),
230 ),
231 (
232 "restrict_push",
233 models.BooleanField(default=True, help_text="Only admins can push"),
234 ),
235 ("history_id", models.AutoField(primary_key=True, serialize=False)),
236 ("history_date", models.DateTimeField(db_index=True)),
237 ("history_change_reason", models.CharField(max_length=100, null=True)),
238 (
239 "history_type",
240 models.CharField(
241 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
242 max_length=1,
243 ),
244 ),
245 (
246 "created_by",
247 models.ForeignKey(
248 blank=True,
249 db_constraint=False,
250 null=True,
251 on_delete=django.db.models.deletion.DO_NOTHING,
252 related_name="+",
253 to=settings.AUTH_USER_MODEL,
254 ),
255 ),
256 (
257 "deleted_by",
258 models.ForeignKey(
259 blank=True,
260 db_constraint=False,
261 null=True,
262 on_delete=django.db.models.deletion.DO_NOTHING,
263 related_name="+",
264 to=settings.AUTH_USER_MODEL,
265 ),
266 ),
267 (
268 "history_user",
269 models.ForeignKey(
270 null=True,
271 on_delete=django.db.models.deletion.SET_NULL,
272 related_name="+",
273 to=settings.AUTH_USER_MODEL,
274 ),
275 ),
276 (
277 "repository",
278 models.ForeignKey(
279 blank=True,
280 db_constraint=False,
281 null=True,
282 on_delete=django.db.models.deletion.DO_NOTHING,
283 related_name="+",
284 to="fossil.fossilrepository",
285 ),
286 ),
287 (
288 "updated_by",
289 models.ForeignKey(
290 blank=True,
291 db_constraint=False,
292 null=True,
293 on_delete=django.db.models.deletion.DO_NOTHING,
294 related_name="+",
295 to=settings.AUTH_USER_MODEL,
296 ),
297 ),
298 ],
299 options={
300 "verbose_name": "historical branch protection",
301 "verbose_name_plural": "historical branch protections",
302 "ordering": ("-history_date", "-history_id"),
303 "get_latest_by": ("history_date", "history_id"),
304 },
305 bases=(simple_history.models.HistoricalChanges, models.Model),
306 ),
307 migrations.CreateModel(
308 name="HistoricalStatusCheck",
309 fields=[
310 (
311 "id",
312 models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name="ID"),
313 ),
314 ("version", models.PositiveIntegerField(default=1, editable=False)),
315 ("created_at", models.DateTimeField(blank=True, editable=False)),
316 ("updated_at", models.DateTimeField(blank=True, editable=False)),
317 ("deleted_at", models.DateTimeField(blank=True, null=True)),
318 ("checkin_uuid", models.CharField(db_index=True, max_length=64)),
319 (
320 "context",
321 models.CharField(
322 help_text="CI context name (e.g., 'ci/tests', 'ci/lint')",
323 max_length=200,
324 ),
325 ),
326 (
327 "state",
328 models.CharField(
329 choices=[
330 ("pending", "Pending"),
331 ("success", "Success"),
332 ("failure", "Failure"),
333 ("error", "Error"),
334 ],
335 default="pending",
336 max_length=20,
337 ),
338 ),
339 (
340 "description",
341 models.CharField(blank=True, default="", max_length=500),
342 ),
343 (
344 "target_url",
345 models.URLField(blank=True, default="", help_text="Link to CI build details"),
346 ),
347 ("history_id", models.AutoField(primary_key=True, serialize=False)),
348 ("history_date", models.DateTimeField(db_index=True)),
349 ("history_change_reason", models.CharField(max_length=100, null=True)),
350 (
351 "history_type",
352 models.CharField(
353 choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
354 max_length=1,
355 ),
356 ),
357 (
358 "created_by",
359 models.ForeignKey(
360 blank=True,
361 db_constraint=False,
362 null=True,
363 on_delete=django.db.models.deletion.DO_NOTHING,
364 related_name="+",
365 to=settings.AUTH_USER_MODEL,
366 ),
367 ),
368 (
369 "deleted_by",
370 models.ForeignKey(
371 blank=True,
372 db_constraint=False,
373 null=True,
374 on_delete=django.db.models.deletion.DO_NOTHING,
375 related_name="+",
376 to=settings.AUTH_USER_MODEL,
377 ),
378 ),
379 (
380 "history_user",
381 models.ForeignKey(
382 null=True,
383 on_delete=django.db.models.deletion.SET_NULL,
384 related_name="+",
385 to=settings.AUTH_USER_MODEL,
386 ),
387 ),
388 (
389 "repository",
390 models.ForeignKey(
391 blank=True,
392 db_constraint=False,
393 null=True,
394 on_delete=django.db.models.deletion.DO_NOTHING,
395 related_name="+",
396 to="fossil.fossilrepository",
397 ),
398 ),
399 (
400 "updated_by",
401 models.ForeignKey(
402 blank=True,
403 db_constraint=False,
404 null=True,
405 on_delete=django.db.models.deletion.DO_NOTHING,
406 related_name="+",
407 to=settings.AUTH_USER_MODEL,
408 ),
409 ),
410 ],
411 options={
412 "verbose_name": "historical status check",
413 "verbose_name_plural": "historical status checks",
414 "ordering": ("-history_date", "-history_id"),
415 "get_latest_by": ("history_date", "history_id"),
416 },
417 bases=(simple_history.models.HistoricalChanges, models.Model),
418 ),
419 migrations.CreateModel(
420 name="BranchProtection",
421 fields=[
422 (
423 "id",
424 models.BigAutoField(
425 auto_created=True,
426 primary_key=True,
427 serialize=False,
428 verbose_name="ID",
429 ),
430 ),
431 ("version", models.PositiveIntegerField(default=1, editable=False)),
432 ("created_at", models.DateTimeField(auto_now_add=True)),
433 ("updated_at", models.DateTimeField(auto_now=True)),
434 ("deleted_at", models.DateTimeField(blank=True, null=True)),
435 (
436 "branch_pattern",
437 models.CharField(
438 help_text="Branch name or glob pattern (e.g., 'trunk', 'release-*')",
439 max_length=200,
440 ),
441 ),
442 ("require_status_checks", models.BooleanField(default=False)),
443 (
444 "required_contexts",
445 models.TextField(
446 blank=True,
447 default="",
448 help_text="Required CI contexts, one per line",
449 ),
450 ),
451 (
452 "restrict_push",
453 models.BooleanField(default=True, help_text="Only admins can push"),
454 ),
455 (
456 "created_by",
457 models.ForeignKey(
458 blank=True,
459 null=True,
460 on_delete=django.db.models.deletion.SET_NULL,
461 related_name="+",
462 to=settings.AUTH_USER_MODEL,
463 ),
464 ),
465 (
466 "deleted_by",
467 models.ForeignKey(
468 blank=True,
469 null=True,
470 on_delete=django.db.models.deletion.SET_NULL,
471 related_name="+",
472 to=settings.AUTH_USER_MODEL,
473 ),
474 ),
475 (
476 "repository",
477 models.ForeignKey(
478 on_delete=django.db.models.deletion.CASCADE,
479 related_name="branch_protections",
480 to="fossil.fossilrepository",
481 ),
482 ),
483 (
484 "updated_by",
485 models.ForeignKey(
486 blank=True,
487 null=True,
488 on_delete=django.db.models.deletion.SET_NULL,
489 related_name="+",
490 to=settings.AUTH_USER_MODEL,
491 ),
492 ),
493 ],
494 options={
495 "ordering": ["branch_pattern"],
496 "unique_together": {("repository", "branch_pattern")},
497 },
498 ),
499 migrations.CreateModel(
500 name="StatusCheck",
501 fields=[
502 (
503 "id",
504 models.BigAutoField(
505 auto_created=True,
506 primary_key=True,
507 serialize=False,
508 verbose_name="ID",
509 ),
510 ),
511 ("version", models.PositiveIntegerField(default=1, editable=False)),
512 ("created_at", models.DateTimeField(auto_now_add=True)),
513 ("updated_at", models.DateTimeField(auto_now=True)),
514 ("deleted_at", models.DateTimeField(blank=True, null=True)),
515 ("checkin_uuid", models.CharField(db_index=True, max_length=64)),
516 (
517 "context",
518 models.CharField(
519 help_text="CI context name (e.g., 'ci/tests', 'ci/lint')",
520 max_length=200,
521 ),
522 ),
523 (
524 "state",
525 models.CharField(
526 choices=[
527 ("pending", "Pending"),
528 ("success", "Success"),
529 ("failure", "Failure"),
530 ("error", "Error"),
531 ],
532 default="pending",
533 max_length=20,
534 ),
535 ),
536 (
537 "description",
538 models.CharField(blank=True, default="", max_length=500),
539 ),
540 (
541 "target_url",
542 models.URLField(blank=True, default="", help_text="Link to CI build details"),
543 ),
544 (
545 "created_by",
546 models.ForeignKey(
547 blank=True,
548 null=True,
549 on_delete=django.db.models.deletion.SET_NULL,
550 related_name="+",
551 to=settings.AUTH_USER_MODEL,
552 ),
553 ),
554 (
555 "deleted_by",
556 models.ForeignKey(
557 blank=True,
558 null=True,
559 on_delete=django.db.models.deletion.SET_NULL,
560 related_name="+",
561 to=settings.AUTH_USER_MODEL,
562 ),
563 ),
564 (
565 "repository",
566 models.ForeignKey(
567 on_delete=django.db.models.deletion.CASCADE,
568 related_name="status_checks",
569 to="fossil.fossilrepository",
570 ),
571 ),
572 (
573 "updated_by",
574 models.ForeignKey(
575 blank=True,
576 null=True,
577 on_delete=django.db.models.deletion.SET_NULL,
578 related_name="+",
579 to=settings.AUTH_USER_MODEL,
580 ),
581 ),
582 ],
583 options={
584 "ordering": ["-created_at"],
585 "unique_together": {("repository", "checkin_uuid", "context")},
586 },
587 ),
588 ]
--- fossil/models.py
+++ fossil/models.py
@@ -65,10 +65,13 @@
6565
def __str__(self):
6666
return f"{self.repository.filename} @ {self.created_at:%Y-%m-%d %H:%M}" if self.created_at else self.repository.filename
6767
6868
6969
# Import related models so they're discoverable by Django
70
+from fossil.api_tokens import APIToken # noqa: E402, F401
71
+from fossil.branch_protection import BranchProtection # noqa: E402, F401
72
+from fossil.ci import StatusCheck # noqa: E402, F401
7073
from fossil.forum import ForumPost # noqa: E402, F401
7174
from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
7275
from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
7376
from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
7477
from fossil.user_keys import UserSSHKey # noqa: E402, F401
7578
--- fossil/models.py
+++ fossil/models.py
@@ -65,10 +65,13 @@
65 def __str__(self):
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.forum import ForumPost # noqa: E402, F401
71 from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
72 from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
73 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
74 from fossil.user_keys import UserSSHKey # noqa: E402, F401
75
--- fossil/models.py
+++ fossil/models.py
@@ -65,10 +65,13 @@
65 def __str__(self):
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.api_tokens import APIToken # noqa: E402, F401
71 from fossil.branch_protection import BranchProtection # noqa: E402, F401
72 from fossil.ci import StatusCheck # noqa: E402, F401
73 from fossil.forum import ForumPost # noqa: E402, F401
74 from fossil.notifications import Notification, ProjectWatch # noqa: E402, F401
75 from fossil.releases import Release, ReleaseAsset # noqa: E402, F401
76 from fossil.sync_models import GitMirror, SSHKey, SyncLog # noqa: E402, F401
77 from fossil.user_keys import UserSSHKey # noqa: E402, F401
78
--- fossil/reader.py
+++ fossil/reader.py
@@ -378,10 +378,78 @@
378378
}
379379
)
380380
except sqlite3.OperationalError:
381381
pass
382382
return notes
383
+
384
+ def get_technote_detail(self, technote_uuid: str) -> dict | None:
385
+ """Get a single technote by UUID, including its body content."""
386
+ try:
387
+ row = self.conn.execute(
388
+ """
389
+ SELECT blob.rid, blob.uuid, event.mtime, event.user, event.comment
390
+ FROM event
391
+ JOIN blob ON event.objid = blob.rid
392
+ WHERE event.type = 'e' AND blob.uuid LIKE ? || '%'
393
+ ORDER BY event.mtime DESC
394
+ LIMIT 1
395
+ """,
396
+ (technote_uuid,),
397
+ ).fetchone()
398
+ if not row:
399
+ return None
400
+
401
+ # Read the technote body from the blob
402
+ blob_row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (row["rid"],)).fetchone()
403
+ body = ""
404
+ if blob_row and blob_row[0]:
405
+ raw = _decompress_blob(blob_row[0])
406
+ text = raw.decode("utf-8", errors="replace")
407
+ body = _extract_wiki_content(text)
408
+
409
+ return {
410
+ "uuid": row["uuid"],
411
+ "timestamp": _julian_to_datetime(row["mtime"]),
412
+ "user": row["user"] or "",
413
+ "comment": row["comment"] or "",
414
+ "body": body,
415
+ }
416
+ except sqlite3.OperationalError:
417
+ return None
418
+
419
+ def get_unversioned_files(self) -> list[dict]:
420
+ """List unversioned files. Returns [{name, size, mtime, hash}].
421
+
422
+ The unversioned table is created on demand by Fossil when the first
423
+ unversioned file is added, so it may not exist in every repo.
424
+ """
425
+ files = []
426
+ try:
427
+ rows = self.conn.execute(
428
+ """
429
+ SELECT name, sz, mtime, hash
430
+ FROM unversioned
431
+ WHERE hash IS NOT NULL AND hash != ''
432
+ ORDER BY name
433
+ """
434
+ ).fetchall()
435
+ for r in rows:
436
+ mtime_val = r["mtime"]
437
+ # mtime in the unversioned table is a Unix timestamp (integer seconds)
438
+ ts = datetime.fromtimestamp(int(mtime_val), tz=UTC) if mtime_val else None
439
+ files.append(
440
+ {
441
+ "name": r["name"],
442
+ "size": r["sz"] or 0,
443
+ "mtime": ts,
444
+ "hash": r["hash"] or "",
445
+ }
446
+ )
447
+ except sqlite3.OperationalError:
448
+ # Table doesn't exist yet -- no unversioned files added
449
+ pass
450
+ return files
383451
384452
def get_commit_activity(self, weeks: int = 52) -> list[dict]:
385453
"""Get weekly commit counts for the last N weeks. Returns [{week, count}]."""
386454
activity = []
387455
try:
388456
--- fossil/reader.py
+++ fossil/reader.py
@@ -378,10 +378,78 @@
378 }
379 )
380 except sqlite3.OperationalError:
381 pass
382 return notes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
384 def get_commit_activity(self, weeks: int = 52) -> list[dict]:
385 """Get weekly commit counts for the last N weeks. Returns [{week, count}]."""
386 activity = []
387 try:
388
--- fossil/reader.py
+++ fossil/reader.py
@@ -378,10 +378,78 @@
378 }
379 )
380 except sqlite3.OperationalError:
381 pass
382 return notes
383
384 def get_technote_detail(self, technote_uuid: str) -> dict | None:
385 """Get a single technote by UUID, including its body content."""
386 try:
387 row = self.conn.execute(
388 """
389 SELECT blob.rid, blob.uuid, event.mtime, event.user, event.comment
390 FROM event
391 JOIN blob ON event.objid = blob.rid
392 WHERE event.type = 'e' AND blob.uuid LIKE ? || '%'
393 ORDER BY event.mtime DESC
394 LIMIT 1
395 """,
396 (technote_uuid,),
397 ).fetchone()
398 if not row:
399 return None
400
401 # Read the technote body from the blob
402 blob_row = self.conn.execute("SELECT content FROM blob WHERE rid=?", (row["rid"],)).fetchone()
403 body = ""
404 if blob_row and blob_row[0]:
405 raw = _decompress_blob(blob_row[0])
406 text = raw.decode("utf-8", errors="replace")
407 body = _extract_wiki_content(text)
408
409 return {
410 "uuid": row["uuid"],
411 "timestamp": _julian_to_datetime(row["mtime"]),
412 "user": row["user"] or "",
413 "comment": row["comment"] or "",
414 "body": body,
415 }
416 except sqlite3.OperationalError:
417 return None
418
419 def get_unversioned_files(self) -> list[dict]:
420 """List unversioned files. Returns [{name, size, mtime, hash}].
421
422 The unversioned table is created on demand by Fossil when the first
423 unversioned file is added, so it may not exist in every repo.
424 """
425 files = []
426 try:
427 rows = self.conn.execute(
428 """
429 SELECT name, sz, mtime, hash
430 FROM unversioned
431 WHERE hash IS NOT NULL AND hash != ''
432 ORDER BY name
433 """
434 ).fetchall()
435 for r in rows:
436 mtime_val = r["mtime"]
437 # mtime in the unversioned table is a Unix timestamp (integer seconds)
438 ts = datetime.fromtimestamp(int(mtime_val), tz=UTC) if mtime_val else None
439 files.append(
440 {
441 "name": r["name"],
442 "size": r["sz"] or 0,
443 "mtime": ts,
444 "hash": r["hash"] or "",
445 }
446 )
447 except sqlite3.OperationalError:
448 # Table doesn't exist yet -- no unversioned files added
449 pass
450 return files
451
452 def get_commit_activity(self, weeks: int = 52) -> list[dict]:
453 """Get weekly commit counts for the last N weeks. Returns [{week, count}]."""
454 activity = []
455 try:
456
--- fossil/urls.py
+++ fossil/urls.py
@@ -31,10 +31,17 @@
3131
path("webhooks/<int:webhook_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"),
3232
path("user/<str:username>/", views.user_activity, name="user_activity"),
3333
path("branches/", views.branch_list, name="branches"),
3434
path("tags/", views.tag_list, name="tags"),
3535
path("technotes/", views.technote_list, name="technotes"),
36
+ path("technotes/create/", views.technote_create, name="technote_create"),
37
+ path("technotes/<str:technote_id>/", views.technote_detail, name="technote_detail"),
38
+ path("technotes/<str:technote_id>/edit/", views.technote_edit, name="technote_edit"),
39
+ # Unversioned content
40
+ path("files/", views.unversioned_list, name="unversioned"),
41
+ path("files/upload/", views.unversioned_upload, name="unversioned_upload"),
42
+ path("files/download/<path:filename>", views.unversioned_download, name="unversioned_download"),
3643
path("search/", views.search, name="search"),
3744
path("stats/", views.repo_stats, name="stats"),
3845
path("compare/", views.compare_checkins, name="compare"),
3946
path("settings/", views.repo_settings, name="repo_settings"),
4047
path("sync/", views.sync_pull, name="sync"),
@@ -59,6 +66,18 @@
5966
path("releases/<str:tag_name>/", views.release_detail, name="release_detail"),
6067
path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"),
6168
path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"),
6269
path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"),
6370
path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"),
71
+ # CI Status API
72
+ path("api/status", views.status_check_api, name="status_check_api"),
73
+ path("api/status/<str:checkin_uuid>/badge.svg", views.status_badge, name="status_badge"),
74
+ # API Tokens
75
+ path("tokens/", views.api_token_list, name="api_tokens"),
76
+ path("tokens/create/", views.api_token_create, name="api_token_create"),
77
+ path("tokens/<int:token_id>/delete/", views.api_token_delete, name="api_token_delete"),
78
+ # Branch Protection
79
+ path("branches/protect/", views.branch_protection_list, name="branch_protections"),
80
+ path("branches/protect/create/", views.branch_protection_create, name="branch_protection_create"),
81
+ path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"),
82
+ path("branches/protect/<int:pk>/delete/", views.branch_protection_delete, name="branch_protection_delete"),
6483
]
6584
--- fossil/urls.py
+++ fossil/urls.py
@@ -31,10 +31,17 @@
31 path("webhooks/<int:webhook_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"),
32 path("user/<str:username>/", views.user_activity, name="user_activity"),
33 path("branches/", views.branch_list, name="branches"),
34 path("tags/", views.tag_list, name="tags"),
35 path("technotes/", views.technote_list, name="technotes"),
 
 
 
 
 
 
 
36 path("search/", views.search, name="search"),
37 path("stats/", views.repo_stats, name="stats"),
38 path("compare/", views.compare_checkins, name="compare"),
39 path("settings/", views.repo_settings, name="repo_settings"),
40 path("sync/", views.sync_pull, name="sync"),
@@ -59,6 +66,18 @@
59 path("releases/<str:tag_name>/", views.release_detail, name="release_detail"),
60 path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"),
61 path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"),
62 path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"),
63 path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"),
 
 
 
 
 
 
 
 
 
 
 
 
64 ]
65
--- fossil/urls.py
+++ fossil/urls.py
@@ -31,10 +31,17 @@
31 path("webhooks/<int:webhook_id>/deliveries/", views.webhook_deliveries, name="webhook_deliveries"),
32 path("user/<str:username>/", views.user_activity, name="user_activity"),
33 path("branches/", views.branch_list, name="branches"),
34 path("tags/", views.tag_list, name="tags"),
35 path("technotes/", views.technote_list, name="technotes"),
36 path("technotes/create/", views.technote_create, name="technote_create"),
37 path("technotes/<str:technote_id>/", views.technote_detail, name="technote_detail"),
38 path("technotes/<str:technote_id>/edit/", views.technote_edit, name="technote_edit"),
39 # Unversioned content
40 path("files/", views.unversioned_list, name="unversioned"),
41 path("files/upload/", views.unversioned_upload, name="unversioned_upload"),
42 path("files/download/<path:filename>", views.unversioned_download, name="unversioned_download"),
43 path("search/", views.search, name="search"),
44 path("stats/", views.repo_stats, name="stats"),
45 path("compare/", views.compare_checkins, name="compare"),
46 path("settings/", views.repo_settings, name="repo_settings"),
47 path("sync/", views.sync_pull, name="sync"),
@@ -59,6 +66,18 @@
66 path("releases/<str:tag_name>/", views.release_detail, name="release_detail"),
67 path("releases/<str:tag_name>/edit/", views.release_edit, name="release_edit"),
68 path("releases/<str:tag_name>/delete/", views.release_delete, name="release_delete"),
69 path("releases/<str:tag_name>/upload/", views.release_asset_upload, name="release_asset_upload"),
70 path("releases/<str:tag_name>/assets/<int:asset_id>/", views.release_asset_download, name="release_asset_download"),
71 # CI Status API
72 path("api/status", views.status_check_api, name="status_check_api"),
73 path("api/status/<str:checkin_uuid>/badge.svg", views.status_badge, name="status_badge"),
74 # API Tokens
75 path("tokens/", views.api_token_list, name="api_tokens"),
76 path("tokens/create/", views.api_token_create, name="api_token_create"),
77 path("tokens/<int:token_id>/delete/", views.api_token_delete, name="api_token_delete"),
78 # Branch Protection
79 path("branches/protect/", views.branch_protection_list, name="branch_protections"),
80 path("branches/protect/create/", views.branch_protection_create, name="branch_protection_create"),
81 path("branches/protect/<int:pk>/edit/", views.branch_protection_edit, name="branch_protection_edit"),
82 path("branches/protect/<int:pk>/delete/", views.branch_protection_delete, name="branch_protection_delete"),
83 ]
84
+595 -2
--- fossil/views.py
+++ fossil/views.py
@@ -2,11 +2,11 @@
22
import re
33
from datetime import datetime
44
55
import markdown as md
66
from django.contrib.auth.decorators import login_required
7
-from django.http import Http404, HttpResponse
7
+from django.http import Http404, HttpResponse, JsonResponse
88
from django.shortcuts import get_object_or_404, redirect, render
99
from django.utils.safestring import mark_safe
1010
from django.views.decorators.csrf import csrf_exempt
1111
1212
from core.sanitize import sanitize_html
@@ -610,18 +610,24 @@
610610
"deletions": deletions,
611611
"language": ext,
612612
}
613613
)
614614
615
+ # Fetch CI status checks for this checkin
616
+ from fossil.ci import StatusCheck
617
+
618
+ status_checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid)
619
+
615620
return render(
616621
request,
617622
"fossil/checkin_detail.html",
618623
{
619624
"project": project,
620625
"fossil_repo": fossil_repo,
621626
"checkin": checkin,
622627
"file_diffs": file_diffs,
628
+ "status_checks": status_checks,
623629
"active_tab": "timeline",
624630
},
625631
)
626632
627633
@@ -1788,21 +1794,217 @@
17881794
17891795
# --- Technotes ---
17901796
17911797
17921798
def technote_list(request, slug):
1799
+ from projects.access import can_write_project
1800
+
17931801
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
17941802
17951803
with reader:
17961804
notes = reader.get_technotes()
17971805
1806
+ has_write = can_write_project(request.user, project)
1807
+
17981808
return render(
17991809
request,
18001810
"fossil/technote_list.html",
1801
- {"project": project, "notes": notes, "active_tab": "wiki"},
1811
+ {"project": project, "notes": notes, "has_write": has_write, "active_tab": "wiki"},
1812
+ )
1813
+
1814
+
1815
+@login_required
1816
+def technote_create(request, slug):
1817
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1818
+
1819
+ if request.method == "POST":
1820
+ title = request.POST.get("title", "").strip()
1821
+ body = request.POST.get("body", "")
1822
+ timestamp = request.POST.get("timestamp", "").strip()
1823
+ if title:
1824
+ from fossil.cli import FossilCLI
1825
+
1826
+ cli = FossilCLI()
1827
+ ts = timestamp if timestamp else None
1828
+ success = cli.technote_create(
1829
+ fossil_repo.full_path,
1830
+ title,
1831
+ body,
1832
+ timestamp=ts,
1833
+ user=request.user.username,
1834
+ )
1835
+ if success:
1836
+ from django.contrib import messages
1837
+
1838
+ messages.success(request, f'Technote "{title}" created.')
1839
+ return redirect("fossil:technotes", slug=slug)
1840
+
1841
+ return render(
1842
+ request,
1843
+ "fossil/technote_form.html",
1844
+ {"project": project, "active_tab": "wiki", "form_title": "New Technote"},
1845
+ )
1846
+
1847
+
1848
+def technote_detail(request, slug, technote_id):
1849
+ from projects.access import can_write_project
1850
+
1851
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
1852
+
1853
+ with reader:
1854
+ note = reader.get_technote_detail(technote_id)
1855
+
1856
+ if not note:
1857
+ raise Http404("Technote not found")
1858
+
1859
+ body_html = ""
1860
+ if note["body"]:
1861
+ body_html = mark_safe(sanitize_html(md.markdown(note["body"], extensions=["footnotes", "tables", "fenced_code"])))
1862
+
1863
+ has_write = can_write_project(request.user, project)
1864
+
1865
+ return render(
1866
+ request,
1867
+ "fossil/technote_detail.html",
1868
+ {
1869
+ "project": project,
1870
+ "note": note,
1871
+ "body_html": body_html,
1872
+ "has_write": has_write,
1873
+ "active_tab": "wiki",
1874
+ },
1875
+ )
1876
+
1877
+
1878
+@login_required
1879
+def technote_edit(request, slug, technote_id):
1880
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1881
+
1882
+ with reader:
1883
+ note = reader.get_technote_detail(technote_id)
1884
+
1885
+ if not note:
1886
+ raise Http404("Technote not found")
1887
+
1888
+ if request.method == "POST":
1889
+ body = request.POST.get("body", "")
1890
+ from fossil.cli import FossilCLI
1891
+
1892
+ cli = FossilCLI()
1893
+ success = cli.technote_edit(
1894
+ fossil_repo.full_path,
1895
+ technote_id,
1896
+ body,
1897
+ user=request.user.username,
1898
+ )
1899
+ if success:
1900
+ from django.contrib import messages
1901
+
1902
+ messages.success(request, "Technote updated.")
1903
+ return redirect("fossil:technote_detail", slug=slug, technote_id=technote_id)
1904
+
1905
+ return render(
1906
+ request,
1907
+ "fossil/technote_form.html",
1908
+ {
1909
+ "project": project,
1910
+ "note": note,
1911
+ "form_title": f"Edit Technote: {note['comment'][:60]}",
1912
+ "active_tab": "wiki",
1913
+ },
1914
+ )
1915
+
1916
+
1917
+# --- Unversioned Content ---
1918
+
1919
+
1920
+def unversioned_list(request, slug):
1921
+ from projects.access import can_admin_project
1922
+
1923
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
1924
+
1925
+ with reader:
1926
+ files = reader.get_unversioned_files()
1927
+
1928
+ has_admin = can_admin_project(request.user, project)
1929
+
1930
+ return render(
1931
+ request,
1932
+ "fossil/unversioned_list.html",
1933
+ {
1934
+ "project": project,
1935
+ "files": files,
1936
+ "has_admin": has_admin,
1937
+ "active_tab": "files",
1938
+ },
18021939
)
18031940
1941
+
1942
+def unversioned_download(request, slug, filename):
1943
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request)
1944
+
1945
+ import mimetypes
1946
+
1947
+ from fossil.cli import FossilCLI
1948
+
1949
+ cli = FossilCLI()
1950
+ try:
1951
+ content = cli.uv_cat(fossil_repo.full_path, filename)
1952
+ except FileNotFoundError as exc:
1953
+ raise Http404(f"Unversioned file not found: {filename}") from exc
1954
+
1955
+ content_type, _ = mimetypes.guess_type(filename)
1956
+ if not content_type:
1957
+ content_type = "application/octet-stream"
1958
+
1959
+ response = HttpResponse(content, content_type=content_type)
1960
+ response["Content-Disposition"] = f'attachment; filename="{filename.split("/")[-1]}"'
1961
+ response["Content-Length"] = len(content)
1962
+ return response
1963
+
1964
+
1965
+@login_required
1966
+def unversioned_upload(request, slug):
1967
+ project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
1968
+
1969
+ if request.method != "POST":
1970
+ return redirect("fossil:unversioned", slug=slug)
1971
+
1972
+ uploaded_file = request.FILES.get("file")
1973
+ if not uploaded_file:
1974
+ from django.contrib import messages
1975
+
1976
+ messages.error(request, "No file selected.")
1977
+ return redirect("fossil:unversioned", slug=slug)
1978
+
1979
+ import tempfile
1980
+
1981
+ from fossil.cli import FossilCLI
1982
+
1983
+ cli = FossilCLI()
1984
+
1985
+ # Write uploaded file to a temp location, then add via CLI
1986
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
1987
+ for chunk in uploaded_file.chunks():
1988
+ tmp.write(chunk)
1989
+ tmp_path = tmp.name
1990
+
1991
+ from pathlib import Path
1992
+
1993
+ try:
1994
+ success = cli.uv_add(fossil_repo.full_path, uploaded_file.name, Path(tmp_path))
1995
+ from django.contrib import messages
1996
+
1997
+ if success:
1998
+ messages.success(request, f'File "{uploaded_file.name}" uploaded.')
1999
+ else:
2000
+ messages.error(request, f'Failed to upload "{uploaded_file.name}".')
2001
+ finally:
2002
+ Path(tmp_path).unlink(missing_ok=True)
2003
+
2004
+ return redirect("fossil:unversioned", slug=slug)
2005
+
18042006
18052007
# --- Compare Checkins ---
18062008
18072009
18082010
def compare_checkins(request, slug):
@@ -2725,5 +2927,396 @@
27252927
27262928
# Increment download count atomically
27272929
ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
27282930
27292931
return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name)
2932
+
2933
+
2934
+# --- CI Status Check API ---
2935
+
2936
+
2937
+@csrf_exempt
2938
+def status_check_api(request, slug):
2939
+ """API endpoint for CI to report status checks.
2940
+
2941
+ POST /projects/<slug>/fossil/api/status
2942
+ Authorization: Bearer <api_token>
2943
+ {
2944
+ "checkin": "abc123...",
2945
+ "context": "ci/tests",
2946
+ "state": "success",
2947
+ "description": "All 200 tests passed",
2948
+ "target_url": "https://ci.example.com/build/123"
2949
+ }
2950
+
2951
+ GET /projects/<slug>/fossil/api/status?checkin=<uuid>
2952
+ Returns status checks for a specific checkin (public if project is public).
2953
+ """
2954
+ import json
2955
+
2956
+ from fossil.api_tokens import authenticate_api_token
2957
+ from fossil.ci import StatusCheck
2958
+
2959
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
2960
+ fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
2961
+
2962
+ if request.method == "GET":
2963
+ # Read access -- use normal project visibility rules
2964
+ from projects.access import can_read_project
2965
+
2966
+ if not can_read_project(request.user, project):
2967
+ return JsonResponse({"error": "Access denied"}, status=403)
2968
+
2969
+ checkin_uuid = request.GET.get("checkin", "")
2970
+ if not checkin_uuid:
2971
+ return JsonResponse({"error": "checkin parameter required"}, status=400)
2972
+
2973
+ checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid)
2974
+ data = [
2975
+ {
2976
+ "context": c.context,
2977
+ "state": c.state,
2978
+ "description": c.description,
2979
+ "target_url": c.target_url,
2980
+ "created_at": c.created_at.isoformat() if c.created_at else None,
2981
+ }
2982
+ for c in checks
2983
+ ]
2984
+ return JsonResponse({"checkin": checkin_uuid, "checks": data})
2985
+
2986
+ if request.method == "POST":
2987
+ token = authenticate_api_token(request, fossil_repo)
2988
+ if not token:
2989
+ return JsonResponse({"error": "Invalid or expired token"}, status=401)
2990
+
2991
+ if not token.has_permission("status:write"):
2992
+ return JsonResponse({"error": "Token lacks status:write permission"}, status=403)
2993
+
2994
+ try:
2995
+ body = json.loads(request.body)
2996
+ except (json.JSONDecodeError, ValueError):
2997
+ return JsonResponse({"error": "Invalid JSON"}, status=400)
2998
+
2999
+ checkin_uuid = body.get("checkin", "").strip()
3000
+ context = body.get("context", "").strip()
3001
+ state = body.get("state", "").strip()
3002
+ description = body.get("description", "").strip()
3003
+ target_url = body.get("target_url", "").strip()
3004
+
3005
+ if not checkin_uuid:
3006
+ return JsonResponse({"error": "checkin is required"}, status=400)
3007
+ if not context:
3008
+ return JsonResponse({"error": "context is required"}, status=400)
3009
+ if state not in StatusCheck.State.values:
3010
+ return JsonResponse({"error": f"state must be one of: {', '.join(StatusCheck.State.values)}"}, status=400)
3011
+ if len(context) > 200:
3012
+ return JsonResponse({"error": "context must be 200 characters or fewer"}, status=400)
3013
+ if len(description) > 500:
3014
+ return JsonResponse({"error": "description must be 500 characters or fewer"}, status=400)
3015
+
3016
+ check, created = StatusCheck.objects.update_or_create(
3017
+ repository=fossil_repo,
3018
+ checkin_uuid=checkin_uuid,
3019
+ context=context,
3020
+ defaults={
3021
+ "state": state,
3022
+ "description": description,
3023
+ "target_url": target_url,
3024
+ "created_by": None,
3025
+ },
3026
+ )
3027
+
3028
+ return JsonResponse(
3029
+ {
3030
+ "id": check.pk,
3031
+ "context": check.context,
3032
+ "state": check.state,
3033
+ "description": check.description,
3034
+ "target_url": check.target_url,
3035
+ "created": created,
3036
+ },
3037
+ status=201 if created else 200,
3038
+ )
3039
+
3040
+ return JsonResponse({"error": "Method not allowed"}, status=405)
3041
+
3042
+
3043
+def status_badge(request, slug, checkin_uuid):
3044
+ """SVG badge for CI status (like shields.io).
3045
+
3046
+ Returns an SVG image showing the aggregate status for all checks on a checkin.
3047
+ """
3048
+ from fossil.ci import StatusCheck
3049
+
3050
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
3051
+
3052
+ # Badge endpoint is public for embeddability (like shields.io)
3053
+ fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
3054
+
3055
+ checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid)
3056
+
3057
+ if not checks.exists():
3058
+ label = "build"
3059
+ message = "unknown"
3060
+ color = "#9ca3af" # gray
3061
+ else:
3062
+ states = set(checks.values_list("state", flat=True))
3063
+ if "error" in states or "failure" in states:
3064
+ label = "build"
3065
+ message = "failing"
3066
+ color = "#ef4444" # red
3067
+ elif "pending" in states:
3068
+ label = "build"
3069
+ message = "pending"
3070
+ color = "#eab308" # yellow
3071
+ else:
3072
+ label = "build"
3073
+ message = "passing"
3074
+ color = "#22c55e" # green
3075
+
3076
+ label_width = len(label) * 7 + 10
3077
+ message_width = len(message) * 7 + 10
3078
+ total_width = label_width + message_width
3079
+
3080
+ svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="20" role="img" aria-label="{label}: {message}">
3081
+ <title>{label}: {message}</title>
3082
+ <linearGradient id="s" x2="0" y2="100%">
3083
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
3084
+ <stop offset="1" stop-opacity=".1"/>
3085
+ </linearGradient>
3086
+ <clipPath id="r"><rect width="{total_width}" height="20" rx="3" fill="#fff"/></clipPath>
3087
+ <g clip-path="url(#r)">
3088
+ <rect width="{label_width}" height="20" fill="#555"/>
3089
+ <rect x="{label_width}" width="{message_width}" height="20" fill="{color}"/>
3090
+ <rect width="{total_width}" height="20" fill="url(#s)"/>
3091
+ </g>
3092
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
3093
+ <text x="{label_width / 2}" y="14">{label}</text>
3094
+ <text x="{label_width + message_width / 2}" y="14">{message}</text>
3095
+ </g>
3096
+</svg>"""
3097
+
3098
+ response = HttpResponse(svg, content_type="image/svg+xml")
3099
+ response["Cache-Control"] = "no-cache, no-store, must-revalidate"
3100
+ return response
3101
+
3102
+
3103
+# --- API Token Management ---
3104
+
3105
+
3106
+@login_required
3107
+def api_token_list(request, slug):
3108
+ """List API tokens for a project."""
3109
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3110
+
3111
+ from fossil.api_tokens import APIToken
3112
+
3113
+ tokens = APIToken.objects.filter(repository=fossil_repo)
3114
+
3115
+ return render(
3116
+ request,
3117
+ "fossil/api_token_list.html",
3118
+ {
3119
+ "project": project,
3120
+ "fossil_repo": fossil_repo,
3121
+ "tokens": tokens,
3122
+ "active_tab": "settings",
3123
+ },
3124
+ )
3125
+
3126
+
3127
+@login_required
3128
+def api_token_create(request, slug):
3129
+ """Generate a new API token. Shows the raw token once."""
3130
+ from django.contrib import messages
3131
+
3132
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3133
+
3134
+ from fossil.api_tokens import APIToken
3135
+
3136
+ raw_token = None
3137
+
3138
+ if request.method == "POST":
3139
+ name = request.POST.get("name", "").strip()
3140
+ permissions = request.POST.get("permissions", "status:write").strip()
3141
+ expires_at = request.POST.get("expires_at", "").strip() or None
3142
+
3143
+ if not name:
3144
+ messages.error(request, "Token name is required.")
3145
+ else:
3146
+ raw, token_hash, prefix = APIToken.generate()
3147
+ APIToken.objects.create(
3148
+ repository=fossil_repo,
3149
+ name=name,
3150
+ token_hash=token_hash,
3151
+ token_prefix=prefix,
3152
+ permissions=permissions,
3153
+ expires_at=expires_at,
3154
+ created_by=request.user,
3155
+ )
3156
+ raw_token = raw
3157
+ messages.success(request, f'Token "{name}" created. Copy it now -- it won\'t be shown again.')
3158
+
3159
+ return render(
3160
+ request,
3161
+ "fossil/api_token_create.html",
3162
+ {
3163
+ "project": project,
3164
+ "fossil_repo": fossil_repo,
3165
+ "raw_token": raw_token,
3166
+ "active_tab": "settings",
3167
+ },
3168
+ )
3169
+
3170
+
3171
+@login_required
3172
+def api_token_delete(request, slug, token_id):
3173
+ """Revoke (soft-delete) an API token."""
3174
+ from django.contrib import messages
3175
+
3176
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3177
+
3178
+ from fossil.api_tokens import APIToken
3179
+
3180
+ token = get_object_or_404(APIToken, pk=token_id, repository=fossil_repo, deleted_at__isnull=True)
3181
+
3182
+ if request.method == "POST":
3183
+ token.soft_delete(user=request.user)
3184
+ messages.success(request, f'Token "{token.name}" revoked.')
3185
+ return redirect("fossil:api_tokens", slug=slug)
3186
+
3187
+ return redirect("fossil:api_tokens", slug=slug)
3188
+
3189
+
3190
+# --- Branch Protection ---
3191
+
3192
+
3193
+@login_required
3194
+def branch_protection_list(request, slug):
3195
+ """List branch protection rules."""
3196
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3197
+
3198
+ from fossil.branch_protection import BranchProtection
3199
+
3200
+ rules = BranchProtection.objects.filter(repository=fossil_repo)
3201
+
3202
+ return render(
3203
+ request,
3204
+ "fossil/branch_protection_list.html",
3205
+ {
3206
+ "project": project,
3207
+ "fossil_repo": fossil_repo,
3208
+ "rules": rules,
3209
+ "active_tab": "settings",
3210
+ },
3211
+ )
3212
+
3213
+
3214
+@login_required
3215
+def branch_protection_create(request, slug):
3216
+ """Create a new branch protection rule."""
3217
+ from django.contrib import messages
3218
+
3219
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3220
+
3221
+ from fossil.branch_protection import BranchProtection
3222
+
3223
+ if request.method == "POST":
3224
+ branch_pattern = request.POST.get("branch_pattern", "").strip()
3225
+ require_status_checks = request.POST.get("require_status_checks") == "on"
3226
+ required_contexts = request.POST.get("required_contexts", "").strip()
3227
+ restrict_push = request.POST.get("restrict_push") == "on"
3228
+
3229
+ if not branch_pattern:
3230
+ messages.error(request, "Branch pattern is required.")
3231
+ elif BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exists():
3232
+ messages.error(request, f'A rule for "{branch_pattern}" already exists.')
3233
+ else:
3234
+ BranchProtection.objects.create(
3235
+ repository=fossil_repo,
3236
+ branch_pattern=branch_pattern,
3237
+ require_status_checks=require_status_checks,
3238
+ required_contexts=required_contexts,
3239
+ restrict_push=restrict_push,
3240
+ created_by=request.user,
3241
+ )
3242
+ messages.success(request, f'Branch protection rule for "{branch_pattern}" created.')
3243
+ return redirect("fossil:branch_protections", slug=slug)
3244
+
3245
+ return render(
3246
+ request,
3247
+ "fossil/branch_protection_form.html",
3248
+ {
3249
+ "project": project,
3250
+ "fossil_repo": fossil_repo,
3251
+ "form_title": "Create Branch Protection Rule",
3252
+ "submit_label": "Create Rule",
3253
+ "active_tab": "settings",
3254
+ },
3255
+ )
3256
+
3257
+
3258
+@login_required
3259
+def branch_protection_edit(request, slug, pk):
3260
+ """Edit a branch protection rule."""
3261
+ from django.contrib import messages
3262
+
3263
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3264
+
3265
+ from fossil.branch_protection import BranchProtection
3266
+
3267
+ rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3268
+
3269
+ if request.method == "POST":
3270
+ branch_pattern = request.POST.get("branch_pattern", "").strip()
3271
+ require_status_checks = request.POST.get("require_status_checks") == "on"
3272
+ required_contexts = request.POST.get("required_contexts", "").strip()
3273
+ restrict_push = request.POST.get("restrict_push") == "on"
3274
+
3275
+ if not branch_pattern:
3276
+ messages.error(request, "Branch pattern is required.")
3277
+ else:
3278
+ # Check uniqueness if pattern changed
3279
+ conflict = BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exclude(pk=rule.pk).exists()
3280
+ if conflict:
3281
+ messages.error(request, f'A rule for "{branch_pattern}" already exists.')
3282
+ else:
3283
+ rule.branch_pattern = branch_pattern
3284
+ rule.require_status_checks = require_status_checks
3285
+ rule.required_contexts = required_contexts
3286
+ rule.restrict_push = restrict_push
3287
+ rule.updated_by = request.user
3288
+ rule.save()
3289
+ messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" updated.')
3290
+ return redirect("fossil:branch_protections", slug=slug)
3291
+
3292
+ return render(
3293
+ request,
3294
+ "fossil/branch_protection_form.html",
3295
+ {
3296
+ "project": project,
3297
+ "fossil_repo": fossil_repo,
3298
+ "rule": rule,
3299
+ "form_title": f"Edit Rule: {rule.branch_pattern}",
3300
+ "submit_label": "Update Rule",
3301
+ "active_tab": "settings",
3302
+ },
3303
+ )
3304
+
3305
+
3306
+@login_required
3307
+def branch_protection_delete(request, slug, pk):
3308
+ """Soft-delete a branch protection rule."""
3309
+ from django.contrib import messages
3310
+
3311
+ project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3312
+
3313
+ from fossil.branch_protection import BranchProtection
3314
+
3315
+ rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3316
+
3317
+ if request.method == "POST":
3318
+ rule.soft_delete(user=request.user)
3319
+ messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" deleted.')
3320
+ return redirect("fossil:branch_protections", slug=slug)
3321
+
3322
+ return redirect("fossil:branch_protections", slug=slug)
27303323
--- fossil/views.py
+++ fossil/views.py
@@ -2,11 +2,11 @@
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
9 from django.utils.safestring import mark_safe
10 from django.views.decorators.csrf import csrf_exempt
11
12 from core.sanitize import sanitize_html
@@ -610,18 +610,24 @@
610 "deletions": deletions,
611 "language": ext,
612 }
613 )
614
 
 
 
 
 
615 return render(
616 request,
617 "fossil/checkin_detail.html",
618 {
619 "project": project,
620 "fossil_repo": fossil_repo,
621 "checkin": checkin,
622 "file_diffs": file_diffs,
 
623 "active_tab": "timeline",
624 },
625 )
626
627
@@ -1788,21 +1794,217 @@
1788
1789 # --- Technotes ---
1790
1791
1792 def technote_list(request, slug):
 
 
1793 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
1794
1795 with reader:
1796 notes = reader.get_technotes()
1797
 
 
1798 return render(
1799 request,
1800 "fossil/technote_list.html",
1801 {"project": project, "notes": notes, "active_tab": "wiki"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1802 )
1803
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1804
1805 # --- Compare Checkins ---
1806
1807
1808 def compare_checkins(request, slug):
@@ -2725,5 +2927,396 @@
2725
2726 # Increment download count atomically
2727 ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
2728
2729 return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2730
--- fossil/views.py
+++ fossil/views.py
@@ -2,11 +2,11 @@
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, JsonResponse
8 from django.shortcuts import get_object_or_404, redirect, render
9 from django.utils.safestring import mark_safe
10 from django.views.decorators.csrf import csrf_exempt
11
12 from core.sanitize import sanitize_html
@@ -610,18 +610,24 @@
610 "deletions": deletions,
611 "language": ext,
612 }
613 )
614
615 # Fetch CI status checks for this checkin
616 from fossil.ci import StatusCheck
617
618 status_checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid)
619
620 return render(
621 request,
622 "fossil/checkin_detail.html",
623 {
624 "project": project,
625 "fossil_repo": fossil_repo,
626 "checkin": checkin,
627 "file_diffs": file_diffs,
628 "status_checks": status_checks,
629 "active_tab": "timeline",
630 },
631 )
632
633
@@ -1788,21 +1794,217 @@
1794
1795 # --- Technotes ---
1796
1797
1798 def technote_list(request, slug):
1799 from projects.access import can_write_project
1800
1801 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
1802
1803 with reader:
1804 notes = reader.get_technotes()
1805
1806 has_write = can_write_project(request.user, project)
1807
1808 return render(
1809 request,
1810 "fossil/technote_list.html",
1811 {"project": project, "notes": notes, "has_write": has_write, "active_tab": "wiki"},
1812 )
1813
1814
1815 @login_required
1816 def technote_create(request, slug):
1817 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1818
1819 if request.method == "POST":
1820 title = request.POST.get("title", "").strip()
1821 body = request.POST.get("body", "")
1822 timestamp = request.POST.get("timestamp", "").strip()
1823 if title:
1824 from fossil.cli import FossilCLI
1825
1826 cli = FossilCLI()
1827 ts = timestamp if timestamp else None
1828 success = cli.technote_create(
1829 fossil_repo.full_path,
1830 title,
1831 body,
1832 timestamp=ts,
1833 user=request.user.username,
1834 )
1835 if success:
1836 from django.contrib import messages
1837
1838 messages.success(request, f'Technote "{title}" created.')
1839 return redirect("fossil:technotes", slug=slug)
1840
1841 return render(
1842 request,
1843 "fossil/technote_form.html",
1844 {"project": project, "active_tab": "wiki", "form_title": "New Technote"},
1845 )
1846
1847
1848 def technote_detail(request, slug, technote_id):
1849 from projects.access import can_write_project
1850
1851 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
1852
1853 with reader:
1854 note = reader.get_technote_detail(technote_id)
1855
1856 if not note:
1857 raise Http404("Technote not found")
1858
1859 body_html = ""
1860 if note["body"]:
1861 body_html = mark_safe(sanitize_html(md.markdown(note["body"], extensions=["footnotes", "tables", "fenced_code"])))
1862
1863 has_write = can_write_project(request.user, project)
1864
1865 return render(
1866 request,
1867 "fossil/technote_detail.html",
1868 {
1869 "project": project,
1870 "note": note,
1871 "body_html": body_html,
1872 "has_write": has_write,
1873 "active_tab": "wiki",
1874 },
1875 )
1876
1877
1878 @login_required
1879 def technote_edit(request, slug, technote_id):
1880 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "write")
1881
1882 with reader:
1883 note = reader.get_technote_detail(technote_id)
1884
1885 if not note:
1886 raise Http404("Technote not found")
1887
1888 if request.method == "POST":
1889 body = request.POST.get("body", "")
1890 from fossil.cli import FossilCLI
1891
1892 cli = FossilCLI()
1893 success = cli.technote_edit(
1894 fossil_repo.full_path,
1895 technote_id,
1896 body,
1897 user=request.user.username,
1898 )
1899 if success:
1900 from django.contrib import messages
1901
1902 messages.success(request, "Technote updated.")
1903 return redirect("fossil:technote_detail", slug=slug, technote_id=technote_id)
1904
1905 return render(
1906 request,
1907 "fossil/technote_form.html",
1908 {
1909 "project": project,
1910 "note": note,
1911 "form_title": f"Edit Technote: {note['comment'][:60]}",
1912 "active_tab": "wiki",
1913 },
1914 )
1915
1916
1917 # --- Unversioned Content ---
1918
1919
1920 def unversioned_list(request, slug):
1921 from projects.access import can_admin_project
1922
1923 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
1924
1925 with reader:
1926 files = reader.get_unversioned_files()
1927
1928 has_admin = can_admin_project(request.user, project)
1929
1930 return render(
1931 request,
1932 "fossil/unversioned_list.html",
1933 {
1934 "project": project,
1935 "files": files,
1936 "has_admin": has_admin,
1937 "active_tab": "files",
1938 },
1939 )
1940
1941
1942 def unversioned_download(request, slug, filename):
1943 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
1944
1945 import mimetypes
1946
1947 from fossil.cli import FossilCLI
1948
1949 cli = FossilCLI()
1950 try:
1951 content = cli.uv_cat(fossil_repo.full_path, filename)
1952 except FileNotFoundError as exc:
1953 raise Http404(f"Unversioned file not found: {filename}") from exc
1954
1955 content_type, _ = mimetypes.guess_type(filename)
1956 if not content_type:
1957 content_type = "application/octet-stream"
1958
1959 response = HttpResponse(content, content_type=content_type)
1960 response["Content-Disposition"] = f'attachment; filename="{filename.split("/")[-1]}"'
1961 response["Content-Length"] = len(content)
1962 return response
1963
1964
1965 @login_required
1966 def unversioned_upload(request, slug):
1967 project, fossil_repo, reader = _get_repo_and_reader(slug, request, "admin")
1968
1969 if request.method != "POST":
1970 return redirect("fossil:unversioned", slug=slug)
1971
1972 uploaded_file = request.FILES.get("file")
1973 if not uploaded_file:
1974 from django.contrib import messages
1975
1976 messages.error(request, "No file selected.")
1977 return redirect("fossil:unversioned", slug=slug)
1978
1979 import tempfile
1980
1981 from fossil.cli import FossilCLI
1982
1983 cli = FossilCLI()
1984
1985 # Write uploaded file to a temp location, then add via CLI
1986 with tempfile.NamedTemporaryFile(delete=False) as tmp:
1987 for chunk in uploaded_file.chunks():
1988 tmp.write(chunk)
1989 tmp_path = tmp.name
1990
1991 from pathlib import Path
1992
1993 try:
1994 success = cli.uv_add(fossil_repo.full_path, uploaded_file.name, Path(tmp_path))
1995 from django.contrib import messages
1996
1997 if success:
1998 messages.success(request, f'File "{uploaded_file.name}" uploaded.')
1999 else:
2000 messages.error(request, f'Failed to upload "{uploaded_file.name}".')
2001 finally:
2002 Path(tmp_path).unlink(missing_ok=True)
2003
2004 return redirect("fossil:unversioned", slug=slug)
2005
2006
2007 # --- Compare Checkins ---
2008
2009
2010 def compare_checkins(request, slug):
@@ -2725,5 +2927,396 @@
2927
2928 # Increment download count atomically
2929 ReleaseAsset.objects.filter(pk=asset.pk).update(download_count=db_models.F("download_count") + 1)
2930
2931 return FileResponse(asset.file.open("rb"), as_attachment=True, filename=asset.name)
2932
2933
2934 # --- CI Status Check API ---
2935
2936
2937 @csrf_exempt
2938 def status_check_api(request, slug):
2939 """API endpoint for CI to report status checks.
2940
2941 POST /projects/<slug>/fossil/api/status
2942 Authorization: Bearer <api_token>
2943 {
2944 "checkin": "abc123...",
2945 "context": "ci/tests",
2946 "state": "success",
2947 "description": "All 200 tests passed",
2948 "target_url": "https://ci.example.com/build/123"
2949 }
2950
2951 GET /projects/<slug>/fossil/api/status?checkin=<uuid>
2952 Returns status checks for a specific checkin (public if project is public).
2953 """
2954 import json
2955
2956 from fossil.api_tokens import authenticate_api_token
2957 from fossil.ci import StatusCheck
2958
2959 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
2960 fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
2961
2962 if request.method == "GET":
2963 # Read access -- use normal project visibility rules
2964 from projects.access import can_read_project
2965
2966 if not can_read_project(request.user, project):
2967 return JsonResponse({"error": "Access denied"}, status=403)
2968
2969 checkin_uuid = request.GET.get("checkin", "")
2970 if not checkin_uuid:
2971 return JsonResponse({"error": "checkin parameter required"}, status=400)
2972
2973 checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid)
2974 data = [
2975 {
2976 "context": c.context,
2977 "state": c.state,
2978 "description": c.description,
2979 "target_url": c.target_url,
2980 "created_at": c.created_at.isoformat() if c.created_at else None,
2981 }
2982 for c in checks
2983 ]
2984 return JsonResponse({"checkin": checkin_uuid, "checks": data})
2985
2986 if request.method == "POST":
2987 token = authenticate_api_token(request, fossil_repo)
2988 if not token:
2989 return JsonResponse({"error": "Invalid or expired token"}, status=401)
2990
2991 if not token.has_permission("status:write"):
2992 return JsonResponse({"error": "Token lacks status:write permission"}, status=403)
2993
2994 try:
2995 body = json.loads(request.body)
2996 except (json.JSONDecodeError, ValueError):
2997 return JsonResponse({"error": "Invalid JSON"}, status=400)
2998
2999 checkin_uuid = body.get("checkin", "").strip()
3000 context = body.get("context", "").strip()
3001 state = body.get("state", "").strip()
3002 description = body.get("description", "").strip()
3003 target_url = body.get("target_url", "").strip()
3004
3005 if not checkin_uuid:
3006 return JsonResponse({"error": "checkin is required"}, status=400)
3007 if not context:
3008 return JsonResponse({"error": "context is required"}, status=400)
3009 if state not in StatusCheck.State.values:
3010 return JsonResponse({"error": f"state must be one of: {', '.join(StatusCheck.State.values)}"}, status=400)
3011 if len(context) > 200:
3012 return JsonResponse({"error": "context must be 200 characters or fewer"}, status=400)
3013 if len(description) > 500:
3014 return JsonResponse({"error": "description must be 500 characters or fewer"}, status=400)
3015
3016 check, created = StatusCheck.objects.update_or_create(
3017 repository=fossil_repo,
3018 checkin_uuid=checkin_uuid,
3019 context=context,
3020 defaults={
3021 "state": state,
3022 "description": description,
3023 "target_url": target_url,
3024 "created_by": None,
3025 },
3026 )
3027
3028 return JsonResponse(
3029 {
3030 "id": check.pk,
3031 "context": check.context,
3032 "state": check.state,
3033 "description": check.description,
3034 "target_url": check.target_url,
3035 "created": created,
3036 },
3037 status=201 if created else 200,
3038 )
3039
3040 return JsonResponse({"error": "Method not allowed"}, status=405)
3041
3042
3043 def status_badge(request, slug, checkin_uuid):
3044 """SVG badge for CI status (like shields.io).
3045
3046 Returns an SVG image showing the aggregate status for all checks on a checkin.
3047 """
3048 from fossil.ci import StatusCheck
3049
3050 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
3051
3052 # Badge endpoint is public for embeddability (like shields.io)
3053 fossil_repo = get_object_or_404(FossilRepository, project=project, deleted_at__isnull=True)
3054
3055 checks = StatusCheck.objects.filter(repository=fossil_repo, checkin_uuid=checkin_uuid)
3056
3057 if not checks.exists():
3058 label = "build"
3059 message = "unknown"
3060 color = "#9ca3af" # gray
3061 else:
3062 states = set(checks.values_list("state", flat=True))
3063 if "error" in states or "failure" in states:
3064 label = "build"
3065 message = "failing"
3066 color = "#ef4444" # red
3067 elif "pending" in states:
3068 label = "build"
3069 message = "pending"
3070 color = "#eab308" # yellow
3071 else:
3072 label = "build"
3073 message = "passing"
3074 color = "#22c55e" # green
3075
3076 label_width = len(label) * 7 + 10
3077 message_width = len(message) * 7 + 10
3078 total_width = label_width + message_width
3079
3080 svg = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{total_width}" height="20" role="img" aria-label="{label}: {message}">
3081 <title>{label}: {message}</title>
3082 <linearGradient id="s" x2="0" y2="100%">
3083 <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
3084 <stop offset="1" stop-opacity=".1"/>
3085 </linearGradient>
3086 <clipPath id="r"><rect width="{total_width}" height="20" rx="3" fill="#fff"/></clipPath>
3087 <g clip-path="url(#r)">
3088 <rect width="{label_width}" height="20" fill="#555"/>
3089 <rect x="{label_width}" width="{message_width}" height="20" fill="{color}"/>
3090 <rect width="{total_width}" height="20" fill="url(#s)"/>
3091 </g>
3092 <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
3093 <text x="{label_width / 2}" y="14">{label}</text>
3094 <text x="{label_width + message_width / 2}" y="14">{message}</text>
3095 </g>
3096 </svg>"""
3097
3098 response = HttpResponse(svg, content_type="image/svg+xml")
3099 response["Cache-Control"] = "no-cache, no-store, must-revalidate"
3100 return response
3101
3102
3103 # --- API Token Management ---
3104
3105
3106 @login_required
3107 def api_token_list(request, slug):
3108 """List API tokens for a project."""
3109 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3110
3111 from fossil.api_tokens import APIToken
3112
3113 tokens = APIToken.objects.filter(repository=fossil_repo)
3114
3115 return render(
3116 request,
3117 "fossil/api_token_list.html",
3118 {
3119 "project": project,
3120 "fossil_repo": fossil_repo,
3121 "tokens": tokens,
3122 "active_tab": "settings",
3123 },
3124 )
3125
3126
3127 @login_required
3128 def api_token_create(request, slug):
3129 """Generate a new API token. Shows the raw token once."""
3130 from django.contrib import messages
3131
3132 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3133
3134 from fossil.api_tokens import APIToken
3135
3136 raw_token = None
3137
3138 if request.method == "POST":
3139 name = request.POST.get("name", "").strip()
3140 permissions = request.POST.get("permissions", "status:write").strip()
3141 expires_at = request.POST.get("expires_at", "").strip() or None
3142
3143 if not name:
3144 messages.error(request, "Token name is required.")
3145 else:
3146 raw, token_hash, prefix = APIToken.generate()
3147 APIToken.objects.create(
3148 repository=fossil_repo,
3149 name=name,
3150 token_hash=token_hash,
3151 token_prefix=prefix,
3152 permissions=permissions,
3153 expires_at=expires_at,
3154 created_by=request.user,
3155 )
3156 raw_token = raw
3157 messages.success(request, f'Token "{name}" created. Copy it now -- it won\'t be shown again.')
3158
3159 return render(
3160 request,
3161 "fossil/api_token_create.html",
3162 {
3163 "project": project,
3164 "fossil_repo": fossil_repo,
3165 "raw_token": raw_token,
3166 "active_tab": "settings",
3167 },
3168 )
3169
3170
3171 @login_required
3172 def api_token_delete(request, slug, token_id):
3173 """Revoke (soft-delete) an API token."""
3174 from django.contrib import messages
3175
3176 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3177
3178 from fossil.api_tokens import APIToken
3179
3180 token = get_object_or_404(APIToken, pk=token_id, repository=fossil_repo, deleted_at__isnull=True)
3181
3182 if request.method == "POST":
3183 token.soft_delete(user=request.user)
3184 messages.success(request, f'Token "{token.name}" revoked.')
3185 return redirect("fossil:api_tokens", slug=slug)
3186
3187 return redirect("fossil:api_tokens", slug=slug)
3188
3189
3190 # --- Branch Protection ---
3191
3192
3193 @login_required
3194 def branch_protection_list(request, slug):
3195 """List branch protection rules."""
3196 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3197
3198 from fossil.branch_protection import BranchProtection
3199
3200 rules = BranchProtection.objects.filter(repository=fossil_repo)
3201
3202 return render(
3203 request,
3204 "fossil/branch_protection_list.html",
3205 {
3206 "project": project,
3207 "fossil_repo": fossil_repo,
3208 "rules": rules,
3209 "active_tab": "settings",
3210 },
3211 )
3212
3213
3214 @login_required
3215 def branch_protection_create(request, slug):
3216 """Create a new branch protection rule."""
3217 from django.contrib import messages
3218
3219 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3220
3221 from fossil.branch_protection import BranchProtection
3222
3223 if request.method == "POST":
3224 branch_pattern = request.POST.get("branch_pattern", "").strip()
3225 require_status_checks = request.POST.get("require_status_checks") == "on"
3226 required_contexts = request.POST.get("required_contexts", "").strip()
3227 restrict_push = request.POST.get("restrict_push") == "on"
3228
3229 if not branch_pattern:
3230 messages.error(request, "Branch pattern is required.")
3231 elif BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exists():
3232 messages.error(request, f'A rule for "{branch_pattern}" already exists.')
3233 else:
3234 BranchProtection.objects.create(
3235 repository=fossil_repo,
3236 branch_pattern=branch_pattern,
3237 require_status_checks=require_status_checks,
3238 required_contexts=required_contexts,
3239 restrict_push=restrict_push,
3240 created_by=request.user,
3241 )
3242 messages.success(request, f'Branch protection rule for "{branch_pattern}" created.')
3243 return redirect("fossil:branch_protections", slug=slug)
3244
3245 return render(
3246 request,
3247 "fossil/branch_protection_form.html",
3248 {
3249 "project": project,
3250 "fossil_repo": fossil_repo,
3251 "form_title": "Create Branch Protection Rule",
3252 "submit_label": "Create Rule",
3253 "active_tab": "settings",
3254 },
3255 )
3256
3257
3258 @login_required
3259 def branch_protection_edit(request, slug, pk):
3260 """Edit a branch protection rule."""
3261 from django.contrib import messages
3262
3263 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3264
3265 from fossil.branch_protection import BranchProtection
3266
3267 rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3268
3269 if request.method == "POST":
3270 branch_pattern = request.POST.get("branch_pattern", "").strip()
3271 require_status_checks = request.POST.get("require_status_checks") == "on"
3272 required_contexts = request.POST.get("required_contexts", "").strip()
3273 restrict_push = request.POST.get("restrict_push") == "on"
3274
3275 if not branch_pattern:
3276 messages.error(request, "Branch pattern is required.")
3277 else:
3278 # Check uniqueness if pattern changed
3279 conflict = BranchProtection.objects.filter(repository=fossil_repo, branch_pattern=branch_pattern).exclude(pk=rule.pk).exists()
3280 if conflict:
3281 messages.error(request, f'A rule for "{branch_pattern}" already exists.')
3282 else:
3283 rule.branch_pattern = branch_pattern
3284 rule.require_status_checks = require_status_checks
3285 rule.required_contexts = required_contexts
3286 rule.restrict_push = restrict_push
3287 rule.updated_by = request.user
3288 rule.save()
3289 messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" updated.')
3290 return redirect("fossil:branch_protections", slug=slug)
3291
3292 return render(
3293 request,
3294 "fossil/branch_protection_form.html",
3295 {
3296 "project": project,
3297 "fossil_repo": fossil_repo,
3298 "rule": rule,
3299 "form_title": f"Edit Rule: {rule.branch_pattern}",
3300 "submit_label": "Update Rule",
3301 "active_tab": "settings",
3302 },
3303 )
3304
3305
3306 @login_required
3307 def branch_protection_delete(request, slug, pk):
3308 """Soft-delete a branch protection rule."""
3309 from django.contrib import messages
3310
3311 project, fossil_repo = _get_project_and_repo(slug, request, "admin")
3312
3313 from fossil.branch_protection import BranchProtection
3314
3315 rule = get_object_or_404(BranchProtection, pk=pk, repository=fossil_repo, deleted_at__isnull=True)
3316
3317 if request.method == "POST":
3318 rule.soft_delete(user=request.user)
3319 messages.success(request, f'Branch protection rule for "{rule.branch_pattern}" deleted.')
3320 return redirect("fossil:branch_protections", slug=slug)
3321
3322 return redirect("fossil:branch_protections", slug=slug)
3323
--- organization/urls.py
+++ organization/urls.py
@@ -18,10 +18,12 @@
1818
path("members/<str:username>/remove/", views.member_remove, name="member_remove"),
1919
# Roles
2020
path("roles/", views.role_list, name="role_list"),
2121
path("roles/initialize/", views.role_initialize, name="role_initialize"),
2222
path("roles/<slug:slug>/", views.role_detail, name="role_detail"),
23
+ # Audit log
24
+ path("audit/", views.audit_log, name="audit_log"),
2325
# Teams
2426
path("teams/", views.team_list, name="team_list"),
2527
path("teams/create/", views.team_create, name="team_create"),
2628
path("teams/<slug:slug>/", views.team_detail, name="team_detail"),
2729
path("teams/<slug:slug>/edit/", views.team_update, name="team_update"),
2830
--- organization/urls.py
+++ organization/urls.py
@@ -18,10 +18,12 @@
18 path("members/<str:username>/remove/", views.member_remove, name="member_remove"),
19 # Roles
20 path("roles/", views.role_list, name="role_list"),
21 path("roles/initialize/", views.role_initialize, name="role_initialize"),
22 path("roles/<slug:slug>/", views.role_detail, name="role_detail"),
 
 
23 # Teams
24 path("teams/", views.team_list, name="team_list"),
25 path("teams/create/", views.team_create, name="team_create"),
26 path("teams/<slug:slug>/", views.team_detail, name="team_detail"),
27 path("teams/<slug:slug>/edit/", views.team_update, name="team_update"),
28
--- organization/urls.py
+++ organization/urls.py
@@ -18,10 +18,12 @@
18 path("members/<str:username>/remove/", views.member_remove, name="member_remove"),
19 # Roles
20 path("roles/", views.role_list, name="role_list"),
21 path("roles/initialize/", views.role_initialize, name="role_initialize"),
22 path("roles/<slug:slug>/", views.role_detail, name="role_detail"),
23 # Audit log
24 path("audit/", views.audit_log, name="audit_log"),
25 # Teams
26 path("teams/", views.team_list, name="team_list"),
27 path("teams/create/", views.team_create, name="team_create"),
28 path("teams/<slug:slug>/", views.team_detail, name="team_detail"),
29 path("teams/<slug:slug>/edit/", views.team_update, name="team_update"),
30
--- organization/views.py
+++ organization/views.py
@@ -385,10 +385,62 @@
385385
request,
386386
"organization/role_detail.html",
387387
{"role": role, "grouped_permissions": grouped, "role_members": role_members},
388388
)
389389
390
+
391
+@login_required
392
+def audit_log(request):
393
+ """Unified audit log across all tracked models. Requires superuser or org admin."""
394
+ if not request.user.is_superuser:
395
+ P.ORGANIZATION_CHANGE.check(request.user)
396
+
397
+ from fossil.models import FossilRepository
398
+ from projects.models import Project
399
+
400
+ trackable_models = [
401
+ ("Project", Project),
402
+ ("Organization", Organization),
403
+ ("Team", Team),
404
+ ("FossilRepository", FossilRepository),
405
+ ]
406
+
407
+ entries = []
408
+ model_filter = request.GET.get("model", "").strip()
409
+
410
+ for label, model in trackable_models:
411
+ if model_filter and label.lower() != model_filter.lower():
412
+ continue
413
+ history_model = model.history.model
414
+ qs = history_model.objects.all().select_related("history_user").order_by("-history_date")[:100]
415
+ for h in qs:
416
+ entries.append(
417
+ {
418
+ "date": h.history_date,
419
+ "user": h.history_user,
420
+ "action": h.get_history_type_display(),
421
+ "model": label,
422
+ "object_repr": str(h),
423
+ "object_id": h.pk,
424
+ }
425
+ )
426
+
427
+ entries.sort(key=lambda x: x["date"], reverse=True)
428
+ entries = entries[:200]
429
+
430
+ available_models = [label for label, _ in trackable_models]
431
+
432
+ return render(
433
+ request,
434
+ "organization/audit_log.html",
435
+ {
436
+ "entries": entries,
437
+ "model_filter": model_filter,
438
+ "available_models": available_models,
439
+ },
440
+ )
441
+
390442
391443
@login_required
392444
def role_initialize(request):
393445
P.ORGANIZATION_CHANGE.check(request.user)
394446
395447
--- organization/views.py
+++ organization/views.py
@@ -385,10 +385,62 @@
385 request,
386 "organization/role_detail.html",
387 {"role": role, "grouped_permissions": grouped, "role_members": role_members},
388 )
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
391 @login_required
392 def role_initialize(request):
393 P.ORGANIZATION_CHANGE.check(request.user)
394
395
--- organization/views.py
+++ organization/views.py
@@ -385,10 +385,62 @@
385 request,
386 "organization/role_detail.html",
387 {"role": role, "grouped_permissions": grouped, "role_members": role_members},
388 )
389
390
391 @login_required
392 def audit_log(request):
393 """Unified audit log across all tracked models. Requires superuser or org admin."""
394 if not request.user.is_superuser:
395 P.ORGANIZATION_CHANGE.check(request.user)
396
397 from fossil.models import FossilRepository
398 from projects.models import Project
399
400 trackable_models = [
401 ("Project", Project),
402 ("Organization", Organization),
403 ("Team", Team),
404 ("FossilRepository", FossilRepository),
405 ]
406
407 entries = []
408 model_filter = request.GET.get("model", "").strip()
409
410 for label, model in trackable_models:
411 if model_filter and label.lower() != model_filter.lower():
412 continue
413 history_model = model.history.model
414 qs = history_model.objects.all().select_related("history_user").order_by("-history_date")[:100]
415 for h in qs:
416 entries.append(
417 {
418 "date": h.history_date,
419 "user": h.history_user,
420 "action": h.get_history_type_display(),
421 "model": label,
422 "object_repr": str(h),
423 "object_id": h.pk,
424 }
425 )
426
427 entries.sort(key=lambda x: x["date"], reverse=True)
428 entries = entries[:200]
429
430 available_models = [label for label, _ in trackable_models]
431
432 return render(
433 request,
434 "organization/audit_log.html",
435 {
436 "entries": entries,
437 "model_filter": model_filter,
438 "available_models": available_models,
439 },
440 )
441
442
443 @login_required
444 def role_initialize(request):
445 P.ORGANIZATION_CHANGE.check(request.user)
446
447
--- projects/admin.py
+++ projects/admin.py
@@ -1,10 +1,10 @@
11
from django.contrib import admin
22
33
from core.admin import BaseCoreAdmin
44
5
-from .models import Project, ProjectGroup, ProjectTeam
5
+from .models import Project, ProjectGroup, ProjectStar, ProjectTeam
66
77
88
@admin.register(ProjectGroup)
99
class ProjectGroupAdmin(BaseCoreAdmin):
1010
list_display = ("name", "slug", "created_at")
@@ -29,5 +29,13 @@
2929
class ProjectTeamAdmin(BaseCoreAdmin):
3030
list_display = ("project", "team", "role", "created_at")
3131
list_filter = ("role", "team")
3232
search_fields = ("project__name", "team__name")
3333
raw_id_fields = ("project", "team")
34
+
35
+
36
+@admin.register(ProjectStar)
37
+class ProjectStarAdmin(admin.ModelAdmin):
38
+ list_display = ("user", "project", "created_at")
39
+ list_filter = ("created_at",)
40
+ search_fields = ("user__username", "project__name")
41
+ raw_id_fields = ("user", "project")
3442
3543
ADDED projects/migrations/0003_add_projectstar.py
--- projects/admin.py
+++ projects/admin.py
@@ -1,10 +1,10 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import Project, ProjectGroup, ProjectTeam
6
7
8 @admin.register(ProjectGroup)
9 class ProjectGroupAdmin(BaseCoreAdmin):
10 list_display = ("name", "slug", "created_at")
@@ -29,5 +29,13 @@
29 class ProjectTeamAdmin(BaseCoreAdmin):
30 list_display = ("project", "team", "role", "created_at")
31 list_filter = ("role", "team")
32 search_fields = ("project__name", "team__name")
33 raw_id_fields = ("project", "team")
 
 
 
 
 
 
 
 
34
35 DDED projects/migrations/0003_add_projectstar.py
--- projects/admin.py
+++ projects/admin.py
@@ -1,10 +1,10 @@
1 from django.contrib import admin
2
3 from core.admin import BaseCoreAdmin
4
5 from .models import Project, ProjectGroup, ProjectStar, ProjectTeam
6
7
8 @admin.register(ProjectGroup)
9 class ProjectGroupAdmin(BaseCoreAdmin):
10 list_display = ("name", "slug", "created_at")
@@ -29,5 +29,13 @@
29 class ProjectTeamAdmin(BaseCoreAdmin):
30 list_display = ("project", "team", "role", "created_at")
31 list_filter = ("role", "team")
32 search_fields = ("project__name", "team__name")
33 raw_id_fields = ("project", "team")
34
35
36 @admin.register(ProjectStar)
37 class ProjectStarAdmin(admin.ModelAdmin):
38 list_display = ("user", "project", "created_at")
39 list_filter = ("created_at",)
40 search_fields = ("user__username", "project__name")
41 raw_id_fields = ("user", "project")
42
43 DDED projects/migrations/0003_add_projectstar.py
--- a/projects/migrations/0003_add_projectstar.py
+++ b/projects/migrations/0003_add_projectstar.py
@@ -0,0 +1,50 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 15:00
2
+
3
+import django.db.models.deletion
4
+from django.conf import settings
5
+from django.db import migrations, models
6
+
7
+
8
+class Migration(migrations.Migration):
9
+ dependencies = [
10
+ ("projects", "0002_historicalprojectgroup_projectgroup_and_more"),
11
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="ProjectStar",
17
+ fields=[
18
+ (
19
+ "id",
20
+ models.BigAutoField(
21
+ auto_created=True,
22
+ primary_key=True,
23
+ serialize=False,
24
+ verbose_name="ID",
25
+ ),
26
+ ),
27
+ ("created_at", models.DateTimeField(auto_now_add=True)),
28
+ (
29
+ "project",
30
+ models.ForeignKey(
31
+ on_delete=django.db.models.deletion.CASCADE,
32
+ related_name="stars",
33
+ to="projects.project",
34
+ ),
35
+ ),
36
+ (
37
+ "user",
38
+ models.ForeignKey(
39
+ on_delete=django.db.models.deletion.CASCADE,
40
+ related_name="starred_projects",
41
+ to=settings.AUTH_USER_MODEL,
42
+ ),
43
+ ),
44
+ ],
45
+ options={
46
+ "ordering": ["-created_at"],
47
+ "unique_together": {("user", "project")},
48
+ },
49
+ ),
50
+ ]
--- a/projects/migrations/0003_add_projectstar.py
+++ b/projects/migrations/0003_add_projectstar.py
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/projects/migrations/0003_add_projectstar.py
+++ b/projects/migrations/0003_add_projectstar.py
@@ -0,0 +1,50 @@
1 # Generated by Django 5.2.12 on 2026-04-07 15:00
2
3 import django.db.models.deletion
4 from django.conf import settings
5 from django.db import migrations, models
6
7
8 class Migration(migrations.Migration):
9 dependencies = [
10 ("projects", "0002_historicalprojectgroup_projectgroup_and_more"),
11 migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12 ]
13
14 operations = [
15 migrations.CreateModel(
16 name="ProjectStar",
17 fields=[
18 (
19 "id",
20 models.BigAutoField(
21 auto_created=True,
22 primary_key=True,
23 serialize=False,
24 verbose_name="ID",
25 ),
26 ),
27 ("created_at", models.DateTimeField(auto_now_add=True)),
28 (
29 "project",
30 models.ForeignKey(
31 on_delete=django.db.models.deletion.CASCADE,
32 related_name="stars",
33 to="projects.project",
34 ),
35 ),
36 (
37 "user",
38 models.ForeignKey(
39 on_delete=django.db.models.deletion.CASCADE,
40 related_name="starred_projects",
41 to=settings.AUTH_USER_MODEL,
42 ),
43 ),
44 ],
45 options={
46 "ordering": ["-created_at"],
47 "unique_together": {("user", "project")},
48 },
49 ),
50 ]
--- projects/models.py
+++ projects/models.py
@@ -1,5 +1,6 @@
1
+from django.conf import settings
12
from django.db import models
23
34
from core.models import ActiveManager, BaseCoreModel, Tracking
45
56
@@ -38,10 +39,29 @@
3839
all_objects = models.Manager()
3940
4041
class Meta:
4142
ordering = ["name"]
4243
44
+ @property
45
+ def star_count(self):
46
+ return self.stars.count()
47
+
48
+
49
+class ProjectStar(models.Model):
50
+ """Tracks which users have starred a project."""
51
+
52
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="starred_projects")
53
+ project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="stars")
54
+ created_at = models.DateTimeField(auto_now_add=True)
55
+
56
+ class Meta:
57
+ unique_together = ("user", "project")
58
+ ordering = ["-created_at"]
59
+
60
+ def __str__(self):
61
+ return f"{self.user} starred {self.project}"
62
+
4363
4464
class ProjectTeam(Tracking):
4565
class Role(models.TextChoices):
4666
READ = "read", "Read"
4767
WRITE = "write", "Write"
4868
--- projects/models.py
+++ projects/models.py
@@ -1,5 +1,6 @@
 
1 from django.db import models
2
3 from core.models import ActiveManager, BaseCoreModel, Tracking
4
5
@@ -38,10 +39,29 @@
38 all_objects = models.Manager()
39
40 class Meta:
41 ordering = ["name"]
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
44 class ProjectTeam(Tracking):
45 class Role(models.TextChoices):
46 READ = "read", "Read"
47 WRITE = "write", "Write"
48
--- projects/models.py
+++ projects/models.py
@@ -1,5 +1,6 @@
1 from django.conf import settings
2 from django.db import models
3
4 from core.models import ActiveManager, BaseCoreModel, Tracking
5
6
@@ -38,10 +39,29 @@
39 all_objects = models.Manager()
40
41 class Meta:
42 ordering = ["name"]
43
44 @property
45 def star_count(self):
46 return self.stars.count()
47
48
49 class ProjectStar(models.Model):
50 """Tracks which users have starred a project."""
51
52 user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="starred_projects")
53 project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="stars")
54 created_at = models.DateTimeField(auto_now_add=True)
55
56 class Meta:
57 unique_together = ("user", "project")
58 ordering = ["-created_at"]
59
60 def __str__(self):
61 return f"{self.user} starred {self.project}"
62
63
64 class ProjectTeam(Tracking):
65 class Role(models.TextChoices):
66 READ = "read", "Read"
67 WRITE = "write", "Write"
68
--- projects/urls.py
+++ projects/urls.py
@@ -12,10 +12,11 @@
1212
path("groups/create/", views.group_create, name="group_create"),
1313
path("groups/<slug:slug>/", views.group_detail, name="group_detail"),
1414
path("groups/<slug:slug>/edit/", views.group_edit, name="group_edit"),
1515
path("groups/<slug:slug>/delete/", views.group_delete, name="group_delete"),
1616
# Projects
17
+ path("<slug:slug>/star/", views.toggle_star, name="toggle_star"),
1718
path("<slug:slug>/", views.project_detail, name="detail"),
1819
path("<slug:slug>/edit/", views.project_update, name="update"),
1920
path("<slug:slug>/delete/", views.project_delete, name="delete"),
2021
path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"),
2122
path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"),
2223
--- projects/urls.py
+++ projects/urls.py
@@ -12,10 +12,11 @@
12 path("groups/create/", views.group_create, name="group_create"),
13 path("groups/<slug:slug>/", views.group_detail, name="group_detail"),
14 path("groups/<slug:slug>/edit/", views.group_edit, name="group_edit"),
15 path("groups/<slug:slug>/delete/", views.group_delete, name="group_delete"),
16 # Projects
 
17 path("<slug:slug>/", views.project_detail, name="detail"),
18 path("<slug:slug>/edit/", views.project_update, name="update"),
19 path("<slug:slug>/delete/", views.project_delete, name="delete"),
20 path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"),
21 path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"),
22
--- projects/urls.py
+++ projects/urls.py
@@ -12,10 +12,11 @@
12 path("groups/create/", views.group_create, name="group_create"),
13 path("groups/<slug:slug>/", views.group_detail, name="group_detail"),
14 path("groups/<slug:slug>/edit/", views.group_edit, name="group_edit"),
15 path("groups/<slug:slug>/delete/", views.group_delete, name="group_delete"),
16 # Projects
17 path("<slug:slug>/star/", views.toggle_star, name="toggle_star"),
18 path("<slug:slug>/", views.project_detail, name="detail"),
19 path("<slug:slug>/edit/", views.project_update, name="update"),
20 path("<slug:slug>/delete/", views.project_delete, name="delete"),
21 path("<slug:slug>/teams/add/", views.project_team_add, name="team_add"),
22 path("<slug:slug>/teams/<slug:team_slug>/edit/", views.project_team_edit, name="team_edit"),
23
--- projects/views.py
+++ projects/views.py
@@ -1,16 +1,17 @@
11
from django.contrib import messages
22
from django.contrib.auth.decorators import login_required
3
+from django.db.models import Count
34
from django.http import HttpResponse
45
from django.shortcuts import get_object_or_404, redirect, render
56
67
from core.permissions import P
78
from organization.models import Team
89
from organization.views import get_org
910
1011
from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm
11
-from .models import Project, ProjectGroup, ProjectTeam
12
+from .models import Project, ProjectGroup, ProjectStar, ProjectTeam
1213
1314
1415
@login_required
1516
def project_list(request):
1617
P.PROJECT_VIEW.check(request.user)
@@ -120,14 +121,16 @@
120121
121122
import json
122123
123124
# Check if user is watching this project
124125
is_watching = False
126
+ is_starred = False
125127
if request.user.is_authenticated:
126128
from fossil.notifications import ProjectWatch
127129
128130
is_watching = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).exists()
131
+ is_starred = ProjectStar.objects.filter(user=request.user, project=project).exists()
129132
130133
return render(
131134
request,
132135
"projects/project_detail.html",
133136
{
@@ -136,10 +139,11 @@
136139
"repo_stats": repo_stats,
137140
"recent_commits": recent_commits,
138141
"commit_activity_json": json.dumps([c["count"] for c in commit_activity]),
139142
"top_contributors": top_contributors,
140143
"is_watching": is_watching,
144
+ "is_starred": is_starred,
141145
},
142146
)
143147
144148
145149
@login_required
@@ -315,5 +319,69 @@
315319
return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"})
316320
317321
return redirect("projects:group_list")
318322
319323
return render(request, "projects/group_confirm_delete.html", {"group": group})
324
+
325
+
326
+# --- Project Starring ---
327
+
328
+
329
+@login_required
330
+def toggle_star(request, slug):
331
+ """Toggle star on/off for a project. POST only."""
332
+ project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
333
+ star, created = ProjectStar.objects.get_or_create(user=request.user, project=project)
334
+ if not created:
335
+ star.delete()
336
+ is_starred = created
337
+
338
+ if request.headers.get("HX-Request"):
339
+ return render(request, "projects/partials/star_button.html", {"project": project, "is_starred": is_starred})
340
+
341
+ return redirect("projects:detail", slug=slug)
342
+
343
+
344
+# --- Explore / Discover ---
345
+
346
+
347
+def explore(request):
348
+ """Public project discovery page. Works for anonymous users (public only) and authenticated users (public + internal)."""
349
+ if request.user.is_authenticated:
350
+ allowed_visibility = [Project.Visibility.PUBLIC, Project.Visibility.INTERNAL]
351
+ else:
352
+ allowed_visibility = [Project.Visibility.PUBLIC]
353
+
354
+ projects = (
355
+ Project.objects.filter(deleted_at__isnull=True, visibility__in=allowed_visibility)
356
+ .annotate(star_count_annotated=Count("stars"))
357
+ .select_related("organization")
358
+ )
359
+
360
+ sort = request.GET.get("sort", "stars")
361
+ if sort == "recent":
362
+ projects = projects.order_by("-created_at")
363
+ elif sort == "name":
364
+ projects = projects.order_by("name")
365
+ else:
366
+ sort = "stars"
367
+ projects = projects.order_by("-star_count_annotated", "-created_at")
368
+
369
+ search = request.GET.get("search", "").strip()
370
+ if search:
371
+ projects = projects.filter(name__icontains=search)
372
+
373
+ # Check which projects the current user has starred
374
+ starred_ids = set()
375
+ if request.user.is_authenticated:
376
+ starred_ids = set(ProjectStar.objects.filter(user=request.user).values_list("project_id", flat=True))
377
+
378
+ return render(
379
+ request,
380
+ "projects/explore.html",
381
+ {
382
+ "projects": projects,
383
+ "sort": sort,
384
+ "search": search,
385
+ "starred_ids": starred_ids,
386
+ },
387
+ )
320388
--- projects/views.py
+++ projects/views.py
@@ -1,16 +1,17 @@
1 from django.contrib import messages
2 from django.contrib.auth.decorators import login_required
 
3 from django.http import HttpResponse
4 from django.shortcuts import get_object_or_404, redirect, render
5
6 from core.permissions import P
7 from organization.models import Team
8 from organization.views import get_org
9
10 from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm
11 from .models import Project, ProjectGroup, ProjectTeam
12
13
14 @login_required
15 def project_list(request):
16 P.PROJECT_VIEW.check(request.user)
@@ -120,14 +121,16 @@
120
121 import json
122
123 # Check if user is watching this project
124 is_watching = False
 
125 if request.user.is_authenticated:
126 from fossil.notifications import ProjectWatch
127
128 is_watching = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).exists()
 
129
130 return render(
131 request,
132 "projects/project_detail.html",
133 {
@@ -136,10 +139,11 @@
136 "repo_stats": repo_stats,
137 "recent_commits": recent_commits,
138 "commit_activity_json": json.dumps([c["count"] for c in commit_activity]),
139 "top_contributors": top_contributors,
140 "is_watching": is_watching,
 
141 },
142 )
143
144
145 @login_required
@@ -315,5 +319,69 @@
315 return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"})
316
317 return redirect("projects:group_list")
318
319 return render(request, "projects/group_confirm_delete.html", {"group": group})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
--- projects/views.py
+++ projects/views.py
@@ -1,16 +1,17 @@
1 from django.contrib import messages
2 from django.contrib.auth.decorators import login_required
3 from django.db.models import Count
4 from django.http import HttpResponse
5 from django.shortcuts import get_object_or_404, redirect, render
6
7 from core.permissions import P
8 from organization.models import Team
9 from organization.views import get_org
10
11 from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm
12 from .models import Project, ProjectGroup, ProjectStar, ProjectTeam
13
14
15 @login_required
16 def project_list(request):
17 P.PROJECT_VIEW.check(request.user)
@@ -120,14 +121,16 @@
121
122 import json
123
124 # Check if user is watching this project
125 is_watching = False
126 is_starred = False
127 if request.user.is_authenticated:
128 from fossil.notifications import ProjectWatch
129
130 is_watching = ProjectWatch.objects.filter(user=request.user, project=project, deleted_at__isnull=True).exists()
131 is_starred = ProjectStar.objects.filter(user=request.user, project=project).exists()
132
133 return render(
134 request,
135 "projects/project_detail.html",
136 {
@@ -136,10 +139,11 @@
139 "repo_stats": repo_stats,
140 "recent_commits": recent_commits,
141 "commit_activity_json": json.dumps([c["count"] for c in commit_activity]),
142 "top_contributors": top_contributors,
143 "is_watching": is_watching,
144 "is_starred": is_starred,
145 },
146 )
147
148
149 @login_required
@@ -315,5 +319,69 @@
319 return HttpResponse(status=200, headers={"HX-Redirect": "/projects/groups/"})
320
321 return redirect("projects:group_list")
322
323 return render(request, "projects/group_confirm_delete.html", {"group": group})
324
325
326 # --- Project Starring ---
327
328
329 @login_required
330 def toggle_star(request, slug):
331 """Toggle star on/off for a project. POST only."""
332 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
333 star, created = ProjectStar.objects.get_or_create(user=request.user, project=project)
334 if not created:
335 star.delete()
336 is_starred = created
337
338 if request.headers.get("HX-Request"):
339 return render(request, "projects/partials/star_button.html", {"project": project, "is_starred": is_starred})
340
341 return redirect("projects:detail", slug=slug)
342
343
344 # --- Explore / Discover ---
345
346
347 def explore(request):
348 """Public project discovery page. Works for anonymous users (public only) and authenticated users (public + internal)."""
349 if request.user.is_authenticated:
350 allowed_visibility = [Project.Visibility.PUBLIC, Project.Visibility.INTERNAL]
351 else:
352 allowed_visibility = [Project.Visibility.PUBLIC]
353
354 projects = (
355 Project.objects.filter(deleted_at__isnull=True, visibility__in=allowed_visibility)
356 .annotate(star_count_annotated=Count("stars"))
357 .select_related("organization")
358 )
359
360 sort = request.GET.get("sort", "stars")
361 if sort == "recent":
362 projects = projects.order_by("-created_at")
363 elif sort == "name":
364 projects = projects.order_by("name")
365 else:
366 sort = "stars"
367 projects = projects.order_by("-star_count_annotated", "-created_at")
368
369 search = request.GET.get("search", "").strip()
370 if search:
371 projects = projects.filter(name__icontains=search)
372
373 # Check which projects the current user has starred
374 starred_ids = set()
375 if request.user.is_authenticated:
376 starred_ids = set(ProjectStar.objects.filter(user=request.user).values_list("project_id", flat=True))
377
378 return render(
379 request,
380 "projects/explore.html",
381 {
382 "projects": projects,
383 "sort": sort,
384 "search": search,
385 "starred_ids": starred_ids,
386 },
387 )
388
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -28,10 +28,14 @@
2828
Forum
2929
</a>
3030
<a href="{% url 'fossil:releases' slug=project.slug %}"
3131
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 %}">
3232
Releases
33
+ </a>
34
+ <a href="{% url 'fossil:unversioned' slug=project.slug %}"
35
+ class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'files' %}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 %}">
36
+ Files
3337
</a>
3438
{% if perms.projects.change_project %}
3539
<a href="{% url 'fossil:sync' slug=project.slug %}"
3640
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 %}">
3741
{% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
3842
3943
ADDED templates/fossil/api_token_create.html
4044
ADDED templates/fossil/api_token_list.html
4145
ADDED templates/fossil/branch_protection_form.html
4246
ADDED templates/fossil/branch_protection_list.html
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -28,10 +28,14 @@
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
39 DDED templates/fossil/api_token_create.html
40 DDED templates/fossil/api_token_list.html
41 DDED templates/fossil/branch_protection_form.html
42 DDED templates/fossil/branch_protection_list.html
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -28,10 +28,14 @@
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 <a href="{% url 'fossil:unversioned' slug=project.slug %}"
35 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'files' %}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 %}">
36 Files
37 </a>
38 {% if perms.projects.change_project %}
39 <a href="{% url 'fossil:sync' slug=project.slug %}"
40 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 %}">
41 {% if fossil_repo.remote_url %}Sync{% else %}Setup Sync{% endif %}
42
43 DDED templates/fossil/api_token_create.html
44 DDED templates/fossil/api_token_list.html
45 DDED templates/fossil/branch_protection_form.html
46 DDED templates/fossil/branch_protection_list.html
--- a/templates/fossil/api_token_create.html
+++ b/templates/fossil/api_token_create.html
@@ -0,0 +1,41 @@
1
+{% extends "base.html" %}
2
+{% block title %}Generate API Token — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="mb-4">
9
+ <a href="{% url 'fossil:api_tokens' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Tokens</a>
10
+</div>
11
+
12
+{% if raw_token %}
13
+<div class="mx-auto max-w-3xl">
14
+ <div class="rounded-lg bg-green-900/20 border border-green-800 p-6 mb-6">
15
+ <h2 class="text-lg font-bold text-green-300 mb-2">Token Generated</h2>
16
+ <p class="text-sm text-gray-300 mb-4">Copy this token now. It will not be shown again.</p>
17
+ <div class="flex items-center gap-2" x-data="{ copied: false }">
18
+ <input type="text" readonly value="{{ raw_token }}"
19
+ class="flex-1 rounded-md border-gray-700 bg-gray-900 text-gray-100 text-sm px-3 py-2 font-mono"
20
+ id="token-value">
21
+ <button @click="navigator.clipboard.writeText(document.getElementById('token-value').value); copied = true; setTimeout(() => status:write class="rounded-md px-3 py-2 text-sm font-medium transition-colors">
22
+ <span x-show="!copied">Copy</span>
23
+ <span x-show="copied">Copied</span>
24
+ </button>
25
+ </div>
26
+ </div>
27
+ <a href="{% url 'fossil:api_tokens' slug=project.slug %}"
28
+ class="inline-flex items-center rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
29
+ Done
30
+ </a>
31
+</div>
32
+{% else %}
33
+<div class="mx-auto max-w-3xl">
34
+ <h2 class="text-xl font-bold text-gray-100 mb-4">Generate API Token</h2>
35
+
36
+ <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
37
+ {% csrf_token %}
38
+
39
+ <div>
40
+ <label class="block text-sm font-medium text-gray-300 mb-1">Name <span class="text-red-400">*</span></label>
41
+ <input type="text" name="name" required placeholder="e.g., GitHub Actio
--- a/templates/fossil/api_token_create.html
+++ b/templates/fossil/api_token_create.html
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/api_token_create.html
+++ b/templates/fossil/api_token_create.html
@@ -0,0 +1,41 @@
1 {% extends "base.html" %}
2 {% block title %}Generate API Token — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="mb-4">
9 <a href="{% url 'fossil:api_tokens' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Tokens</a>
10 </div>
11
12 {% if raw_token %}
13 <div class="mx-auto max-w-3xl">
14 <div class="rounded-lg bg-green-900/20 border border-green-800 p-6 mb-6">
15 <h2 class="text-lg font-bold text-green-300 mb-2">Token Generated</h2>
16 <p class="text-sm text-gray-300 mb-4">Copy this token now. It will not be shown again.</p>
17 <div class="flex items-center gap-2" x-data="{ copied: false }">
18 <input type="text" readonly value="{{ raw_token }}"
19 class="flex-1 rounded-md border-gray-700 bg-gray-900 text-gray-100 text-sm px-3 py-2 font-mono"
20 id="token-value">
21 <button @click="navigator.clipboard.writeText(document.getElementById('token-value').value); copied = true; setTimeout(() => status:write class="rounded-md px-3 py-2 text-sm font-medium transition-colors">
22 <span x-show="!copied">Copy</span>
23 <span x-show="copied">Copied</span>
24 </button>
25 </div>
26 </div>
27 <a href="{% url 'fossil:api_tokens' slug=project.slug %}"
28 class="inline-flex items-center rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
29 Done
30 </a>
31 </div>
32 {% else %}
33 <div class="mx-auto max-w-3xl">
34 <h2 class="text-xl font-bold text-gray-100 mb-4">Generate API Token</h2>
35
36 <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
37 {% csrf_token %}
38
39 <div>
40 <label class="block text-sm font-medium text-gray-300 mb-1">Name <span class="text-red-400">*</span></label>
41 <input type="text" name="name" required placeholder="e.g., GitHub Actio
--- a/templates/fossil/api_token_list.html
+++ b/templates/fossil/api_token_list.html
@@ -0,0 +1,39 @@
1
+{% extends "base.html" %}
2
+{% block title %}API Tokens — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="flex items-center justify-between mb-6">
9
+ <div>
10
+ <h2 class="text-lg font-semibold text-gray-200">API Tokens</h2>
11
+ <p class="text-sm text-gray-500 mt-1">Tokens for CI/CD systems and automation. Used with Bearer autheth></svg>
12
+ </span>
13
+ <a href="{% url 'fossil:api_token_create' ug=project.slug %}"
14
+ class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm Generate Token
15
+ </a>
16
+</div>
17
+>
18
+
19
+<div id="token-content">
20
+{% if tokens %}
21
+<div class="space-y-3">
22
+ {% for token in tokens %}
23
+ <div class="rounded-lg bg-gray-800 border border-gray-700">
24
+ <div class="px-5 py-4">
25
+ <div class="flex items-start justify-between gap-4">
26
+ <div class="flex-1 min-w-0">
27
+ <div class="flex items-center gap-3 mb-1">
28
+ <span class="text-sm font-medium text-gray-100">{{ token.name }}</span>
29
+ <code class="text-xs font-mono text-gray-500">{{ token.token_prefix }}...</code>
30
+ </div>
31
+ <div class="flex items-center gap-3 text-xs text-gray-500 mt-1 flex-wrap">
32
+ <span>Permissions: {{ token.permissions }}</span>
33
+ <span>Created {{ token.created_at|timesince }} ago</span>
34
+ {% if token.last_used_at %}
35
+ <span>Last used {{ token.last_used_at|timesince }} ago</span>
36
+ {% else %}
37
+ <span>Never used</span>
38
+ {% endif %}
39
+ {%
--- a/templates/fossil/api_token_list.html
+++ b/templates/fossil/api_token_list.html
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/api_token_list.html
+++ b/templates/fossil/api_token_list.html
@@ -0,0 +1,39 @@
1 {% extends "base.html" %}
2 {% block title %}API Tokens — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="flex items-center justify-between mb-6">
9 <div>
10 <h2 class="text-lg font-semibold text-gray-200">API Tokens</h2>
11 <p class="text-sm text-gray-500 mt-1">Tokens for CI/CD systems and automation. Used with Bearer autheth></svg>
12 </span>
13 <a href="{% url 'fossil:api_token_create' ug=project.slug %}"
14 class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm Generate Token
15 </a>
16 </div>
17 >
18
19 <div id="token-content">
20 {% if tokens %}
21 <div class="space-y-3">
22 {% for token in tokens %}
23 <div class="rounded-lg bg-gray-800 border border-gray-700">
24 <div class="px-5 py-4">
25 <div class="flex items-start justify-between gap-4">
26 <div class="flex-1 min-w-0">
27 <div class="flex items-center gap-3 mb-1">
28 <span class="text-sm font-medium text-gray-100">{{ token.name }}</span>
29 <code class="text-xs font-mono text-gray-500">{{ token.token_prefix }}...</code>
30 </div>
31 <div class="flex items-center gap-3 text-xs text-gray-500 mt-1 flex-wrap">
32 <span>Permissions: {{ token.permissions }}</span>
33 <span>Created {{ token.created_at|timesince }} ago</span>
34 {% if token.last_used_at %}
35 <span>Last used {{ token.last_used_at|timesince }} ago</span>
36 {% else %}
37 <span>Never used</span>
38 {% endif %}
39 {%
--- a/templates/fossil/branch_protection_form.html
+++ b/templates/fossil/branch_protection_form.html
@@ -0,0 +1,64 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="mb-4">
9
+ <a href="{% url 'fossil:branch_protections' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Branch Protection</a>
10
+</div>
11
+
12
+<div class="mx-auto max-w-3xl">
13
+ <h2 class="text-xl font-bold text-gray-100 mb-4">{{ form_title }}</h2>
14
+
15
+ <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
16
+ {% csrf_token %}
17
+
18
+ <div>
19
+ <label class="block text-sm font-medium text-gray-300 mb-1">Branch Pattern <span class="text-red-400">*</span></label>
20
+ <input type="text" name="branch_pattern" required
21
+ placeholder="e.g., trunk, release-*"
22
+ value="{% if rule %}{{ rule.branch_pattern }}{% endif %}"
23
+ 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">
24
+ <p class="mt-1 text-xs text-gray-500">Branch name or glob pattern. Use <code class="text-gray-400">*</code> for wildcards.</p>
25
+ </div>
26
+
27
+ <div class="space-y-3">
28
+ <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
29
+ <input type="checkbox" name="restrict_push"
30
+ {% if rule %}{% if rule.restrict_push %}checked{% endif %}{% else %}checked{% endif %}
31
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
32
+ Restrict push (admin only)
33
+ </label>
34
+ <p class="text-xs text-gray-500 ml-6">Only project admins can push to matching branches.</p>
35
+
36
+ <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
37
+ <input type="checkbox" name="require_status_checks"
38
+ {% if rule and rule.require_status_checks %}checked{% endif %}
39
+ class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
40
+ Require status checks to pass
41
+ </label>
42
+ <p class="text-xs text-gray-500 ml-6">All required CI contexts must report success before merging.</p>
43
+ </div>
44
+
45
+ <div>
46
+ <label class="block text-sm font-medium text-gray-300 mb-1">Required CI Contexts</label>
47
+ <textarea name="required_contexts" rows="3"
48
+ placeholder="ci/tests&#10;ci/lint&#10;ci/build"
49
+ 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">{% if rule %}{{ rule.required_contexts }}{% endif %}</textarea>
50
+ <p class="mt-1 text-xs text-gray-500">One CI context per line. These must report success before merging is allowed.</p>
51
+ </div>
52
+
53
+ <div class="flex justify-end gap-3 pt-2">
54
+ <a href="{% url 'fossil:branch_protections' slug=project.slug %}"
55
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
56
+ Cancel
57
+ </a>
58
+ <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
59
+ {{ submit_label }}
60
+ </button>
61
+ </div>
62
+ </form>
63
+</div>
64
+{% endblock %}
--- a/templates/fossil/branch_protection_form.html
+++ b/templates/fossil/branch_protection_form.html
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/branch_protection_form.html
+++ b/templates/fossil/branch_protection_form.html
@@ -0,0 +1,64 @@
1 {% extends "base.html" %}
2 {% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="mb-4">
9 <a href="{% url 'fossil:branch_protections' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Branch Protection</a>
10 </div>
11
12 <div class="mx-auto max-w-3xl">
13 <h2 class="text-xl font-bold text-gray-100 mb-4">{{ form_title }}</h2>
14
15 <form method="post" class="space-y-4 rounded-lg bg-gray-800 p-6 shadow border border-gray-700">
16 {% csrf_token %}
17
18 <div>
19 <label class="block text-sm font-medium text-gray-300 mb-1">Branch Pattern <span class="text-red-400">*</span></label>
20 <input type="text" name="branch_pattern" required
21 placeholder="e.g., trunk, release-*"
22 value="{% if rule %}{{ rule.branch_pattern }}{% endif %}"
23 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">
24 <p class="mt-1 text-xs text-gray-500">Branch name or glob pattern. Use <code class="text-gray-400">*</code> for wildcards.</p>
25 </div>
26
27 <div class="space-y-3">
28 <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
29 <input type="checkbox" name="restrict_push"
30 {% if rule %}{% if rule.restrict_push %}checked{% endif %}{% else %}checked{% endif %}
31 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
32 Restrict push (admin only)
33 </label>
34 <p class="text-xs text-gray-500 ml-6">Only project admins can push to matching branches.</p>
35
36 <label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
37 <input type="checkbox" name="require_status_checks"
38 {% if rule and rule.require_status_checks %}checked{% endif %}
39 class="rounded border-gray-600 bg-gray-900 text-brand focus:ring-brand">
40 Require status checks to pass
41 </label>
42 <p class="text-xs text-gray-500 ml-6">All required CI contexts must report success before merging.</p>
43 </div>
44
45 <div>
46 <label class="block text-sm font-medium text-gray-300 mb-1">Required CI Contexts</label>
47 <textarea name="required_contexts" rows="3"
48 placeholder="ci/tests&#10;ci/lint&#10;ci/build"
49 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">{% if rule %}{{ rule.required_contexts }}{% endif %}</textarea>
50 <p class="mt-1 text-xs text-gray-500">One CI context per line. These must report success before merging is allowed.</p>
51 </div>
52
53 <div class="flex justify-end gap-3 pt-2">
54 <a href="{% url 'fossil:branch_protections' slug=project.slug %}"
55 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 shadow-sm ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
56 Cancel
57 </a>
58 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
59 {{ submit_label }}
60 </button>
61 </div>
62 </form>
63 </div>
64 {% endblock %}
--- a/templates/fossil/branch_protection_list.html
+++ b/templates/fossil/branch_protection_list.html
@@ -0,0 +1,28 @@
1
+{% extends "base.html" %}
2
+{% block title %}Branch Protection — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="flex items-center justify-between mb-6">
9
+ <div>
10
+ <h2 class="text-lg font-semibold text-gray-200">Branch Protection Rules</h2>
11
+ <p class="text-sm text-gray-500 mt-1">Protect branches from unreviewed changes and require CI checks to pass.</p>
12
+ </div>
13
+ <t_push %}
14
+ <span class="inline-flex rounded-full bg-yellow-900/50 px-2 p{% extends "base.html" in rules %}
15
+ <div class="rounded-lg bg-gray-800 border border-gray-700">
16
+ <div class="px-5 py-4">
17
+ <div class="flex items-start justify-between gap-4">
18
+ <div class="flex-1 min-w-0">
19
+ <div class="flex items-center gap-3 mb-1">
20
+ <code class="text-sm font-mono font-medium text-gray-100">{{ rule.branch_pattern }}</code>
21
+ {% if rule.restrict_push %}
22
+ <span class="inline-flex rounded-full bg-yellow-900/50 px-2 py-0.5 text-xs font-semibold text-yellow-300">Push restricted</span>
23
+ {% endif %}
24
+ {% if rule.require_status_checks %}
25
+ <span class="inline-flex rounded-full bg-blue-900/50 px-2 py-0.5 text-xs font-semibold text-blue-300">CI required</span>yellow-900/20 </div>
26
+ <div class="flex items-center gap-3 text-xs text-gray-500 mt-1 flex-wrap">
27
+ {% if rule.require_status_checks and rule.required_contexts %}
28
+ yellow-
--- a/templates/fossil/branch_protection_list.html
+++ b/templates/fossil/branch_protection_list.html
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/branch_protection_list.html
+++ b/templates/fossil/branch_protection_list.html
@@ -0,0 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Branch Protection — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="flex items-center justify-between mb-6">
9 <div>
10 <h2 class="text-lg font-semibold text-gray-200">Branch Protection Rules</h2>
11 <p class="text-sm text-gray-500 mt-1">Protect branches from unreviewed changes and require CI checks to pass.</p>
12 </div>
13 <t_push %}
14 <span class="inline-flex rounded-full bg-yellow-900/50 px-2 p{% extends "base.html" in rules %}
15 <div class="rounded-lg bg-gray-800 border border-gray-700">
16 <div class="px-5 py-4">
17 <div class="flex items-start justify-between gap-4">
18 <div class="flex-1 min-w-0">
19 <div class="flex items-center gap-3 mb-1">
20 <code class="text-sm font-mono font-medium text-gray-100">{{ rule.branch_pattern }}</code>
21 {% if rule.restrict_push %}
22 <span class="inline-flex rounded-full bg-yellow-900/50 px-2 py-0.5 text-xs font-semibold text-yellow-300">Push restricted</span>
23 {% endif %}
24 {% if rule.require_status_checks %}
25 <span class="inline-flex rounded-full bg-blue-900/50 px-2 py-0.5 text-xs font-semibold text-blue-300">CI required</span>yellow-900/20 </div>
26 <div class="flex items-center gap-3 text-xs text-gray-500 mt-1 flex-wrap">
27 {% if rule.require_status_checks and rule.required_contexts %}
28 yellow-
--- templates/fossil/checkin_detail.html
+++ templates/fossil/checkin_detail.html
@@ -69,10 +69,44 @@
6969
{% if checkin.is_merge %}
7070
<span class="inline-flex items-center rounded-full bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">merge</span>
7171
{% endif %}
7272
</div>
7373
</div>
74
+ {% if status_checks %}
75
+ <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50">
76
+ <div class="flex items-center gap-2 mb-2">
77
+ <span class="text-xs font-medium text-gray-400">CI Status</span>
78
+ <img src="{% url 'fossil:status_badge' slug=project.slug checkin_uuid=checkin.uuid %}" alt="CI Status" class="h-5">
79
+ </div>
80
+ <div class="flex flex-wrap gap-2">
81
+ {% for check in status_checks %}
82
+ <a {% if check.target_url %}href="{{ check.target_url }}" target="_blank" rel="noopener"{% endif %}
83
+ class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs
84
+ {% if check.state == 'success' %}border-green-800 bg-green-900/30 text-green-300
85
+ {% elif check.state == 'failure' %}border-red-800 bg-red-900/30 text-red-300
86
+ {% elif check.state == 'error' %}border-red-800 bg-red-900/30 text-red-300
87
+ {% else %}border-yellow-800 bg-yellow-900/30 text-yellow-300{% endif %}"
88
+ {% if check.description %}title="{{ check.description }}"{% endif %}>
89
+ {% if check.state == 'success' %}
90
+ <svg class="h-3.5 w-3.5 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
91
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
92
+ </svg>
93
+ {% elif check.state == 'failure' or check.state == 'error' %}
94
+ <svg class="h-3.5 w-3.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
95
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
96
+ </svg>
97
+ {% else %}
98
+ <svg class="h-3.5 w-3.5 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
99
+ <circle cx="12" cy="12" r="6"/>
100
+ </svg>
101
+ {% endif %}
102
+ {{ check.context }}
103
+ </a>
104
+ {% endfor %}
105
+ </div>
106
+ </div>
107
+ {% endif %}
74108
<div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50 flex items-center gap-6 flex-wrap text-xs">
75109
<div class="flex items-center gap-2">
76110
<span class="text-gray-500">Commit</span>
77111
<code class="font-mono text-gray-300 break-all">{{ checkin.uuid }}</code>
78112
</div>
79113
--- templates/fossil/checkin_detail.html
+++ templates/fossil/checkin_detail.html
@@ -69,10 +69,44 @@
69 {% if checkin.is_merge %}
70 <span class="inline-flex items-center rounded-full bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">merge</span>
71 {% endif %}
72 </div>
73 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74 <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50 flex items-center gap-6 flex-wrap text-xs">
75 <div class="flex items-center gap-2">
76 <span class="text-gray-500">Commit</span>
77 <code class="font-mono text-gray-300 break-all">{{ checkin.uuid }}</code>
78 </div>
79
--- templates/fossil/checkin_detail.html
+++ templates/fossil/checkin_detail.html
@@ -69,10 +69,44 @@
69 {% if checkin.is_merge %}
70 <span class="inline-flex items-center rounded-full bg-purple-900/50 px-2 py-0.5 text-xs text-purple-300">merge</span>
71 {% endif %}
72 </div>
73 </div>
74 {% if status_checks %}
75 <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50">
76 <div class="flex items-center gap-2 mb-2">
77 <span class="text-xs font-medium text-gray-400">CI Status</span>
78 <img src="{% url 'fossil:status_badge' slug=project.slug checkin_uuid=checkin.uuid %}" alt="CI Status" class="h-5">
79 </div>
80 <div class="flex flex-wrap gap-2">
81 {% for check in status_checks %}
82 <a {% if check.target_url %}href="{{ check.target_url }}" target="_blank" rel="noopener"{% endif %}
83 class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs
84 {% if check.state == 'success' %}border-green-800 bg-green-900/30 text-green-300
85 {% elif check.state == 'failure' %}border-red-800 bg-red-900/30 text-red-300
86 {% elif check.state == 'error' %}border-red-800 bg-red-900/30 text-red-300
87 {% else %}border-yellow-800 bg-yellow-900/30 text-yellow-300{% endif %}"
88 {% if check.description %}title="{{ check.description }}"{% endif %}>
89 {% if check.state == 'success' %}
90 <svg class="h-3.5 w-3.5 text-green-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
91 <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
92 </svg>
93 {% elif check.state == 'failure' or check.state == 'error' %}
94 <svg class="h-3.5 w-3.5 text-red-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
95 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
96 </svg>
97 {% else %}
98 <svg class="h-3.5 w-3.5 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
99 <circle cx="12" cy="12" r="6"/>
100 </svg>
101 {% endif %}
102 {{ check.context }}
103 </a>
104 {% endfor %}
105 </div>
106 </div>
107 {% endif %}
108 <div class="px-6 py-3 border-t border-gray-700 bg-gray-800/50 flex items-center gap-6 flex-wrap text-xs">
109 <div class="flex items-center gap-2">
110 <span class="text-gray-500">Commit</span>
111 <code class="font-mono text-gray-300 break-all">{{ checkin.uuid }}</code>
112 </div>
113
--- templates/fossil/repo_settings.html
+++ templates/fossil/repo_settings.html
@@ -134,10 +134,47 @@
134134
</button>
135135
</form>
136136
{% endif %}
137137
</div>
138138
</div>
139
+
140
+ <!-- API Tokens & Branch Protection -->
141
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-5">
142
+ <h2 class="text-lg font-semibold text-gray-200 mb-4">Management</h2>
143
+ <div class="space-y-3">
144
+ <a href="{% url 'fossil:api_tokens' slug=project.slug %}"
145
+ class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors">
146
+ <div>
147
+ <p class="text-sm font-medium text-gray-200">API Tokens</p>
148
+ <p class="text-xs text-gray-500">Manage tokens for CI/CD systems and automation.</p>
149
+ </div>
150
+ <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
151
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
152
+ </svg>
153
+ </a>
154
+ <a href="{% url 'fossil:branch_protections' slug=project.slug %}"
155
+ class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors">
156
+ <div>
157
+ <p class="text-sm font-medium text-gray-200">Branch Protection</p>
158
+ <p class="text-xs text-gray-500">Protect branches from unreviewed changes.</p>
159
+ </div>
160
+ <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
161
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
162
+ </svg>
163
+ </a>
164
+ <a href="{% url 'fossil:webhooks' slug=project.slug %}"
165
+ class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors">
166
+ <div>
167
+ <p class="text-sm font-medium text-gray-200">Webhooks</p>
168
+ <p class="text-xs text-gray-500">Configure outbound event notifications.</p>
169
+ </div>
170
+ <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
171
+ <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
172
+ </svg>
173
+ </a>
174
+ </div>
175
+ </div>
139176
140177
<!-- Danger Zone -->
141178
<div class="rounded-lg border-2 border-red-900/50 p-5">
142179
<h2 class="text-lg font-semibold text-red-400 mb-2">Danger Zone</h2>
143180
<p class="text-sm text-gray-400 mb-4">Destructive operations that cannot be undone.</p>
144181
145182
ADDED templates/fossil/technote_detail.html
146183
ADDED templates/fossil/technote_form.html
--- templates/fossil/repo_settings.html
+++ templates/fossil/repo_settings.html
@@ -134,10 +134,47 @@
134 </button>
135 </form>
136 {% endif %}
137 </div>
138 </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
140 <!-- Danger Zone -->
141 <div class="rounded-lg border-2 border-red-900/50 p-5">
142 <h2 class="text-lg font-semibold text-red-400 mb-2">Danger Zone</h2>
143 <p class="text-sm text-gray-400 mb-4">Destructive operations that cannot be undone.</p>
144
145 DDED templates/fossil/technote_detail.html
146 DDED templates/fossil/technote_form.html
--- templates/fossil/repo_settings.html
+++ templates/fossil/repo_settings.html
@@ -134,10 +134,47 @@
134 </button>
135 </form>
136 {% endif %}
137 </div>
138 </div>
139
140 <!-- API Tokens & Branch Protection -->
141 <div class="rounded-lg bg-gray-800 border border-gray-700 p-5">
142 <h2 class="text-lg font-semibold text-gray-200 mb-4">Management</h2>
143 <div class="space-y-3">
144 <a href="{% url 'fossil:api_tokens' slug=project.slug %}"
145 class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors">
146 <div>
147 <p class="text-sm font-medium text-gray-200">API Tokens</p>
148 <p class="text-xs text-gray-500">Manage tokens for CI/CD systems and automation.</p>
149 </div>
150 <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
151 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
152 </svg>
153 </a>
154 <a href="{% url 'fossil:branch_protections' slug=project.slug %}"
155 class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors">
156 <div>
157 <p class="text-sm font-medium text-gray-200">Branch Protection</p>
158 <p class="text-xs text-gray-500">Protect branches from unreviewed changes.</p>
159 </div>
160 <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
161 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
162 </svg>
163 </a>
164 <a href="{% url 'fossil:webhooks' slug=project.slug %}"
165 class="flex items-center justify-between rounded-md bg-gray-700/50 px-4 py-3 hover:bg-gray-700 transition-colors">
166 <div>
167 <p class="text-sm font-medium text-gray-200">Webhooks</p>
168 <p class="text-xs text-gray-500">Configure outbound event notifications.</p>
169 </div>
170 <svg class="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
171 <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5"/>
172 </svg>
173 </a>
174 </div>
175 </div>
176
177 <!-- Danger Zone -->
178 <div class="rounded-lg border-2 border-red-900/50 p-5">
179 <h2 class="text-lg font-semibold text-red-400 mb-2">Danger Zone</h2>
180 <p class="text-sm text-gray-400 mb-4">Destructive operations that cannot be undone.</p>
181
182 DDED templates/fossil/technote_detail.html
183 DDED templates/fossil/technote_form.html
--- a/templates/fossil/technote_detail.html
+++ b/templates/fossil/technote_detail.html
@@ -0,0 +1,46 @@
1
+{% extends "base.html" %}
2
+{% load fossil_filters %}
3
+{% block title %}{{ note.comment|truncatechars:60 }} — {{ 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:technotes' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Technotes</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
+ <h2 class="text-xl font-bold text-gray-100 mb-1">{{ note.comment }}</h2>
19
+ <div class="flex items-center gap-3 text-xs text-gray-500">
20
+ <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}"
21
+ ">{{ note.user|display_user }}</a>
22
+ <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:16 }}</span>
23
+ <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span>
24
+ </div>
25
+ </div>
26
+ {% if has_write %}
27
+ <div class="flex items-center gap-2 flex-shrink-0">
28
+ <a href="{% url 'fossil:technote_edit' slug=project.slug technote_id=note.uuid %}"
29
+ class="rounded-md bg-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-gray-600">Edit</a>
30
+ </div>
31
+ {% endif %}
32
+ </div>
33
+ </div>
34
+
35
+ <!-- Body -->
36
+ {% if body_html %}
37
+ <div class="px-6 py-5">
38
+ <div class="prose prose-invert prose-gray prose-sm max-w-none">
39
+ {{ body_html }}
40
+ </div>
41
+ </div>
42
+ {% else %}
43
+ <div class="px-6 py-5">
44
+ <p class="text-sm text-gray-500 italic">No body content.</p>
45
+ </div>
46
+ {% endi
--- a/templates/fossil/technote_detail.html
+++ b/templates/fossil/technote_detail.html
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/technote_detail.html
+++ b/templates/fossil/technote_detail.html
@@ -0,0 +1,46 @@
1 {% extends "base.html" %}
2 {% load fossil_filters %}
3 {% block title %}{{ note.comment|truncatechars:60 }} — {{ 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:technotes' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Technotes</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 <h2 class="text-xl font-bold text-gray-100 mb-1">{{ note.comment }}</h2>
19 <div class="flex items-center gap-3 text-xs text-gray-500">
20 <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}"
21 ">{{ note.user|display_user }}</a>
22 <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:16 }}</span>
23 <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span>
24 </div>
25 </div>
26 {% if has_write %}
27 <div class="flex items-center gap-2 flex-shrink-0">
28 <a href="{% url 'fossil:technote_edit' slug=project.slug technote_id=note.uuid %}"
29 class="rounded-md bg-gray-700 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-gray-600">Edit</a>
30 </div>
31 {% endif %}
32 </div>
33 </div>
34
35 <!-- Body -->
36 {% if body_html %}
37 <div class="px-6 py-5">
38 <div class="prose prose-invert prose-gray prose-sm max-w-none">
39 {{ body_html }}
40 </div>
41 </div>
42 {% else %}
43 <div class="px-6 py-5">
44 <p class="text-sm text-gray-500 italic">No body content.</p>
45 </div>
46 {% endi
--- a/templates/fossil/technote_form.html
+++ b/templates/fossil/technote_form.html
@@ -0,0 +1,21 @@
1
+{% extends "base.html" %}
2
+{% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block extra_head %}
5
+<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
6
+{% endblock %}
7
+
8
+{% block content %}
9
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
10
+{% include "fossil/_project_nav.html" %}
11
+
12
+<div class="mb-4">
13
+ <a href="{% url 'fossil:technotes' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Technotes</a>
14
+</div>
15
+
16
+<div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }">
17
+ <div class="flex items-center justify-between mb-4">
18
+ <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2>
19
+ <div class="flex items-center gap-1 text-xs">
20
+ <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>
21
+ <button @click="tab = 'preview'; document.getElementById('marked.parse(document.getElementB"
--- a/templates/fossil/technote_form.html
+++ b/templates/fossil/technote_form.html
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/technote_form.html
+++ b/templates/fossil/technote_form.html
@@ -0,0 +1,21 @@
1 {% extends "base.html" %}
2 {% block title %}{{ form_title }} — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block extra_head %}
5 <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
6 {% endblock %}
7
8 {% block content %}
9 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
10 {% include "fossil/_project_nav.html" %}
11
12 <div class="mb-4">
13 <a href="{% url 'fossil:technotes' slug=project.slug %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Technotes</a>
14 </div>
15
16 <div class="mx-auto max-w-6xl" x-data="{ tab: 'write' }">
17 <div class="flex items-center justify-between mb-4">
18 <h2 class="text-xl font-bold text-gray-100">{{ form_title }}</h2>
19 <div class="flex items-center gap-1 text-xs">
20 <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>
21 <button @click="tab = 'preview'; document.getElementById('marked.parse(document.getElementB"
--- templates/fossil/technote_list.html
+++ templates/fossil/technote_list.html
@@ -3,26 +3,45 @@
33
44
{% block content %}
55
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
66
{% include "fossil/_project_nav.html" %}
77
8
-<h2 class="text-lg font-semibold text-gray-200 mb-4">Technotes</h2>
8
+<div class="flex items-center justify-between mb-6">
9
+ <h2 class="text-lg font-semibold text-gray-200">Technotes</h2>
10
+ {% if has_write %}
11
+ <a href="{% url 'fossil:technote_create' slug=project.slug %}"
12
+ class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
13
+ New Technote
14
+ </a>
15
+ {% endif %}
16
+</div>
917
18
+{% if notes %}
1019
<div class="space-y-3">
1120
{% for note in notes %}
12
- <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4">
21
+ <a href="{% url 'fossil:technote_detail' slug=project.slug technote_id=note.uuid %}"
22
+ class="block rounded-lg bg-gray-800 border border-gray-700 px-5 py-4 hover:bg-gray-700/50 transition-colors">
1323
<div class="flex items-start justify-between gap-3">
1424
<div class="flex-1 min-w-0">
1525
<p class="text-sm text-gray-200">{{ note.comment|truncatechars:200 }}</p>
1626
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
17
- <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}" class="hover:text-brand-light">{{ note.user }}</a>
18
- <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=note.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ note.uuid|truncatechars:10 }}</a>
27
+ <span class="hover:text-brand-light">{{ note.user }}</span>
28
+ <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:10 }}</span>
1929
<span>{{ note.timestamp|date:"Y-m-d H:i" }}</span>
2030
</div>
2131
</div>
2232
</div>
23
- </div>
24
- {% empty %}
25
- <p class="text-sm text-gray-500 py-8 text-center">No technotes.</p>
33
+ </a>
2634
{% endfor %}
2735
</div>
36
+{% else %}
37
+<div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
38
+ <p class="text-sm text-gray-500">No technotes.</p>
39
+ {% if has_write %}
40
+ <a href="{% url 'fossil:technote_create' slug=project.slug %}"
41
+ class="mt-3 inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
42
+ Create the first technote
43
+ </a>
44
+ {% endif %}
45
+</div>
46
+{% endif %}
2847
{% endblock %}
2948
3049
ADDED templates/fossil/unversioned_list.html
--- templates/fossil/technote_list.html
+++ templates/fossil/technote_list.html
@@ -3,26 +3,45 @@
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <h2 class="text-lg font-semibold text-gray-200 mb-4">Technotes</h2>
 
 
 
 
 
 
 
 
9
 
10 <div class="space-y-3">
11 {% for note in notes %}
12 <div class="rounded-lg bg-gray-800 border border-gray-700 px-5 py-4">
 
13 <div class="flex items-start justify-between gap-3">
14 <div class="flex-1 min-w-0">
15 <p class="text-sm text-gray-200">{{ note.comment|truncatechars:200 }}</p>
16 <div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
17 <a href="{% url 'fossil:user_activity' slug=project.slug username=note.user %}" class="hover:text-brand-light">{{ note.user }}</a>
18 <a href="{% url 'fossil:checkin_detail' slug=project.slug checkin_uuid=note.uuid %}" class="font-mono text-brand-light hover:text-brand">{{ note.uuid|truncatechars:10 }}</a>
19 <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span>
20 </div>
21 </div>
22 </div>
23 </div>
24 {% empty %}
25 <p class="text-sm text-gray-500 py-8 text-center">No technotes.</p>
26 {% endfor %}
27 </div>
 
 
 
 
 
 
 
 
 
 
 
28 {% endblock %}
29
30 DDED templates/fossil/unversioned_list.html
--- templates/fossil/technote_list.html
+++ templates/fossil/technote_list.html
@@ -3,26 +3,45 @@
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="flex items-center justify-between mb-6">
9 <h2 class="text-lg font-semibold text-gray-200">Technotes</h2>
10 {% if has_write %}
11 <a href="{% url 'fossil:technote_create' slug=project.slug %}"
12 class="inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
13 New Technote
14 </a>
15 {% endif %}
16 </div>
17
18 {% if notes %}
19 <div class="space-y-3">
20 {% for note in notes %}
21 <a href="{% url 'fossil:technote_detail' slug=project.slug technote_id=note.uuid %}"
22 class="block rounded-lg bg-gray-800 border border-gray-700 px-5 py-4 hover:bg-gray-700/50 transition-colors">
23 <div class="flex items-start justify-between gap-3">
24 <div class="flex-1 min-w-0">
25 <p class="text-sm text-gray-200">{{ note.comment|truncatechars:200 }}</p>
26 <div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
27 <span class="hover:text-brand-light">{{ note.user }}</span>
28 <span class="font-mono text-brand-light">{{ note.uuid|truncatechars:10 }}</span>
29 <span>{{ note.timestamp|date:"Y-m-d H:i" }}</span>
30 </div>
31 </div>
32 </div>
33 </a>
 
 
34 {% endfor %}
35 </div>
36 {% else %}
37 <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
38 <p class="text-sm text-gray-500">No technotes.</p>
39 {% if has_write %}
40 <a href="{% url 'fossil:technote_create' slug=project.slug %}"
41 class="mt-3 inline-flex items-center rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
42 Create the first technote
43 </a>
44 {% endif %}
45 </div>
46 {% endif %}
47 {% endblock %}
48
49 DDED templates/fossil/unversioned_list.html
--- a/templates/fossil/unversioned_list.html
+++ b/templates/fossil/unversioned_list.html
@@ -0,0 +1,25 @@
1
+{% extends "base.html" %}
2
+{% block title %}Files — {{ project.name }} — Fossilrepo{% endblock %}
3
+
4
+{% block content %}
5
+<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6
+{% include "fossil/_project_nav.html" %}
7
+
8
+<div class="flex items-center justify-between mb-6">
9
+ <h2 class="text-lg font-semibold text-gray-2</div>
10
+
11
+{% if files <table class="overflow-hidden ro border border-gray-700">
12
+ <table class="min-w-full divide-y divide-gray-700">
13
+ thead class="bg-gray-900/80/50">
14
+ <tr>
15
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th>
16
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th>
17
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Modified</th>
18
+ <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th>
19
+ </tr>
20
+ </thead>
21
+ <tbody clasdivide-y divide-gray-700/70">
22
+ {% for file in files %}
23
+ <tr ".size|filesizeformat }200 font-mono">{{ file.name }}</td>
24
+ <td class="px-6 py-3 text-sm text-gray-400">{{ file.size|filesizeformat }}</td>
25
+ <td class="px-6 py-3 text-sm tex
--- a/templates/fossil/unversioned_list.html
+++ b/templates/fossil/unversioned_list.html
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/fossil/unversioned_list.html
+++ b/templates/fossil/unversioned_list.html
@@ -0,0 +1,25 @@
1 {% extends "base.html" %}
2 {% block title %}Files — {{ project.name }} — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
6 {% include "fossil/_project_nav.html" %}
7
8 <div class="flex items-center justify-between mb-6">
9 <h2 class="text-lg font-semibold text-gray-2</div>
10
11 {% if files <table class="overflow-hidden ro border border-gray-700">
12 <table class="min-w-full divide-y divide-gray-700">
13 thead class="bg-gray-900/80/50">
14 <tr>
15 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th>
16 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th>
17 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Modified</th>
18 <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th>
19 </tr>
20 </thead>
21 <tbody clasdivide-y divide-gray-700/70">
22 {% for file in files %}
23 <tr ".size|filesizeformat }200 font-mono">{{ file.name }}</td>
24 <td class="px-6 py-3 text-sm text-gray-400">{{ file.size|filesizeformat }}</td>
25 <td class="px-6 py-3 text-sm tex
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -22,10 +22,20 @@
2222
<svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
2323
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
2424
</svg>
2525
<span x-show="!collapsed" class="truncate">Dashboard</span>
2626
</a>
27
+
28
+ <!-- Explore -->
29
+ <a href="{% url 'explore' %}"
30
+ class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/explore/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
31
+ :title="collapsed ? 'Explore' : ''">
32
+ <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
33
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
34
+ </svg>
35
+ <span x-show="!collapsed" class="truncate">Explore</span>
36
+ </a>
2737
2838
<!-- Projects section -->
2939
{% if perms.projects.view_project %}
3040
<div>
3141
<button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
@@ -157,10 +167,22 @@
157167
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
158168
</svg>
159169
<span x-show="!collapsed" class="truncate">Settings</span>
160170
</a>
161171
{% endif %}
172
+
173
+ <!-- Audit Log -->
174
+ {% if user.is_superuser or perms.organization.change_organization %}
175
+ <a href="{% url 'organization:audit_log' %}"
176
+ class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/audit/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
177
+ :title="collapsed ? 'Audit Log' : ''">
178
+ <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
179
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
180
+ </svg>
181
+ <span x-show="!collapsed" class="truncate">Audit Log</span>
182
+ </a>
183
+ {% endif %}
162184
163185
<!-- Admin -->
164186
{% if user.is_staff %}
165187
<a href="{% url 'admin:index' %}"
166188
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white"
167189
168190
ADDED templates/organization/audit_log.html
169191
ADDED templates/projects/explore.html
170192
ADDED templates/projects/partials/star_button.html
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -22,10 +22,20 @@
22 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
23 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
24 </svg>
25 <span x-show="!collapsed" class="truncate">Dashboard</span>
26 </a>
 
 
 
 
 
 
 
 
 
 
27
28 <!-- Projects section -->
29 {% if perms.projects.view_project %}
30 <div>
31 <button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
@@ -157,10 +167,22 @@
157 <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
158 </svg>
159 <span x-show="!collapsed" class="truncate">Settings</span>
160 </a>
161 {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
162
163 <!-- Admin -->
164 {% if user.is_staff %}
165 <a href="{% url 'admin:index' %}"
166 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white"
167
168 DDED templates/organization/audit_log.html
169 DDED templates/projects/explore.html
170 DDED templates/projects/partials/star_button.html
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -22,10 +22,20 @@
22 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
23 <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
24 </svg>
25 <span x-show="!collapsed" class="truncate">Dashboard</span>
26 </a>
27
28 <!-- Explore -->
29 <a href="{% url 'explore' %}"
30 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if request.path == '/explore/' %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
31 :title="collapsed ? 'Explore' : ''">
32 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
33 <path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
34 </svg>
35 <span x-show="!collapsed" class="truncate">Explore</span>
36 </a>
37
38 <!-- Projects section -->
39 {% if perms.projects.view_project %}
40 <div>
41 <button @click="collapsed ? (collapsed = false, projectsOpen = true) : (projectsOpen = !projectsOpen)"
@@ -157,10 +167,22 @@
167 <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
168 </svg>
169 <span x-show="!collapsed" class="truncate">Settings</span>
170 </a>
171 {% endif %}
172
173 <!-- Audit Log -->
174 {% if user.is_superuser or perms.organization.change_organization %}
175 <a href="{% url 'organization:audit_log' %}"
176 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium {% if '/settings/audit/' in request.path %}bg-gray-800 text-white{% else %}text-gray-400 hover:bg-gray-800 hover:text-white{% endif %}"
177 :title="collapsed ? 'Audit Log' : ''">
178 <svg class="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
179 <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
180 </svg>
181 <span x-show="!collapsed" class="truncate">Audit Log</span>
182 </a>
183 {% endif %}
184
185 <!-- Admin -->
186 {% if user.is_staff %}
187 <a href="{% url 'admin:index' %}"
188 class="flex items-center gap-2 rounded-md px-2 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white"
189
190 DDED templates/organization/audit_log.html
191 DDED templates/projects/explore.html
192 DDED templates/projects/partials/star_button.html
--- a/templates/organization/audit_log.html
+++ b/templates/organization/audit_log.html
@@ -0,0 +1,36 @@
1
+{% extends "base.html" %}
2
+{% load humanize %}
3
+{% block title %}Audit Log — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<div class="md:flex md:items-center md:justify-between mb-6">
7
+ <div>
8
+ <h1 class="text-2xl font-bold text-gray-100">Audit Log</h1>
9
+ <p class="mt-1 text-sm text-gray-400">History of changes across all tracked models.</p>
10
+ </div>
11
+</div>
12
+
13
+<!-- Filter by model type -->
14
+<div class="flex <div class="flex flex-wrap gap-2 mb-6">
15
+ <a href="{% url 'organization:audit_log' %}"
16
+ class="rounded-md px-3 py-2 text-sm font-medium {% if not model_filter %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
17
+ All
18
+ </a>
19
+ {% for model_name in available_models %}
20
+ <a href="{% url 'organization:audit_log' %}?model={{ model_name }}"
21
+ class="rounded-md px-3 py-2 text-sm font-medium {% if model_filter == model_name %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
22
+ {{ model_name }}
23
+ </a>
24
+ {% endfor %}
25
+</dihidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
26
+ <table class="min-w-full divide-y divide-gray-700">
27
+ <thead class="bg-gray-900">
28
+ <tr>
29
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Date</th>
30
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">User</th>
31
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Action</th>
32
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Model</th>
33
+ <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Object</th>
34
+ </tr>
35
+ </thead>
36
+ <tbody clasendblock %}
--- a/templates/organization/audit_log.html
+++ b/templates/organization/audit_log.html
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/organization/audit_log.html
+++ b/templates/organization/audit_log.html
@@ -0,0 +1,36 @@
1 {% extends "base.html" %}
2 {% load humanize %}
3 {% block title %}Audit Log — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <div class="md:flex md:items-center md:justify-between mb-6">
7 <div>
8 <h1 class="text-2xl font-bold text-gray-100">Audit Log</h1>
9 <p class="mt-1 text-sm text-gray-400">History of changes across all tracked models.</p>
10 </div>
11 </div>
12
13 <!-- Filter by model type -->
14 <div class="flex <div class="flex flex-wrap gap-2 mb-6">
15 <a href="{% url 'organization:audit_log' %}"
16 class="rounded-md px-3 py-2 text-sm font-medium {% if not model_filter %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
17 All
18 </a>
19 {% for model_name in available_models %}
20 <a href="{% url 'organization:audit_log' %}?model={{ model_name }}"
21 class="rounded-md px-3 py-2 text-sm font-medium {% if model_filter == model_name %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
22 {{ model_name }}
23 </a>
24 {% endfor %}
25 </dihidden rounded-lg border border-gray-700 bg-gray-800 shadow-sm">
26 <table class="min-w-full divide-y divide-gray-700">
27 <thead class="bg-gray-900">
28 <tr>
29 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Date</th>
30 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">User</th>
31 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Action</th>
32 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Model</th>
33 <th class="px-6 py-3 text-left text-xs font-medium uppercase text-gray-400">Object</th>
34 </tr>
35 </thead>
36 <tbody clasendblock %}
--- a/templates/projects/explore.html
+++ b/templates/projects/explore.html
@@ -0,0 +1,51 @@
1
+{% extends "base.html" %}
2
+{% load humanize %}
3
+{% block title %}Explore Projects — Fossilrepo{% endblock %}
4
+
5
+{% block content %}
6
+<div class="md:flex md:items-center md:justify-between mb-6">
7
+ <div>
8
+ <h1 class="text-2xl font-bold text-gray-100">Explore Projects</h1>
9
+ <p class="mt-1 text-sm text-gray-400">Discover public and open-source Fossil repositories.</p>
10
+ </div>
11
+</div>
12
+
13
+<!-- Search and Sort controls -->
14
+<div class="flex flex-col sm:flex-row gap-4 mb-6">
15
+ <form method="get" action="{% url 'explore' %}" class="flex-1 flex gap-2">
16
+ <input type="search"
17
+ name="search"
18
+ value="{{ search }}"
19
+ placeholder="Searclass="flex-1 max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" />
20
+ {% if sort != "stars" %}
21
+ <input type="hidden" name="sort" value="{{ sort }}" />
22
+ {% endif %}
23
+ <button type="submit"
24
+ class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
25
+ Search
26
+ </button>
27
+ <div class="flex flex-wrap gap-2">
28
+ <a href="{% url 'explore' %}?sort=stars{% if search %}&search={{ search }}{% endif %}"
29
+ class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'stars' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
30
+ Stars
31
+ </a>
32
+ <a href="{% url 'explore' %}?sort=recent{% if search %}&search={{ search }}{% endif %}"
33
+ class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'recent' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
34
+ Recent
35
+ </a>
36
+ <a href="{% url 'explore' %}?sort=name{% if search %}&search={{ search }}{% endif %}"
37
+ class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'name' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
38
+ Name
39
+ </a>
40
+ </div>
41
+</div>
42
+
43
+<!-- Project cards grid -->
44
+<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
45
+ {% for project in projects %}
46
+ <a href="{% url 'projects:detail' slug=project.slug %}"
47
+ class="group rounded-lg border border-gray-700 bg-gray-800 p-5 hover:border-gray-600 transition-colors">
48
+ <div class="flex items-start justify-between mb-2">
49
+ <h3 class="text-base font-semibold text-gray-100 group-hover:text-brand-light truncate">{{ project.name }}</h3>
50
+ {% if project.visibility == "public" %}
51
+ <span class="ml-2 flex-shrink-0 inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300
--- a/templates/projects/explore.html
+++ b/templates/projects/explore.html
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/explore.html
+++ b/templates/projects/explore.html
@@ -0,0 +1,51 @@
1 {% extends "base.html" %}
2 {% load humanize %}
3 {% block title %}Explore Projects — Fossilrepo{% endblock %}
4
5 {% block content %}
6 <div class="md:flex md:items-center md:justify-between mb-6">
7 <div>
8 <h1 class="text-2xl font-bold text-gray-100">Explore Projects</h1>
9 <p class="mt-1 text-sm text-gray-400">Discover public and open-source Fossil repositories.</p>
10 </div>
11 </div>
12
13 <!-- Search and Sort controls -->
14 <div class="flex flex-col sm:flex-row gap-4 mb-6">
15 <form method="get" action="{% url 'explore' %}" class="flex-1 flex gap-2">
16 <input type="search"
17 name="search"
18 value="{{ search }}"
19 placeholder="Searclass="flex-1 max-w-md rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm" />
20 {% if sort != "stars" %}
21 <input type="hidden" name="sort" value="{{ sort }}" />
22 {% endif %}
23 <button type="submit"
24 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
25 Search
26 </button>
27 <div class="flex flex-wrap gap-2">
28 <a href="{% url 'explore' %}?sort=stars{% if search %}&search={{ search }}{% endif %}"
29 class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'stars' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
30 Stars
31 </a>
32 <a href="{% url 'explore' %}?sort=recent{% if search %}&search={{ search }}{% endif %}"
33 class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'recent' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
34 Recent
35 </a>
36 <a href="{% url 'explore' %}?sort=name{% if search %}&search={{ search }}{% endif %}"
37 class="rounded-md px-3 py-2 text-sm font-medium {% if sort == 'name' %}bg-brand text-white{% else %}bg-gray-700 text-gray-300 ring-1 ring-inset ring-gray-600 hover:bg-gray-600{% endif %}">
38 Name
39 </a>
40 </div>
41 </div>
42
43 <!-- Project cards grid -->
44 <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
45 {% for project in projects %}
46 <a href="{% url 'projects:detail' slug=project.slug %}"
47 class="group rounded-lg border border-gray-700 bg-gray-800 p-5 hover:border-gray-600 transition-colors">
48 <div class="flex items-start justify-between mb-2">
49 <h3 class="text-base font-semibold text-gray-100 group-hover:text-brand-light truncate">{{ project.name }}</h3>
50 {% if project.visibility == "public" %}
51 <span class="ml-2 flex-shrink-0 inline-flex rounded-full bg-green-900/50 px-2 text-xs font-semibold leading-5 text-green-300
--- a/templates/projects/partials/star_button.html
+++ b/templates/projects/partials/star_button.html
@@ -0,0 +1,19 @@
1
+<div id="star-button">
2
+ <button hx-post="{% url 'projects:toggle_star' slug=project.slug %}"
3
+ hx-target="#star-button"
4
+ hx-swap="outerHTML"
5
+ class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
6
+ {% if is_starred %}
7
+ <svg class="h-4 w-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
8
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
9
+ </svg>
10
+ <span>Starred</span>
11
+ {% else %}
12
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
13
+ <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"/>
14
+ </svg>
15
+ <span>Star</span>
16
+ {% endif %}
17
+ <span class="text-gray-400">{{ project.star_count }}</span>
18
+ </button>
19
+</div>
--- a/templates/projects/partials/star_button.html
+++ b/templates/projects/partials/star_button.html
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/projects/partials/star_button.html
+++ b/templates/projects/partials/star_button.html
@@ -0,0 +1,19 @@
1 <div id="star-button">
2 <button hx-post="{% url 'projects:toggle_star' slug=project.slug %}"
3 hx-target="#star-button"
4 hx-swap="outerHTML"
5 class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
6 {% if is_starred %}
7 <svg class="h-4 w-4 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
8 <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
9 </svg>
10 <span>Starred</span>
11 {% else %}
12 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
13 <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"/>
14 </svg>
15 <span>Star</span>
16 {% endif %}
17 <span class="text-gray-400">{{ project.star_count }}</span>
18 </button>
19 </div>
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -11,10 +11,11 @@
1111
<h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
1212
<p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p>
1313
</div>
1414
<div class="flex gap-3">
1515
{% if user.is_authenticated %}
16
+ {% include "projects/partials/star_button.html" %}
1617
<form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}">
1718
{% csrf_token %}
1819
{% if is_watching %}
1920
<button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
2021
<svg class="h-4 w-4 text-brand" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
2122
2223
ADDED tests/test_api_tokens.py
2324
ADDED tests/test_audit_log.py
2425
ADDED tests/test_branch_protection.py
2526
ADDED tests/test_ci_status.py
2627
ADDED tests/test_starring.py
2728
ADDED tests/test_technotes.py
2829
ADDED tests/test_unversioned.py
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -11,10 +11,11 @@
11 <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
12 <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p>
13 </div>
14 <div class="flex gap-3">
15 {% if user.is_authenticated %}
 
16 <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}">
17 {% csrf_token %}
18 {% if is_watching %}
19 <button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
20 <svg class="h-4 w-4 text-brand" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
21
22 DDED tests/test_api_tokens.py
23 DDED tests/test_audit_log.py
24 DDED tests/test_branch_protection.py
25 DDED tests/test_ci_status.py
26 DDED tests/test_starring.py
27 DDED tests/test_technotes.py
28 DDED tests/test_unversioned.py
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -11,10 +11,11 @@
11 <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
12 <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p>
13 </div>
14 <div class="flex gap-3">
15 {% if user.is_authenticated %}
16 {% include "projects/partials/star_button.html" %}
17 <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}">
18 {% csrf_token %}
19 {% if is_watching %}
20 <button type="submit" class="inline-flex items-center gap-1.5 rounded-md bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-100 ring-1 ring-inset ring-gray-600 hover:bg-gray-600">
21 <svg class="h-4 w-4 text-brand" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
22
23 DDED tests/test_api_tokens.py
24 DDED tests/test_audit_log.py
25 DDED tests/test_branch_protection.py
26 DDED tests/test_ci_status.py
27 DDED tests/test_starring.py
28 DDED tests/test_technotes.py
29 DDED tests/test_unversioned.py
--- a/tests/test_api_tokens.py
+++ b/tests/test_api_tokens.py
@@ -0,0 +1,291 @@
1
+import pytest
2
+from django.contrib.auth.models import User
3
+from django.test import Client
4
+
5
+from fossil.api_tokens import APIToken, authenticate_api_token
6
+from fossil.models import FossilRepository
7
+from organization.models import Team
8
+from projects.models import ProjectTeam
9
+
10
+
11
+@pytest.fixture
12
+def fossil_repo_obj(sample_project):
13
+ """Return the auto-created FossilRepository for sample_project."""
14
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
+
16
+
17
+@pytest.fixture
18
+def api_token(fossil_repo_obj, admin_user):
19
+ """Create an API token and return (APIToken instance, raw_token)."""
20
+ raw, token_hash, prefix = APIToken.generate()
21
+ token = APIToken.objects.create(
22
+ repository=fossil_repo_obj,
23
+ name="Test Token",
24
+ token_hash=token_hash,
25
+ token_prefix=prefix,
26
+ permissions="status:write",
27
+ created_by=admin_user,
28
+ )
29
+ return token, raw
30
+
31
+
32
+@pytest.fixture
33
+def writer_user(db, admin_user, sample_project):
34
+ """User with write access but not admin."""
35
+ writer = User.objects.create_user(username="writer_tok", password="testpass123")
36
+ team = Team.objects.create(name="Token Writers", organization=sample_project.organization, created_by=admin_user)
37
+ team.members.add(writer)
38
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
39
+ return writer
40
+
41
+
42
+@pytest.fixture
43
+def writer_client(writer_user):
44
+ client = Client()
45
+ client.login(username="writer_tok", password="testpass123")
46
+ return client
47
+
48
+
49
+# --- APIToken Model Tests ---
50
+
51
+
52
+@pytest.mark.django_db
53
+class TestAPITokenModel:
54
+ def test_generate_token(self):
55
+ raw, token_hash, prefix = APIToken.generate()
56
+ assert raw.startswith("frp_")
57
+ assert len(token_hash) == 64 # SHA-256 hex digest
58
+ assert prefix == raw[:12]
59
+
60
+ def test_hash_token(self):
61
+ raw, token_hash, prefix = APIToken.generate()
62
+ assert APIToken.hash_token(raw) == token_hash
63
+
64
+ def test_create_token(self, api_token):
65
+ token, raw = api_token
66
+ assert token.pk is not None
67
+ assert "Test Token" in str(token)
68
+ assert token.token_prefix in str(token)
69
+
70
+ def test_soft_delete(self, api_token, admin_user):
71
+ token, _ = api_token
72
+ token.soft_delete(user=admin_user)
73
+ assert token.is_deleted
74
+ assert APIToken.objects.filter(pk=token.pk).count() == 0
75
+ assert APIToken.all_objects.filter(pk=token.pk).count() == 1
76
+
77
+ def test_has_permission(self, api_token):
78
+ token, _ = api_token
79
+ assert token.has_permission("status:write") is True
80
+ assert token.has_permission("status:read") is False
81
+
82
+ def test_has_permission_wildcard(self, fossil_repo_obj, admin_user):
83
+ raw, token_hash, prefix = APIToken.generate()
84
+ token = APIToken.objects.create(
85
+ repository=fossil_repo_obj,
86
+ name="Wildcard",
87
+ token_hash=token_hash,
88
+ token_prefix=prefix,
89
+ permissions="*",
90
+ created_by=admin_user,
91
+ )
92
+ assert token.has_permission("status:write") is True
93
+ assert token.has_permission("anything") is True
94
+
95
+ def test_unique_token_hash(self, fossil_repo_obj, admin_user, api_token):
96
+ """Token hashes must be unique across all tokens."""
97
+ token, _ = api_token
98
+ from django.db import IntegrityError
99
+
100
+ with pytest.raises(IntegrityError):
101
+ APIToken.objects.create(
102
+ repository=fossil_repo_obj,
103
+ name="Duplicate Hash",
104
+ token_hash=token.token_hash,
105
+ token_prefix="dup_",
106
+ created_by=admin_user,
107
+ )
108
+
109
+
110
+# --- authenticate_api_token Tests ---
111
+
112
+
113
+@pytest.mark.django_db
114
+class TestAuthenticateAPIToken:
115
+ def test_valid_token(self, api_token, fossil_repo_obj):
116
+ token, raw = api_token
117
+
118
+ class FakeRequest:
119
+ META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
120
+
121
+ result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
122
+ assert result is not None
123
+ assert result.pk == token.pk
124
+
125
+ def test_invalid_token(self, fossil_repo_obj):
126
+ class FakeRequest:
127
+ META = {"HTTP_AUTHORIZATION": "Bearer invalid_token_xyz"}
128
+
129
+ result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
130
+ assert result is None
131
+
132
+ def test_no_auth_header(self, fossil_repo_obj):
133
+ class FakeRequest:
134
+ META = {}
135
+
136
+ result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
137
+ assert result is None
138
+
139
+ def test_non_bearer_auth(self, fossil_repo_obj):
140
+ class FakeRequest:
141
+ META = {"HTTP_AUTHORIZATION": "Basic dXNlcjpwYXNz"}
142
+
143
+ result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
144
+ assert result is None
145
+
146
+ def test_expired_token(self, fossil_repo_obj, admin_user):
147
+ from datetime import timedelta
148
+
149
+ from django.utils import timezone
150
+
151
+ raw, token_hash, prefix = APIToken.generate()
152
+ APIToken.objects.create(
153
+ repository=fossil_repo_obj,
154
+ name="Expired",
155
+ token_hash=token_hash,
156
+ token_prefix=prefix,
157
+ expires_at=timezone.now() - timedelta(days=1),
158
+ created_by=admin_user,
159
+ )
160
+
161
+ class FakeRequest:
162
+ META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
163
+
164
+ result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
165
+ assert result is None
166
+
167
+ def test_updates_last_used_at(self, api_token, fossil_repo_obj):
168
+ token, raw = api_token
169
+ assert token.last_used_at is None
170
+
171
+ class FakeRequest:
172
+ META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
173
+
174
+ authenticate_api_token(FakeRequest(), fossil_repo_obj)
175
+ token.refresh_from_db()
176
+ assert token.last_used_at is not None
177
+
178
+ def test_deleted_token_not_found(self, api_token, fossil_repo_obj, admin_user):
179
+ token, raw = api_token
180
+ token.soft_delete(user=admin_user)
181
+
182
+ class FakeRequest:
183
+ META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
184
+
185
+ result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
186
+ assert result is None
187
+
188
+
189
+# --- API Token List View Tests ---
190
+
191
+
192
+@pytest.mark.django_db
193
+class TestAPITokenListView:
194
+ def test_list_tokens(self, admin_client, sample_project, api_token):
195
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
196
+ assert response.status_code == 200
197
+ content = response.content.decode()
198
+ assert "Test Token" in content
199
+ assert "status:write" in content
200
+
201
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
202
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
203
+ assert response.status_code == 200
204
+ assert "No API tokens generated yet" in response.content.decode()
205
+
206
+ def test_list_denied_for_writer(self, writer_client, sample_project, api_token):
207
+ """Token management requires admin, not just write."""
208
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
209
+ assert response.status_code == 403
210
+
211
+ def test_list_denied_for_no_perm(self, no_perm_client, sample_project):
212
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
213
+ assert response.status_code == 403
214
+
215
+ def test_list_denied_for_anon(self, client, sample_project):
216
+ response = client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
217
+ assert response.status_code == 302 # redirect to login
218
+
219
+
220
+# --- API Token Create View Tests ---
221
+
222
+
223
+@pytest.mark.django_db
224
+class TestAPITokenCreateView:
225
+ def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
226
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/create/")
227
+ assert response.status_code == 200
228
+ assert "Generate API Token" in response.content.decode()
229
+
230
+ def test_create_token(self, admin_client, sample_project, fossil_repo_obj):
231
+ response = admin_client.post(
232
+ f"/projects/{sample_project.slug}/fossil/tokens/create/",
233
+ {"name": "New CI Token", "permissions": "status:write"},
234
+ )
235
+ assert response.status_code == 200 # Shows the token on the same page
236
+ content = response.content.decode()
237
+ assert "frp_" in content # Raw token is displayed
238
+ assert "Token Generated" in content
239
+
240
+ # Verify token was created in DB
241
+ token = APIToken.objects.get(name="New CI Token")
242
+ assert token.permissions == "status:write"
243
+
244
+ def test_create_token_without_name_fails(self, admin_client, sample_project, fossil_repo_obj):
245
+ response = admin_client.post(
246
+ f"/projects/{sample_project.slug}/fossil/tokens/create/",
247
+ {"name": "", "permissions": "status:write"},
248
+ )
249
+ assert response.status_code == 200
250
+ assert "Token name is required" in response.content.decode()
251
+ assert APIToken.objects.filter(repository__project=sample_project).count() == 0
252
+
253
+ def test_create_denied_for_writer(self, writer_client, sample_project):
254
+ response = writer_client.post(
255
+ f"/projects/{sample_project.slug}/fossil/tokens/create/",
256
+ {"name": "Evil Token"},
257
+ )
258
+ assert response.status_code == 403
259
+
260
+
261
+# --- API Token Delete View Tests ---
262
+
263
+
264
+@pytest.mark.django_db
265
+class TestAPITokenDeleteView:
266
+ def test_delete_token(self, admin_client, sample_project, api_token):
267
+ token, _ = api_token
268
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
269
+ assert response.status_code == 302
270
+ token.refresh_from_db()
271
+ assert token.is_deleted
272
+
273
+ def test_delete_get_redirects(self, admin_client, sample_project, api_token):
274
+ token, _ = api_token
275
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
276
+ assert response.status_code == 302 # GET redirects to list
277
+
278
+ def test_delete_denied_for_writer(self, writer_client, sample_project, api_token):
279
+ token, _ = api_token
280
+ response = writer_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
281
+ assert response.status_code == 403
282
+
283
+ def test_delete_nonexistent_token(self, admin_client, sample_project, fossil_repo_obj):
284
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/99999/delete/")
285
+ assert response.status_code == 404
286
+
287
+ def test_deleted_token_cannot_be_deleted_again(self, admin_client, sample_project, api_token, admin_user):
288
+ token, _ = api_token
289
+ token.soft_delete(user=admin_user)
290
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
291
+ assert response.status_code == 404
--- a/tests/test_api_tokens.py
+++ b/tests/test_api_tokens.py
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_api_tokens.py
+++ b/tests/test_api_tokens.py
@@ -0,0 +1,291 @@
1 import pytest
2 from django.contrib.auth.models import User
3 from django.test import Client
4
5 from fossil.api_tokens import APIToken, authenticate_api_token
6 from fossil.models import FossilRepository
7 from organization.models import Team
8 from projects.models import ProjectTeam
9
10
11 @pytest.fixture
12 def fossil_repo_obj(sample_project):
13 """Return the auto-created FossilRepository for sample_project."""
14 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
16
17 @pytest.fixture
18 def api_token(fossil_repo_obj, admin_user):
19 """Create an API token and return (APIToken instance, raw_token)."""
20 raw, token_hash, prefix = APIToken.generate()
21 token = APIToken.objects.create(
22 repository=fossil_repo_obj,
23 name="Test Token",
24 token_hash=token_hash,
25 token_prefix=prefix,
26 permissions="status:write",
27 created_by=admin_user,
28 )
29 return token, raw
30
31
32 @pytest.fixture
33 def writer_user(db, admin_user, sample_project):
34 """User with write access but not admin."""
35 writer = User.objects.create_user(username="writer_tok", password="testpass123")
36 team = Team.objects.create(name="Token Writers", organization=sample_project.organization, created_by=admin_user)
37 team.members.add(writer)
38 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
39 return writer
40
41
42 @pytest.fixture
43 def writer_client(writer_user):
44 client = Client()
45 client.login(username="writer_tok", password="testpass123")
46 return client
47
48
49 # --- APIToken Model Tests ---
50
51
52 @pytest.mark.django_db
53 class TestAPITokenModel:
54 def test_generate_token(self):
55 raw, token_hash, prefix = APIToken.generate()
56 assert raw.startswith("frp_")
57 assert len(token_hash) == 64 # SHA-256 hex digest
58 assert prefix == raw[:12]
59
60 def test_hash_token(self):
61 raw, token_hash, prefix = APIToken.generate()
62 assert APIToken.hash_token(raw) == token_hash
63
64 def test_create_token(self, api_token):
65 token, raw = api_token
66 assert token.pk is not None
67 assert "Test Token" in str(token)
68 assert token.token_prefix in str(token)
69
70 def test_soft_delete(self, api_token, admin_user):
71 token, _ = api_token
72 token.soft_delete(user=admin_user)
73 assert token.is_deleted
74 assert APIToken.objects.filter(pk=token.pk).count() == 0
75 assert APIToken.all_objects.filter(pk=token.pk).count() == 1
76
77 def test_has_permission(self, api_token):
78 token, _ = api_token
79 assert token.has_permission("status:write") is True
80 assert token.has_permission("status:read") is False
81
82 def test_has_permission_wildcard(self, fossil_repo_obj, admin_user):
83 raw, token_hash, prefix = APIToken.generate()
84 token = APIToken.objects.create(
85 repository=fossil_repo_obj,
86 name="Wildcard",
87 token_hash=token_hash,
88 token_prefix=prefix,
89 permissions="*",
90 created_by=admin_user,
91 )
92 assert token.has_permission("status:write") is True
93 assert token.has_permission("anything") is True
94
95 def test_unique_token_hash(self, fossil_repo_obj, admin_user, api_token):
96 """Token hashes must be unique across all tokens."""
97 token, _ = api_token
98 from django.db import IntegrityError
99
100 with pytest.raises(IntegrityError):
101 APIToken.objects.create(
102 repository=fossil_repo_obj,
103 name="Duplicate Hash",
104 token_hash=token.token_hash,
105 token_prefix="dup_",
106 created_by=admin_user,
107 )
108
109
110 # --- authenticate_api_token Tests ---
111
112
113 @pytest.mark.django_db
114 class TestAuthenticateAPIToken:
115 def test_valid_token(self, api_token, fossil_repo_obj):
116 token, raw = api_token
117
118 class FakeRequest:
119 META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
120
121 result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
122 assert result is not None
123 assert result.pk == token.pk
124
125 def test_invalid_token(self, fossil_repo_obj):
126 class FakeRequest:
127 META = {"HTTP_AUTHORIZATION": "Bearer invalid_token_xyz"}
128
129 result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
130 assert result is None
131
132 def test_no_auth_header(self, fossil_repo_obj):
133 class FakeRequest:
134 META = {}
135
136 result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
137 assert result is None
138
139 def test_non_bearer_auth(self, fossil_repo_obj):
140 class FakeRequest:
141 META = {"HTTP_AUTHORIZATION": "Basic dXNlcjpwYXNz"}
142
143 result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
144 assert result is None
145
146 def test_expired_token(self, fossil_repo_obj, admin_user):
147 from datetime import timedelta
148
149 from django.utils import timezone
150
151 raw, token_hash, prefix = APIToken.generate()
152 APIToken.objects.create(
153 repository=fossil_repo_obj,
154 name="Expired",
155 token_hash=token_hash,
156 token_prefix=prefix,
157 expires_at=timezone.now() - timedelta(days=1),
158 created_by=admin_user,
159 )
160
161 class FakeRequest:
162 META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
163
164 result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
165 assert result is None
166
167 def test_updates_last_used_at(self, api_token, fossil_repo_obj):
168 token, raw = api_token
169 assert token.last_used_at is None
170
171 class FakeRequest:
172 META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
173
174 authenticate_api_token(FakeRequest(), fossil_repo_obj)
175 token.refresh_from_db()
176 assert token.last_used_at is not None
177
178 def test_deleted_token_not_found(self, api_token, fossil_repo_obj, admin_user):
179 token, raw = api_token
180 token.soft_delete(user=admin_user)
181
182 class FakeRequest:
183 META = {"HTTP_AUTHORIZATION": f"Bearer {raw}"}
184
185 result = authenticate_api_token(FakeRequest(), fossil_repo_obj)
186 assert result is None
187
188
189 # --- API Token List View Tests ---
190
191
192 @pytest.mark.django_db
193 class TestAPITokenListView:
194 def test_list_tokens(self, admin_client, sample_project, api_token):
195 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
196 assert response.status_code == 200
197 content = response.content.decode()
198 assert "Test Token" in content
199 assert "status:write" in content
200
201 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
202 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
203 assert response.status_code == 200
204 assert "No API tokens generated yet" in response.content.decode()
205
206 def test_list_denied_for_writer(self, writer_client, sample_project, api_token):
207 """Token management requires admin, not just write."""
208 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
209 assert response.status_code == 403
210
211 def test_list_denied_for_no_perm(self, no_perm_client, sample_project):
212 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
213 assert response.status_code == 403
214
215 def test_list_denied_for_anon(self, client, sample_project):
216 response = client.get(f"/projects/{sample_project.slug}/fossil/tokens/")
217 assert response.status_code == 302 # redirect to login
218
219
220 # --- API Token Create View Tests ---
221
222
223 @pytest.mark.django_db
224 class TestAPITokenCreateView:
225 def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
226 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/create/")
227 assert response.status_code == 200
228 assert "Generate API Token" in response.content.decode()
229
230 def test_create_token(self, admin_client, sample_project, fossil_repo_obj):
231 response = admin_client.post(
232 f"/projects/{sample_project.slug}/fossil/tokens/create/",
233 {"name": "New CI Token", "permissions": "status:write"},
234 )
235 assert response.status_code == 200 # Shows the token on the same page
236 content = response.content.decode()
237 assert "frp_" in content # Raw token is displayed
238 assert "Token Generated" in content
239
240 # Verify token was created in DB
241 token = APIToken.objects.get(name="New CI Token")
242 assert token.permissions == "status:write"
243
244 def test_create_token_without_name_fails(self, admin_client, sample_project, fossil_repo_obj):
245 response = admin_client.post(
246 f"/projects/{sample_project.slug}/fossil/tokens/create/",
247 {"name": "", "permissions": "status:write"},
248 )
249 assert response.status_code == 200
250 assert "Token name is required" in response.content.decode()
251 assert APIToken.objects.filter(repository__project=sample_project).count() == 0
252
253 def test_create_denied_for_writer(self, writer_client, sample_project):
254 response = writer_client.post(
255 f"/projects/{sample_project.slug}/fossil/tokens/create/",
256 {"name": "Evil Token"},
257 )
258 assert response.status_code == 403
259
260
261 # --- API Token Delete View Tests ---
262
263
264 @pytest.mark.django_db
265 class TestAPITokenDeleteView:
266 def test_delete_token(self, admin_client, sample_project, api_token):
267 token, _ = api_token
268 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
269 assert response.status_code == 302
270 token.refresh_from_db()
271 assert token.is_deleted
272
273 def test_delete_get_redirects(self, admin_client, sample_project, api_token):
274 token, _ = api_token
275 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
276 assert response.status_code == 302 # GET redirects to list
277
278 def test_delete_denied_for_writer(self, writer_client, sample_project, api_token):
279 token, _ = api_token
280 response = writer_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
281 assert response.status_code == 403
282
283 def test_delete_nonexistent_token(self, admin_client, sample_project, fossil_repo_obj):
284 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/99999/delete/")
285 assert response.status_code == 404
286
287 def test_deleted_token_cannot_be_deleted_again(self, admin_client, sample_project, api_token, admin_user):
288 token, _ = api_token
289 token.soft_delete(user=admin_user)
290 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/tokens/{token.pk}/delete/")
291 assert response.status_code == 404
--- a/tests/test_audit_log.py
+++ b/tests/test_audit_log.py
@@ -0,0 +1,121 @@
1
+"""Tests for Unified Audit Log: view, permissions, filtering."""
2
+
3
+import pytest
4
+from django.contrib.auth.models import Group, Permission
5
+
6
+from organization.models import Team
7
+from projects.models import Project
8
+
9
+
10
+@pytest.fixture
11
+def org_admin_user(db):
12
+ """User with ORGANIZATION_CHANGE permission but not superuser."""
13
+ user = __import__("django.contrib.auth.models", fromlist=["User"]).User.objects.create_user(
14
+ username="orgadmin", email="[email protected]", password="testpass123"
15
+ )
16
+ group, _ = Group.objects.get_or_create(name="OrgAdmins")
17
+ perms = Permission.objects.filter(
18
+ content_type__app_label="organization",
19
+ )
20
+ group.permissions.set(perms)
21
+ user.groups.add(group)
22
+ return user
23
+
24
+
25
+@pytest.fixture
26
+def org_admin_client(client, org_admin_user):
27
+ client.login(username="orgadmin", password="testpass123")
28
+ return client
29
+
30
+
31
+# --- Access Control ---
32
+
33
+
34
+@pytest.mark.django_db
35
+class TestAuditLogAccess:
36
+ def test_audit_log_accessible_to_superuser(self, admin_client):
37
+ response = admin_client.get("/settings/audit/")
38
+ assert response.status_code == 200
39
+ assert "Audit Log" in response.content.decode()
40
+
41
+ def test_audit_log_accessible_to_org_admin(self, org_admin_client):
42
+ response = org_admin_client.get("/settings/audit/")
43
+ assert response.status_code == 200
44
+
45
+ def test_audit_log_denied_for_viewer(self, viewer_client):
46
+ response = viewer_client.get("/settings/audit/")
47
+ assert response.status_code == 403
48
+
49
+ def test_audit_log_denied_for_no_perm(self, no_perm_client):
50
+ response = no_perm_client.get("/settings/audit/")
51
+ assert response.status_code == 403
52
+
53
+ def test_audit_log_denied_for_anon(self, client):
54
+ response = client.get("/settings/audit/")
55
+ assert response.status_code == 302 # Redirect to login
56
+
57
+
58
+# --- Content ---
59
+
60
+
61
+@pytest.mark.django_db
62
+class TestAuditLogContent:
63
+ def test_shows_project_history(self, admin_client, admin_user, org):
64
+ Project.objects.create(name="Audit Test Project", organization=org, created_by=admin_user)
65
+ response = admin_client.get("/settings/audit/")
66
+ content = response.content.decode()
67
+ assert "Audit Test Project" in content
68
+ assert "Created" in content
69
+
70
+ def test_shows_organization_history(self, admin_client, org):
71
+ response = admin_client.get("/settings/audit/")
72
+ content = response.content.decode()
73
+ assert "Organization" in content
74
+
75
+ def test_shows_team_history(self, admin_client, admin_user, org):
76
+ Team.objects.create(name="Audit Test Team", organization=org, created_by=admin_user)
77
+ response = admin_client.get("/settings/audit/")
78
+ content = response.content.decode()
79
+ assert "Audit Test Team" in content
80
+
81
+ def test_filter_by_model_type(self, admin_client, admin_user, org):
82
+ Project.objects.create(name="Filter Test", organization=org, created_by=admin_user)
83
+ Team.objects.create(name="Should Not Show", organization=org, created_by=admin_user)
84
+ response = admin_client.get("/settings/audit/?model=Project")
85
+ content = response.content.decode()
86
+ assert "Filter Test" in content
87
+ assert "Should Not Show" not in content
88
+
89
+ def test_filter_shows_all_when_no_filter(self, admin_client, admin_user, org):
90
+ Project.objects.create(name="Project Entry", organization=org, created_by=admin_user)
91
+ Team.objects.create(name="Team Entry", organization=org, created_by=admin_user)
92
+ response = admin_client.get("/settings/audit/")
93
+ content = response.content.decode()
94
+ assert "Project Entry" in content
95
+ assert "Team Entry" in content
96
+
97
+ def test_audit_log_entries_sorted_by_date(self, admin_client, admin_user, org):
98
+ Project.objects.create(name="First Project", organization=org, created_by=admin_user)
99
+ Project.objects.create(name="Second Project", organization=org, created_by=admin_user)
100
+ response = admin_client.get("/settings/audit/?model=Project")
101
+ entries = response.context["entries"]
102
+ # Most recent first
103
+ project_entries = [e for e in entries if e["model"] == "Project"]
104
+ dates = [e["date"] for e in project_entries]
105
+ assert dates == sorted(dates, reverse=True)
106
+
107
+ def test_available_models_in_context(self, admin_client):
108
+ response = admin_client.get("/settings/audit/")
109
+ assert "available_models" in response.context
110
+ assert "Project" in response.context["available_models"]
111
+ assert "Organization" in response.context["available_models"]
112
+ assert "Team" in response.context["available_models"]
113
+ assert "FossilRepository" in response.context["available_models"]
114
+
115
+ def test_audit_log_sidebar_link_for_superuser(self, admin_client):
116
+ response = admin_client.get("/dashboard/")
117
+ assert "/settings/audit/" in response.content.decode()
118
+
119
+ def test_audit_log_sidebar_link_hidden_for_viewer(self, viewer_client):
120
+ response = viewer_client.get("/dashboard/")
121
+ assert "/settings/audit/" not in response.content.decode()
--- a/tests/test_audit_log.py
+++ b/tests/test_audit_log.py
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_audit_log.py
+++ b/tests/test_audit_log.py
@@ -0,0 +1,121 @@
1 """Tests for Unified Audit Log: view, permissions, filtering."""
2
3 import pytest
4 from django.contrib.auth.models import Group, Permission
5
6 from organization.models import Team
7 from projects.models import Project
8
9
10 @pytest.fixture
11 def org_admin_user(db):
12 """User with ORGANIZATION_CHANGE permission but not superuser."""
13 user = __import__("django.contrib.auth.models", fromlist=["User"]).User.objects.create_user(
14 username="orgadmin", email="[email protected]", password="testpass123"
15 )
16 group, _ = Group.objects.get_or_create(name="OrgAdmins")
17 perms = Permission.objects.filter(
18 content_type__app_label="organization",
19 )
20 group.permissions.set(perms)
21 user.groups.add(group)
22 return user
23
24
25 @pytest.fixture
26 def org_admin_client(client, org_admin_user):
27 client.login(username="orgadmin", password="testpass123")
28 return client
29
30
31 # --- Access Control ---
32
33
34 @pytest.mark.django_db
35 class TestAuditLogAccess:
36 def test_audit_log_accessible_to_superuser(self, admin_client):
37 response = admin_client.get("/settings/audit/")
38 assert response.status_code == 200
39 assert "Audit Log" in response.content.decode()
40
41 def test_audit_log_accessible_to_org_admin(self, org_admin_client):
42 response = org_admin_client.get("/settings/audit/")
43 assert response.status_code == 200
44
45 def test_audit_log_denied_for_viewer(self, viewer_client):
46 response = viewer_client.get("/settings/audit/")
47 assert response.status_code == 403
48
49 def test_audit_log_denied_for_no_perm(self, no_perm_client):
50 response = no_perm_client.get("/settings/audit/")
51 assert response.status_code == 403
52
53 def test_audit_log_denied_for_anon(self, client):
54 response = client.get("/settings/audit/")
55 assert response.status_code == 302 # Redirect to login
56
57
58 # --- Content ---
59
60
61 @pytest.mark.django_db
62 class TestAuditLogContent:
63 def test_shows_project_history(self, admin_client, admin_user, org):
64 Project.objects.create(name="Audit Test Project", organization=org, created_by=admin_user)
65 response = admin_client.get("/settings/audit/")
66 content = response.content.decode()
67 assert "Audit Test Project" in content
68 assert "Created" in content
69
70 def test_shows_organization_history(self, admin_client, org):
71 response = admin_client.get("/settings/audit/")
72 content = response.content.decode()
73 assert "Organization" in content
74
75 def test_shows_team_history(self, admin_client, admin_user, org):
76 Team.objects.create(name="Audit Test Team", organization=org, created_by=admin_user)
77 response = admin_client.get("/settings/audit/")
78 content = response.content.decode()
79 assert "Audit Test Team" in content
80
81 def test_filter_by_model_type(self, admin_client, admin_user, org):
82 Project.objects.create(name="Filter Test", organization=org, created_by=admin_user)
83 Team.objects.create(name="Should Not Show", organization=org, created_by=admin_user)
84 response = admin_client.get("/settings/audit/?model=Project")
85 content = response.content.decode()
86 assert "Filter Test" in content
87 assert "Should Not Show" not in content
88
89 def test_filter_shows_all_when_no_filter(self, admin_client, admin_user, org):
90 Project.objects.create(name="Project Entry", organization=org, created_by=admin_user)
91 Team.objects.create(name="Team Entry", organization=org, created_by=admin_user)
92 response = admin_client.get("/settings/audit/")
93 content = response.content.decode()
94 assert "Project Entry" in content
95 assert "Team Entry" in content
96
97 def test_audit_log_entries_sorted_by_date(self, admin_client, admin_user, org):
98 Project.objects.create(name="First Project", organization=org, created_by=admin_user)
99 Project.objects.create(name="Second Project", organization=org, created_by=admin_user)
100 response = admin_client.get("/settings/audit/?model=Project")
101 entries = response.context["entries"]
102 # Most recent first
103 project_entries = [e for e in entries if e["model"] == "Project"]
104 dates = [e["date"] for e in project_entries]
105 assert dates == sorted(dates, reverse=True)
106
107 def test_available_models_in_context(self, admin_client):
108 response = admin_client.get("/settings/audit/")
109 assert "available_models" in response.context
110 assert "Project" in response.context["available_models"]
111 assert "Organization" in response.context["available_models"]
112 assert "Team" in response.context["available_models"]
113 assert "FossilRepository" in response.context["available_models"]
114
115 def test_audit_log_sidebar_link_for_superuser(self, admin_client):
116 response = admin_client.get("/dashboard/")
117 assert "/settings/audit/" in response.content.decode()
118
119 def test_audit_log_sidebar_link_hidden_for_viewer(self, viewer_client):
120 response = viewer_client.get("/dashboard/")
121 assert "/settings/audit/" not in response.content.decode()
--- a/tests/test_branch_protection.py
+++ b/tests/test_branch_protection.py
@@ -0,0 +1,256 @@
1
+import pytest
2
+from django.contrib.auth.models import User
3
+from django.test import Client
4
+
5
+from fossil.branch_protection import BranchProtection
6
+from fossil.models import FossilRepository
7
+from organization.models import Team
8
+from projects.models import ProjectTeam
9
+
10
+
11
+@pytest.fixture
12
+def fossil_repo_obj(sample_project):
13
+ """Return the auto-created FossilRepository for sample_project."""
14
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
+
16
+
17
+@pytest.fixture
18
+def protection_rule(fossil_repo_obj, admin_user):
19
+ return BranchProtection.objects.create(
20
+ repository=fossil_repo_obj,
21
+ branch_pattern="trunk",
22
+ require_status_checks=True,
23
+ required_contexts="ci/tests\nci/lint",
24
+ restrict_push=True,
25
+ created_by=admin_user,
26
+ )
27
+
28
+
29
+@pytest.fixture
30
+def writer_user(db, admin_user, sample_project):
31
+ """User with write access but not admin."""
32
+ writer = User.objects.create_user(username="writer_bp", password="testpass123")
33
+ team = Team.objects.create(name="BP Writers", organization=sample_project.organization, created_by=admin_user)
34
+ team.members.add(writer)
35
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
36
+ return writer
37
+
38
+
39
+@pytest.fixture
40
+def writer_client(writer_user):
41
+ client = Client()
42
+ client.login(username="writer_bp", password="testpass123")
43
+ return client
44
+
45
+
46
+# --- BranchProtection Model Tests ---
47
+
48
+
49
+@pytest.mark.django_db
50
+class TestBranchProtectionModel:
51
+ def test_create_rule(self, protection_rule):
52
+ assert protection_rule.pk is not None
53
+ assert str(protection_rule) == f"trunk ({protection_rule.repository})"
54
+
55
+ def test_soft_delete(self, protection_rule, admin_user):
56
+ protection_rule.soft_delete(user=admin_user)
57
+ assert protection_rule.is_deleted
58
+ assert BranchProtection.objects.filter(pk=protection_rule.pk).count() == 0
59
+ assert BranchProtection.all_objects.filter(pk=protection_rule.pk).count() == 1
60
+
61
+ def test_unique_together(self, fossil_repo_obj, admin_user):
62
+ BranchProtection.objects.create(
63
+ repository=fossil_repo_obj,
64
+ branch_pattern="release-*",
65
+ created_by=admin_user,
66
+ )
67
+ from django.db import IntegrityError
68
+
69
+ with pytest.raises(IntegrityError):
70
+ BranchProtection.objects.create(
71
+ repository=fossil_repo_obj,
72
+ branch_pattern="release-*",
73
+ created_by=admin_user,
74
+ )
75
+
76
+ def test_ordering(self, fossil_repo_obj, admin_user):
77
+ r1 = BranchProtection.objects.create(repository=fossil_repo_obj, branch_pattern="trunk", created_by=admin_user)
78
+ r2 = BranchProtection.objects.create(repository=fossil_repo_obj, branch_pattern="develop", created_by=admin_user)
79
+ rules = list(BranchProtection.objects.filter(repository=fossil_repo_obj))
80
+ # Ordered by branch_pattern alphabetically
81
+ assert rules[0] == r2
82
+ assert rules[1] == r1
83
+
84
+ def test_get_required_contexts_list(self, protection_rule):
85
+ contexts = protection_rule.get_required_contexts_list()
86
+ assert contexts == ["ci/tests", "ci/lint"]
87
+
88
+ def test_get_required_contexts_list_empty(self, fossil_repo_obj, admin_user):
89
+ rule = BranchProtection.objects.create(
90
+ repository=fossil_repo_obj,
91
+ branch_pattern="feature-*",
92
+ required_contexts="",
93
+ created_by=admin_user,
94
+ )
95
+ assert rule.get_required_contexts_list() == []
96
+
97
+ def test_get_required_contexts_list_filters_blanks(self, fossil_repo_obj, admin_user):
98
+ rule = BranchProtection.objects.create(
99
+ repository=fossil_repo_obj,
100
+ branch_pattern="hotfix-*",
101
+ required_contexts="ci/tests\n\n \nci/lint\n",
102
+ created_by=admin_user,
103
+ )
104
+ assert rule.get_required_contexts_list() == ["ci/tests", "ci/lint"]
105
+
106
+
107
+# --- Branch Protection List View Tests ---
108
+
109
+
110
+@pytest.mark.django_db
111
+class TestBranchProtectionListView:
112
+ def test_list_rules(self, admin_client, sample_project, protection_rule):
113
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
114
+ assert response.status_code == 200
115
+ content = response.content.decode()
116
+ assert "trunk" in content
117
+ assert "CI required" in content
118
+ assert "Push restricted" in content
119
+
120
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
121
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
122
+ assert response.status_code == 200
123
+ assert "No branch protection rules configured" in response.content.decode()
124
+
125
+ def test_list_denied_for_writer(self, writer_client, sample_project, protection_rule):
126
+ response = writer_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
127
+ assert response.status_code == 403
128
+
129
+ def test_list_denied_for_no_perm(self, no_perm_client, sample_project):
130
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
131
+ assert response.status_code == 403
132
+
133
+ def test_list_denied_for_anon(self, client, sample_project):
134
+ response = client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
135
+ assert response.status_code == 302 # redirect to login
136
+
137
+
138
+# --- Branch Protection Create View Tests ---
139
+
140
+
141
+@pytest.mark.django_db
142
+class TestBranchProtectionCreateView:
143
+ def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
144
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/create/")
145
+ assert response.status_code == 200
146
+ assert "Create Branch Protection Rule" in response.content.decode()
147
+
148
+ def test_create_rule(self, admin_client, sample_project, fossil_repo_obj):
149
+ response = admin_client.post(
150
+ f"/projects/{sample_project.slug}/fossil/branches/protect/create/",
151
+ {
152
+ "branch_pattern": "develop",
153
+ "require_status_checks": "on",
154
+ "required_contexts": "ci/tests",
155
+ "restrict_push": "on",
156
+ },
157
+ )
158
+ assert response.status_code == 302
159
+ rule = BranchProtection.objects.get(branch_pattern="develop")
160
+ assert rule.require_status_checks is True
161
+ assert rule.required_contexts == "ci/tests"
162
+ assert rule.restrict_push is True
163
+
164
+ def test_create_without_pattern_fails(self, admin_client, sample_project, fossil_repo_obj):
165
+ response = admin_client.post(
166
+ f"/projects/{sample_project.slug}/fossil/branches/protect/create/",
167
+ {"branch_pattern": ""},
168
+ )
169
+ assert response.status_code == 200 # Re-renders form
170
+ assert BranchProtection.objects.count() == 0
171
+
172
+ def test_create_duplicate_pattern_fails(self, admin_client, sample_project, fossil_repo_obj, protection_rule):
173
+ response = admin_client.post(
174
+ f"/projects/{sample_project.slug}/fossil/branches/protect/create/",
175
+ {"branch_pattern": "trunk"},
176
+ )
177
+ assert response.status_code == 200 # Re-renders form with error
178
+ assert "already exists" in response.content.decode()
179
+
180
+ def test_create_denied_for_writer(self, writer_client, sample_project):
181
+ response = writer_client.post(
182
+ f"/projects/{sample_project.slug}/fossil/branches/protect/create/",
183
+ {"branch_pattern": "evil-branch"},
184
+ )
185
+ assert response.status_code == 403
186
+
187
+
188
+# --- Branch Protection Edit View Tests ---
189
+
190
+
191
+@pytest.mark.django_db
192
+class TestBranchProtectionEditView:
193
+ def test_get_edit_form(self, admin_client, sample_project, protection_rule):
194
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/")
195
+ assert response.status_code == 200
196
+ content = response.content.decode()
197
+ assert "trunk" in content
198
+ assert "Update Rule" in content
199
+
200
+ def test_edit_rule(self, admin_client, sample_project, protection_rule):
201
+ response = admin_client.post(
202
+ f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/",
203
+ {
204
+ "branch_pattern": "trunk",
205
+ "require_status_checks": "on",
206
+ "required_contexts": "ci/tests\nci/lint\nci/build",
207
+ "restrict_push": "on",
208
+ },
209
+ )
210
+ assert response.status_code == 302
211
+ protection_rule.refresh_from_db()
212
+ assert "ci/build" in protection_rule.required_contexts
213
+
214
+ def test_edit_change_pattern(self, admin_client, sample_project, protection_rule):
215
+ response = admin_client.post(
216
+ f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/",
217
+ {"branch_pattern": "main", "restrict_push": "on"},
218
+ )
219
+ assert response.status_code == 302
220
+ protection_rule.refresh_from_db()
221
+ assert protection_rule.branch_pattern == "main"
222
+
223
+ def test_edit_denied_for_writer(self, writer_client, sample_project, protection_rule):
224
+ response = writer_client.post(
225
+ f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/",
226
+ {"branch_pattern": "evil-branch"},
227
+ )
228
+ assert response.status_code == 403
229
+
230
+ def test_edit_nonexistent_rule(self, admin_client, sample_project, fossil_repo_obj):
231
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/99999/edit/")
232
+ assert response.status_code == 404
233
+
234
+
235
+# --- Branch Protection Delete View Tests ---
236
+
237
+
238
+@pytest.mark.django_db
239
+class TestBranchProtectionDeleteView:
240
+ def test_delete_rule(self, admin_client, sample_project, protection_rule):
241
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/")
242
+ assert response.status_code == 302
243
+ protection_rule.refresh_from_db()
244
+ assert protection_rule.is_deleted
245
+
246
+ def test_delete_get_redirects(self, admin_client, sample_project, protection_rule):
247
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/")
248
+ assert response.status_code == 302 # GET redirects to list
249
+
250
+ def test_delete_denied_for_writer(self, writer_client, sample_project, protection_rule):
251
+ response = writer_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/")
252
+ assert response.status_code == 403
253
+
254
+ def test_delete_nonexistent_rule(self, admin_client, sample_project, fossil_repo_obj):
255
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/99999/delete/")
256
+ assert response.status_code == 404
--- a/tests/test_branch_protection.py
+++ b/tests/test_branch_protection.py
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_branch_protection.py
+++ b/tests/test_branch_protection.py
@@ -0,0 +1,256 @@
1 import pytest
2 from django.contrib.auth.models import User
3 from django.test import Client
4
5 from fossil.branch_protection import BranchProtection
6 from fossil.models import FossilRepository
7 from organization.models import Team
8 from projects.models import ProjectTeam
9
10
11 @pytest.fixture
12 def fossil_repo_obj(sample_project):
13 """Return the auto-created FossilRepository for sample_project."""
14 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
16
17 @pytest.fixture
18 def protection_rule(fossil_repo_obj, admin_user):
19 return BranchProtection.objects.create(
20 repository=fossil_repo_obj,
21 branch_pattern="trunk",
22 require_status_checks=True,
23 required_contexts="ci/tests\nci/lint",
24 restrict_push=True,
25 created_by=admin_user,
26 )
27
28
29 @pytest.fixture
30 def writer_user(db, admin_user, sample_project):
31 """User with write access but not admin."""
32 writer = User.objects.create_user(username="writer_bp", password="testpass123")
33 team = Team.objects.create(name="BP Writers", organization=sample_project.organization, created_by=admin_user)
34 team.members.add(writer)
35 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
36 return writer
37
38
39 @pytest.fixture
40 def writer_client(writer_user):
41 client = Client()
42 client.login(username="writer_bp", password="testpass123")
43 return client
44
45
46 # --- BranchProtection Model Tests ---
47
48
49 @pytest.mark.django_db
50 class TestBranchProtectionModel:
51 def test_create_rule(self, protection_rule):
52 assert protection_rule.pk is not None
53 assert str(protection_rule) == f"trunk ({protection_rule.repository})"
54
55 def test_soft_delete(self, protection_rule, admin_user):
56 protection_rule.soft_delete(user=admin_user)
57 assert protection_rule.is_deleted
58 assert BranchProtection.objects.filter(pk=protection_rule.pk).count() == 0
59 assert BranchProtection.all_objects.filter(pk=protection_rule.pk).count() == 1
60
61 def test_unique_together(self, fossil_repo_obj, admin_user):
62 BranchProtection.objects.create(
63 repository=fossil_repo_obj,
64 branch_pattern="release-*",
65 created_by=admin_user,
66 )
67 from django.db import IntegrityError
68
69 with pytest.raises(IntegrityError):
70 BranchProtection.objects.create(
71 repository=fossil_repo_obj,
72 branch_pattern="release-*",
73 created_by=admin_user,
74 )
75
76 def test_ordering(self, fossil_repo_obj, admin_user):
77 r1 = BranchProtection.objects.create(repository=fossil_repo_obj, branch_pattern="trunk", created_by=admin_user)
78 r2 = BranchProtection.objects.create(repository=fossil_repo_obj, branch_pattern="develop", created_by=admin_user)
79 rules = list(BranchProtection.objects.filter(repository=fossil_repo_obj))
80 # Ordered by branch_pattern alphabetically
81 assert rules[0] == r2
82 assert rules[1] == r1
83
84 def test_get_required_contexts_list(self, protection_rule):
85 contexts = protection_rule.get_required_contexts_list()
86 assert contexts == ["ci/tests", "ci/lint"]
87
88 def test_get_required_contexts_list_empty(self, fossil_repo_obj, admin_user):
89 rule = BranchProtection.objects.create(
90 repository=fossil_repo_obj,
91 branch_pattern="feature-*",
92 required_contexts="",
93 created_by=admin_user,
94 )
95 assert rule.get_required_contexts_list() == []
96
97 def test_get_required_contexts_list_filters_blanks(self, fossil_repo_obj, admin_user):
98 rule = BranchProtection.objects.create(
99 repository=fossil_repo_obj,
100 branch_pattern="hotfix-*",
101 required_contexts="ci/tests\n\n \nci/lint\n",
102 created_by=admin_user,
103 )
104 assert rule.get_required_contexts_list() == ["ci/tests", "ci/lint"]
105
106
107 # --- Branch Protection List View Tests ---
108
109
110 @pytest.mark.django_db
111 class TestBranchProtectionListView:
112 def test_list_rules(self, admin_client, sample_project, protection_rule):
113 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
114 assert response.status_code == 200
115 content = response.content.decode()
116 assert "trunk" in content
117 assert "CI required" in content
118 assert "Push restricted" in content
119
120 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
121 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
122 assert response.status_code == 200
123 assert "No branch protection rules configured" in response.content.decode()
124
125 def test_list_denied_for_writer(self, writer_client, sample_project, protection_rule):
126 response = writer_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
127 assert response.status_code == 403
128
129 def test_list_denied_for_no_perm(self, no_perm_client, sample_project):
130 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
131 assert response.status_code == 403
132
133 def test_list_denied_for_anon(self, client, sample_project):
134 response = client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/")
135 assert response.status_code == 302 # redirect to login
136
137
138 # --- Branch Protection Create View Tests ---
139
140
141 @pytest.mark.django_db
142 class TestBranchProtectionCreateView:
143 def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
144 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/create/")
145 assert response.status_code == 200
146 assert "Create Branch Protection Rule" in response.content.decode()
147
148 def test_create_rule(self, admin_client, sample_project, fossil_repo_obj):
149 response = admin_client.post(
150 f"/projects/{sample_project.slug}/fossil/branches/protect/create/",
151 {
152 "branch_pattern": "develop",
153 "require_status_checks": "on",
154 "required_contexts": "ci/tests",
155 "restrict_push": "on",
156 },
157 )
158 assert response.status_code == 302
159 rule = BranchProtection.objects.get(branch_pattern="develop")
160 assert rule.require_status_checks is True
161 assert rule.required_contexts == "ci/tests"
162 assert rule.restrict_push is True
163
164 def test_create_without_pattern_fails(self, admin_client, sample_project, fossil_repo_obj):
165 response = admin_client.post(
166 f"/projects/{sample_project.slug}/fossil/branches/protect/create/",
167 {"branch_pattern": ""},
168 )
169 assert response.status_code == 200 # Re-renders form
170 assert BranchProtection.objects.count() == 0
171
172 def test_create_duplicate_pattern_fails(self, admin_client, sample_project, fossil_repo_obj, protection_rule):
173 response = admin_client.post(
174 f"/projects/{sample_project.slug}/fossil/branches/protect/create/",
175 {"branch_pattern": "trunk"},
176 )
177 assert response.status_code == 200 # Re-renders form with error
178 assert "already exists" in response.content.decode()
179
180 def test_create_denied_for_writer(self, writer_client, sample_project):
181 response = writer_client.post(
182 f"/projects/{sample_project.slug}/fossil/branches/protect/create/",
183 {"branch_pattern": "evil-branch"},
184 )
185 assert response.status_code == 403
186
187
188 # --- Branch Protection Edit View Tests ---
189
190
191 @pytest.mark.django_db
192 class TestBranchProtectionEditView:
193 def test_get_edit_form(self, admin_client, sample_project, protection_rule):
194 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/")
195 assert response.status_code == 200
196 content = response.content.decode()
197 assert "trunk" in content
198 assert "Update Rule" in content
199
200 def test_edit_rule(self, admin_client, sample_project, protection_rule):
201 response = admin_client.post(
202 f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/",
203 {
204 "branch_pattern": "trunk",
205 "require_status_checks": "on",
206 "required_contexts": "ci/tests\nci/lint\nci/build",
207 "restrict_push": "on",
208 },
209 )
210 assert response.status_code == 302
211 protection_rule.refresh_from_db()
212 assert "ci/build" in protection_rule.required_contexts
213
214 def test_edit_change_pattern(self, admin_client, sample_project, protection_rule):
215 response = admin_client.post(
216 f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/",
217 {"branch_pattern": "main", "restrict_push": "on"},
218 )
219 assert response.status_code == 302
220 protection_rule.refresh_from_db()
221 assert protection_rule.branch_pattern == "main"
222
223 def test_edit_denied_for_writer(self, writer_client, sample_project, protection_rule):
224 response = writer_client.post(
225 f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/edit/",
226 {"branch_pattern": "evil-branch"},
227 )
228 assert response.status_code == 403
229
230 def test_edit_nonexistent_rule(self, admin_client, sample_project, fossil_repo_obj):
231 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/99999/edit/")
232 assert response.status_code == 404
233
234
235 # --- Branch Protection Delete View Tests ---
236
237
238 @pytest.mark.django_db
239 class TestBranchProtectionDeleteView:
240 def test_delete_rule(self, admin_client, sample_project, protection_rule):
241 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/")
242 assert response.status_code == 302
243 protection_rule.refresh_from_db()
244 assert protection_rule.is_deleted
245
246 def test_delete_get_redirects(self, admin_client, sample_project, protection_rule):
247 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/")
248 assert response.status_code == 302 # GET redirects to list
249
250 def test_delete_denied_for_writer(self, writer_client, sample_project, protection_rule):
251 response = writer_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/{protection_rule.pk}/delete/")
252 assert response.status_code == 403
253
254 def test_delete_nonexistent_rule(self, admin_client, sample_project, fossil_repo_obj):
255 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/branches/protect/99999/delete/")
256 assert response.status_code == 404
--- a/tests/test_ci_status.py
+++ b/tests/test_ci_status.py
@@ -0,0 +1,349 @@
1
+import json
2
+
3
+import pytest
4
+from django.contrib.auth.models import User
5
+from django.test import Client
6
+
7
+from fossil.api_tokens import APIToken
8
+from fossil.ci import StatusCheck
9
+from fossil.models import FossilRepository
10
+from organization.models import Team
11
+from projects.models import ProjectTeam
12
+
13
+
14
+@pytest.fixture
15
+def fossil_repo_obj(sample_project):
16
+ """Return the auto-created FossilRepository for sample_project."""
17
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
18
+
19
+
20
+@pytest.fixture
21
+def api_token(fossil_repo_obj, admin_user):
22
+ """Create an API token and return (APIToken instance, raw_token)."""
23
+ raw, token_hash, prefix = APIToken.generate()
24
+ token = APIToken.objects.create(
25
+ repository=fossil_repo_obj,
26
+ name="CI Token",
27
+ token_hash=token_hash,
28
+ token_prefix=prefix,
29
+ permissions="status:write",
30
+ created_by=admin_user,
31
+ )
32
+ return token, raw
33
+
34
+
35
+@pytest.fixture
36
+def status_check(fossil_repo_obj):
37
+ return StatusCheck.objects.create(
38
+ repository=fossil_repo_obj,
39
+ checkin_uuid="abc123def456",
40
+ context="ci/tests",
41
+ state="success",
42
+ description="All 42 tests passed",
43
+ target_url="https://ci.example.com/build/1",
44
+ )
45
+
46
+
47
+@pytest.fixture
48
+def writer_user(db, admin_user, sample_project):
49
+ """User with write access but not admin."""
50
+ writer = User.objects.create_user(username="writer_ci", password="testpass123")
51
+ team = Team.objects.create(name="CI Writers", organization=sample_project.organization, created_by=admin_user)
52
+ team.members.add(writer)
53
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
54
+ return writer
55
+
56
+
57
+@pytest.fixture
58
+def writer_client(writer_user):
59
+ client = Client()
60
+ client.login(username="writer_ci", password="testpass123")
61
+ return client
62
+
63
+
64
+# --- StatusCheck Model Tests ---
65
+
66
+
67
+@pytest.mark.django_db
68
+class TestStatusCheckModel:
69
+ def test_create_status_check(self, status_check):
70
+ assert status_check.pk is not None
71
+ assert str(status_check) == "ci/tests: success @ abc123def4"
72
+
73
+ def test_soft_delete(self, status_check, admin_user):
74
+ status_check.soft_delete(user=admin_user)
75
+ assert status_check.is_deleted
76
+ assert StatusCheck.objects.filter(pk=status_check.pk).count() == 0
77
+ assert StatusCheck.all_objects.filter(pk=status_check.pk).count() == 1
78
+
79
+ def test_unique_together(self, fossil_repo_obj):
80
+ StatusCheck.objects.create(
81
+ repository=fossil_repo_obj,
82
+ checkin_uuid="unique123",
83
+ context="ci/lint",
84
+ state="pending",
85
+ )
86
+ from django.db import IntegrityError
87
+
88
+ with pytest.raises(IntegrityError):
89
+ StatusCheck.objects.create(
90
+ repository=fossil_repo_obj,
91
+ checkin_uuid="unique123",
92
+ context="ci/lint",
93
+ state="success",
94
+ )
95
+
96
+ def test_ordering(self, fossil_repo_obj):
97
+ c1 = StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="ord1", context="ci/first", state="pending")
98
+ c2 = StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="ord2", context="ci/second", state="success")
99
+ checks = list(StatusCheck.objects.filter(repository=fossil_repo_obj))
100
+ assert checks[0] == c2 # newest first
101
+ assert checks[1] == c1
102
+
103
+ def test_state_choices(self):
104
+ assert "pending" in StatusCheck.State.values
105
+ assert "success" in StatusCheck.State.values
106
+ assert "failure" in StatusCheck.State.values
107
+ assert "error" in StatusCheck.State.values
108
+
109
+
110
+# --- Status Check API POST Tests ---
111
+
112
+
113
+@pytest.mark.django_db
114
+class TestStatusCheckAPIPost:
115
+ def test_post_creates_status_check(self, client, sample_project, fossil_repo_obj, api_token):
116
+ token_obj, raw_token = api_token
117
+ response = client.post(
118
+ f"/projects/{sample_project.slug}/fossil/api/status",
119
+ data=json.dumps(
120
+ {
121
+ "checkin": "deadbeef123",
122
+ "context": "ci/tests",
123
+ "state": "success",
124
+ "description": "All tests passed",
125
+ "target_url": "https://ci.example.com/build/42",
126
+ }
127
+ ),
128
+ content_type="application/json",
129
+ HTTP_AUTHORIZATION=f"Bearer {raw_token}",
130
+ )
131
+ assert response.status_code == 201
132
+ data = response.json()
133
+ assert data["context"] == "ci/tests"
134
+ assert data["state"] == "success"
135
+ assert data["created"] is True
136
+
137
+ check = StatusCheck.objects.get(repository=fossil_repo_obj, checkin_uuid="deadbeef123", context="ci/tests")
138
+ assert check.state == "success"
139
+ assert check.description == "All tests passed"
140
+
141
+ def test_post_updates_existing_check(self, client, sample_project, fossil_repo_obj, api_token):
142
+ token_obj, raw_token = api_token
143
+ # Create initial check
144
+ StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="update123", context="ci/tests", state="pending")
145
+ # Update it
146
+ response = client.post(
147
+ f"/projects/{sample_project.slug}/fossil/api/status",
148
+ data=json.dumps(
149
+ {
150
+ "checkin": "update123",
151
+ "context": "ci/tests",
152
+ "state": "success",
153
+ "description": "Now passing",
154
+ }
155
+ ),
156
+ content_type="application/json",
157
+ HTTP_AUTHORIZATION=f"Bearer {raw_token}",
158
+ )
159
+ assert response.status_code == 200
160
+ data = response.json()
161
+ assert data["created"] is False
162
+ assert data["state"] == "success"
163
+
164
+ def test_post_without_token_returns_401(self, client, sample_project, fossil_repo_obj):
165
+ response = client.post(
166
+ f"/projects/{sample_project.slug}/fossil/api/status",
167
+ data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
168
+ content_type="application/json",
169
+ )
170
+ assert response.status_code == 401
171
+
172
+ def test_post_with_invalid_token_returns_401(self, client, sample_project, fossil_repo_obj):
173
+ response = client.post(
174
+ f"/projects/{sample_project.slug}/fossil/api/status",
175
+ data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
176
+ content_type="application/json",
177
+ HTTP_AUTHORIZATION="Bearer invalid_token_xyz",
178
+ )
179
+ assert response.status_code == 401
180
+
181
+ def test_post_with_wrong_repo_token_returns_401(self, client, sample_project, fossil_repo_obj, admin_user, org):
182
+ """Token scoped to a different repo should fail."""
183
+ from projects.models import Project
184
+
185
+ other_project = Project.objects.create(name="Other Project", organization=org, visibility="private", created_by=admin_user)
186
+ other_repo = FossilRepository.objects.get(project=other_project, deleted_at__isnull=True)
187
+ raw, token_hash, prefix = APIToken.generate()
188
+ APIToken.objects.create(
189
+ repository=other_repo, name="Other Token", token_hash=token_hash, token_prefix=prefix, created_by=admin_user
190
+ )
191
+
192
+ response = client.post(
193
+ f"/projects/{sample_project.slug}/fossil/api/status",
194
+ data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
195
+ content_type="application/json",
196
+ HTTP_AUTHORIZATION=f"Bearer {raw}",
197
+ )
198
+ assert response.status_code == 401
199
+
200
+ def test_post_missing_checkin_returns_400(self, client, sample_project, fossil_repo_obj, api_token):
201
+ _, raw_token = api_token
202
+ response = client.post(
203
+ f"/projects/{sample_project.slug}/fossil/api/status",
204
+ data=json.dumps({"context": "ci/tests", "state": "success"}),
205
+ content_type="application/json",
206
+ HTTP_AUTHORIZATION=f"Bearer {raw_token}",
207
+ )
208
+ assert response.status_code == 400
209
+ assert "checkin" in response.json()["error"]
210
+
211
+ def test_post_missing_context_returns_400(self, client, sample_project, fossil_repo_obj, api_token):
212
+ _, raw_token = api_token
213
+ response = client.post(
214
+ f"/projects/{sample_project.slug}/fossil/api/status",
215
+ data=json.dumps({"checkin": "abc123", "state": "success"}),
216
+ content_type="application/json",
217
+ HTTP_AUTHORIZATION=f"Bearer {raw_token}",
218
+ )
219
+ assert response.status_code == 400
220
+ assert "context" in response.json()["error"]
221
+
222
+ def test_post_invalid_state_returns_400(self, client, sample_project, fossil_repo_obj, api_token):
223
+ _, raw_token = api_token
224
+ response = client.post(
225
+ f"/projects/{sample_project.slug}/fossil/api/status",
226
+ data=json.dumps({"checkin": "abc123", "context": "ci/tests", "state": "bogus"}),
227
+ content_type="application/json",
228
+ HTTP_AUTHORIZATION=f"Bearer {raw_token}",
229
+ )
230
+ assert response.status_code == 400
231
+ assert "state" in response.json()["error"]
232
+
233
+ def test_post_invalid_json_returns_400(self, client, sample_project, fossil_repo_obj, api_token):
234
+ _, raw_token = api_token
235
+ response = client.post(
236
+ f"/projects/{sample_project.slug}/fossil/api/status",
237
+ data="not json",
238
+ content_type="application/json",
239
+ HTTP_AUTHORIZATION=f"Bearer {raw_token}",
240
+ )
241
+ assert response.status_code == 400
242
+
243
+ def test_post_expired_token_returns_401(self, client, sample_project, fossil_repo_obj, admin_user):
244
+ from datetime import timedelta
245
+
246
+ from django.utils import timezone
247
+
248
+ raw, token_hash, prefix = APIToken.generate()
249
+ APIToken.objects.create(
250
+ repository=fossil_repo_obj,
251
+ name="Expired Token",
252
+ token_hash=token_hash,
253
+ token_prefix=prefix,
254
+ expires_at=timezone.now() - timedelta(days=1),
255
+ created_by=admin_user,
256
+ )
257
+ response = client.post(
258
+ f"/projects/{sample_project.slug}/fossil/api/status",
259
+ data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
260
+ content_type="application/json",
261
+ HTTP_AUTHORIZATION=f"Bearer {raw}",
262
+ )
263
+ assert response.status_code == 401
264
+
265
+ def test_post_token_without_status_write_returns_403(self, client, sample_project, fossil_repo_obj, admin_user):
266
+ raw, token_hash, prefix = APIToken.generate()
267
+ APIToken.objects.create(
268
+ repository=fossil_repo_obj,
269
+ name="Read Only Token",
270
+ token_hash=token_hash,
271
+ token_prefix=prefix,
272
+ permissions="status:read",
273
+ created_by=admin_user,
274
+ )
275
+ response = client.post(
276
+ f"/projects/{sample_project.slug}/fossil/api/status",
277
+ data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
278
+ content_type="application/json",
279
+ HTTP_AUTHORIZATION=f"Bearer {raw}",
280
+ )
281
+ assert response.status_code == 403
282
+
283
+ def test_method_not_allowed(self, client, sample_project, fossil_repo_obj):
284
+ response = client.delete(f"/projects/{sample_project.slug}/fossil/api/status")
285
+ assert response.status_code == 405
286
+
287
+
288
+# --- Status Check API GET Tests ---
289
+
290
+
291
+@pytest.mark.django_db
292
+class TestStatusCheckAPIGet:
293
+ def test_get_checks_for_checkin(self, admin_client, sample_project, fossil_repo_obj, status_check):
294
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status?checkin={status_check.checkin_uuid}")
295
+ assert response.status_code == 200
296
+ data = response.json()
297
+ assert len(data["checks"]) == 1
298
+ assert data["checks"][0]["context"] == "ci/tests"
299
+ assert data["checks"][0]["state"] == "success"
300
+
301
+ def test_get_without_checkin_param_returns_400(self, admin_client, sample_project, fossil_repo_obj):
302
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status")
303
+ assert response.status_code == 400
304
+
305
+ def test_get_empty_results(self, admin_client, sample_project, fossil_repo_obj):
306
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status?checkin=nonexistent")
307
+ assert response.status_code == 200
308
+ assert len(response.json()["checks"]) == 0
309
+
310
+
311
+# --- Status Badge Tests ---
312
+
313
+
314
+@pytest.mark.django_db
315
+class TestStatusBadge:
316
+ def test_badge_unknown_no_checks(self, client, sample_project, fossil_repo_obj):
317
+ response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/abc123/badge.svg")
318
+ assert response.status_code == 200
319
+ assert response["Content-Type"] == "image/svg+xml"
320
+ assert "unknown" in response.content.decode()
321
+
322
+ def test_badge_passing(self, client, sample_project, fossil_repo_obj):
323
+ StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="pass123", context="ci/tests", state="success")
324
+ response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/pass123/badge.svg")
325
+ assert response.status_code == 200
326
+ assert "passing" in response.content.decode()
327
+
328
+ def test_badge_failing(self, client, sample_project, fossil_repo_obj):
329
+ StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="fail123", context="ci/tests", state="failure")
330
+ response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/fail123/badge.svg")
331
+ assert response.status_code == 200
332
+ assert "failing" in response.content.decode()
333
+
334
+ def test_badge_pending(self, client, sample_project, fossil_repo_obj):
335
+ StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="pend123", context="ci/tests", state="pending")
336
+ response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/pend123/badge.svg")
337
+ assert response.status_code == 200
338
+ assert "pending" in response.content.decode()
339
+
340
+ def test_badge_mixed_failing_wins(self, client, sample_project, fossil_repo_obj):
341
+ """If any check is failing, the aggregate badge should say 'failing'."""
342
+ StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="mixed123", context="ci/tests", state="success")
343
+ StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="mixed123", context="ci/lint", state="failure")
344
+ response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/mixed123/badge.svg")
345
+ assert "failing" in response.content.decode()
346
+
347
+ def test_badge_nonexistent_project_returns_404(self, client):
348
+ response = client.get("/projects/nonexistent-project/fossil/api/status/abc/badge.svg")
349
+ assert response.status_code == 404
--- a/tests/test_ci_status.py
+++ b/tests/test_ci_status.py
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_ci_status.py
+++ b/tests/test_ci_status.py
@@ -0,0 +1,349 @@
1 import json
2
3 import pytest
4 from django.contrib.auth.models import User
5 from django.test import Client
6
7 from fossil.api_tokens import APIToken
8 from fossil.ci import StatusCheck
9 from fossil.models import FossilRepository
10 from organization.models import Team
11 from projects.models import ProjectTeam
12
13
14 @pytest.fixture
15 def fossil_repo_obj(sample_project):
16 """Return the auto-created FossilRepository for sample_project."""
17 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
18
19
20 @pytest.fixture
21 def api_token(fossil_repo_obj, admin_user):
22 """Create an API token and return (APIToken instance, raw_token)."""
23 raw, token_hash, prefix = APIToken.generate()
24 token = APIToken.objects.create(
25 repository=fossil_repo_obj,
26 name="CI Token",
27 token_hash=token_hash,
28 token_prefix=prefix,
29 permissions="status:write",
30 created_by=admin_user,
31 )
32 return token, raw
33
34
35 @pytest.fixture
36 def status_check(fossil_repo_obj):
37 return StatusCheck.objects.create(
38 repository=fossil_repo_obj,
39 checkin_uuid="abc123def456",
40 context="ci/tests",
41 state="success",
42 description="All 42 tests passed",
43 target_url="https://ci.example.com/build/1",
44 )
45
46
47 @pytest.fixture
48 def writer_user(db, admin_user, sample_project):
49 """User with write access but not admin."""
50 writer = User.objects.create_user(username="writer_ci", password="testpass123")
51 team = Team.objects.create(name="CI Writers", organization=sample_project.organization, created_by=admin_user)
52 team.members.add(writer)
53 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
54 return writer
55
56
57 @pytest.fixture
58 def writer_client(writer_user):
59 client = Client()
60 client.login(username="writer_ci", password="testpass123")
61 return client
62
63
64 # --- StatusCheck Model Tests ---
65
66
67 @pytest.mark.django_db
68 class TestStatusCheckModel:
69 def test_create_status_check(self, status_check):
70 assert status_check.pk is not None
71 assert str(status_check) == "ci/tests: success @ abc123def4"
72
73 def test_soft_delete(self, status_check, admin_user):
74 status_check.soft_delete(user=admin_user)
75 assert status_check.is_deleted
76 assert StatusCheck.objects.filter(pk=status_check.pk).count() == 0
77 assert StatusCheck.all_objects.filter(pk=status_check.pk).count() == 1
78
79 def test_unique_together(self, fossil_repo_obj):
80 StatusCheck.objects.create(
81 repository=fossil_repo_obj,
82 checkin_uuid="unique123",
83 context="ci/lint",
84 state="pending",
85 )
86 from django.db import IntegrityError
87
88 with pytest.raises(IntegrityError):
89 StatusCheck.objects.create(
90 repository=fossil_repo_obj,
91 checkin_uuid="unique123",
92 context="ci/lint",
93 state="success",
94 )
95
96 def test_ordering(self, fossil_repo_obj):
97 c1 = StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="ord1", context="ci/first", state="pending")
98 c2 = StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="ord2", context="ci/second", state="success")
99 checks = list(StatusCheck.objects.filter(repository=fossil_repo_obj))
100 assert checks[0] == c2 # newest first
101 assert checks[1] == c1
102
103 def test_state_choices(self):
104 assert "pending" in StatusCheck.State.values
105 assert "success" in StatusCheck.State.values
106 assert "failure" in StatusCheck.State.values
107 assert "error" in StatusCheck.State.values
108
109
110 # --- Status Check API POST Tests ---
111
112
113 @pytest.mark.django_db
114 class TestStatusCheckAPIPost:
115 def test_post_creates_status_check(self, client, sample_project, fossil_repo_obj, api_token):
116 token_obj, raw_token = api_token
117 response = client.post(
118 f"/projects/{sample_project.slug}/fossil/api/status",
119 data=json.dumps(
120 {
121 "checkin": "deadbeef123",
122 "context": "ci/tests",
123 "state": "success",
124 "description": "All tests passed",
125 "target_url": "https://ci.example.com/build/42",
126 }
127 ),
128 content_type="application/json",
129 HTTP_AUTHORIZATION=f"Bearer {raw_token}",
130 )
131 assert response.status_code == 201
132 data = response.json()
133 assert data["context"] == "ci/tests"
134 assert data["state"] == "success"
135 assert data["created"] is True
136
137 check = StatusCheck.objects.get(repository=fossil_repo_obj, checkin_uuid="deadbeef123", context="ci/tests")
138 assert check.state == "success"
139 assert check.description == "All tests passed"
140
141 def test_post_updates_existing_check(self, client, sample_project, fossil_repo_obj, api_token):
142 token_obj, raw_token = api_token
143 # Create initial check
144 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="update123", context="ci/tests", state="pending")
145 # Update it
146 response = client.post(
147 f"/projects/{sample_project.slug}/fossil/api/status",
148 data=json.dumps(
149 {
150 "checkin": "update123",
151 "context": "ci/tests",
152 "state": "success",
153 "description": "Now passing",
154 }
155 ),
156 content_type="application/json",
157 HTTP_AUTHORIZATION=f"Bearer {raw_token}",
158 )
159 assert response.status_code == 200
160 data = response.json()
161 assert data["created"] is False
162 assert data["state"] == "success"
163
164 def test_post_without_token_returns_401(self, client, sample_project, fossil_repo_obj):
165 response = client.post(
166 f"/projects/{sample_project.slug}/fossil/api/status",
167 data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
168 content_type="application/json",
169 )
170 assert response.status_code == 401
171
172 def test_post_with_invalid_token_returns_401(self, client, sample_project, fossil_repo_obj):
173 response = client.post(
174 f"/projects/{sample_project.slug}/fossil/api/status",
175 data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
176 content_type="application/json",
177 HTTP_AUTHORIZATION="Bearer invalid_token_xyz",
178 )
179 assert response.status_code == 401
180
181 def test_post_with_wrong_repo_token_returns_401(self, client, sample_project, fossil_repo_obj, admin_user, org):
182 """Token scoped to a different repo should fail."""
183 from projects.models import Project
184
185 other_project = Project.objects.create(name="Other Project", organization=org, visibility="private", created_by=admin_user)
186 other_repo = FossilRepository.objects.get(project=other_project, deleted_at__isnull=True)
187 raw, token_hash, prefix = APIToken.generate()
188 APIToken.objects.create(
189 repository=other_repo, name="Other Token", token_hash=token_hash, token_prefix=prefix, created_by=admin_user
190 )
191
192 response = client.post(
193 f"/projects/{sample_project.slug}/fossil/api/status",
194 data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
195 content_type="application/json",
196 HTTP_AUTHORIZATION=f"Bearer {raw}",
197 )
198 assert response.status_code == 401
199
200 def test_post_missing_checkin_returns_400(self, client, sample_project, fossil_repo_obj, api_token):
201 _, raw_token = api_token
202 response = client.post(
203 f"/projects/{sample_project.slug}/fossil/api/status",
204 data=json.dumps({"context": "ci/tests", "state": "success"}),
205 content_type="application/json",
206 HTTP_AUTHORIZATION=f"Bearer {raw_token}",
207 )
208 assert response.status_code == 400
209 assert "checkin" in response.json()["error"]
210
211 def test_post_missing_context_returns_400(self, client, sample_project, fossil_repo_obj, api_token):
212 _, raw_token = api_token
213 response = client.post(
214 f"/projects/{sample_project.slug}/fossil/api/status",
215 data=json.dumps({"checkin": "abc123", "state": "success"}),
216 content_type="application/json",
217 HTTP_AUTHORIZATION=f"Bearer {raw_token}",
218 )
219 assert response.status_code == 400
220 assert "context" in response.json()["error"]
221
222 def test_post_invalid_state_returns_400(self, client, sample_project, fossil_repo_obj, api_token):
223 _, raw_token = api_token
224 response = client.post(
225 f"/projects/{sample_project.slug}/fossil/api/status",
226 data=json.dumps({"checkin": "abc123", "context": "ci/tests", "state": "bogus"}),
227 content_type="application/json",
228 HTTP_AUTHORIZATION=f"Bearer {raw_token}",
229 )
230 assert response.status_code == 400
231 assert "state" in response.json()["error"]
232
233 def test_post_invalid_json_returns_400(self, client, sample_project, fossil_repo_obj, api_token):
234 _, raw_token = api_token
235 response = client.post(
236 f"/projects/{sample_project.slug}/fossil/api/status",
237 data="not json",
238 content_type="application/json",
239 HTTP_AUTHORIZATION=f"Bearer {raw_token}",
240 )
241 assert response.status_code == 400
242
243 def test_post_expired_token_returns_401(self, client, sample_project, fossil_repo_obj, admin_user):
244 from datetime import timedelta
245
246 from django.utils import timezone
247
248 raw, token_hash, prefix = APIToken.generate()
249 APIToken.objects.create(
250 repository=fossil_repo_obj,
251 name="Expired Token",
252 token_hash=token_hash,
253 token_prefix=prefix,
254 expires_at=timezone.now() - timedelta(days=1),
255 created_by=admin_user,
256 )
257 response = client.post(
258 f"/projects/{sample_project.slug}/fossil/api/status",
259 data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
260 content_type="application/json",
261 HTTP_AUTHORIZATION=f"Bearer {raw}",
262 )
263 assert response.status_code == 401
264
265 def test_post_token_without_status_write_returns_403(self, client, sample_project, fossil_repo_obj, admin_user):
266 raw, token_hash, prefix = APIToken.generate()
267 APIToken.objects.create(
268 repository=fossil_repo_obj,
269 name="Read Only Token",
270 token_hash=token_hash,
271 token_prefix=prefix,
272 permissions="status:read",
273 created_by=admin_user,
274 )
275 response = client.post(
276 f"/projects/{sample_project.slug}/fossil/api/status",
277 data=json.dumps({"checkin": "abc", "context": "ci/tests", "state": "success"}),
278 content_type="application/json",
279 HTTP_AUTHORIZATION=f"Bearer {raw}",
280 )
281 assert response.status_code == 403
282
283 def test_method_not_allowed(self, client, sample_project, fossil_repo_obj):
284 response = client.delete(f"/projects/{sample_project.slug}/fossil/api/status")
285 assert response.status_code == 405
286
287
288 # --- Status Check API GET Tests ---
289
290
291 @pytest.mark.django_db
292 class TestStatusCheckAPIGet:
293 def test_get_checks_for_checkin(self, admin_client, sample_project, fossil_repo_obj, status_check):
294 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status?checkin={status_check.checkin_uuid}")
295 assert response.status_code == 200
296 data = response.json()
297 assert len(data["checks"]) == 1
298 assert data["checks"][0]["context"] == "ci/tests"
299 assert data["checks"][0]["state"] == "success"
300
301 def test_get_without_checkin_param_returns_400(self, admin_client, sample_project, fossil_repo_obj):
302 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status")
303 assert response.status_code == 400
304
305 def test_get_empty_results(self, admin_client, sample_project, fossil_repo_obj):
306 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/api/status?checkin=nonexistent")
307 assert response.status_code == 200
308 assert len(response.json()["checks"]) == 0
309
310
311 # --- Status Badge Tests ---
312
313
314 @pytest.mark.django_db
315 class TestStatusBadge:
316 def test_badge_unknown_no_checks(self, client, sample_project, fossil_repo_obj):
317 response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/abc123/badge.svg")
318 assert response.status_code == 200
319 assert response["Content-Type"] == "image/svg+xml"
320 assert "unknown" in response.content.decode()
321
322 def test_badge_passing(self, client, sample_project, fossil_repo_obj):
323 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="pass123", context="ci/tests", state="success")
324 response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/pass123/badge.svg")
325 assert response.status_code == 200
326 assert "passing" in response.content.decode()
327
328 def test_badge_failing(self, client, sample_project, fossil_repo_obj):
329 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="fail123", context="ci/tests", state="failure")
330 response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/fail123/badge.svg")
331 assert response.status_code == 200
332 assert "failing" in response.content.decode()
333
334 def test_badge_pending(self, client, sample_project, fossil_repo_obj):
335 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="pend123", context="ci/tests", state="pending")
336 response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/pend123/badge.svg")
337 assert response.status_code == 200
338 assert "pending" in response.content.decode()
339
340 def test_badge_mixed_failing_wins(self, client, sample_project, fossil_repo_obj):
341 """If any check is failing, the aggregate badge should say 'failing'."""
342 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="mixed123", context="ci/tests", state="success")
343 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="mixed123", context="ci/lint", state="failure")
344 response = client.get(f"/projects/{sample_project.slug}/fossil/api/status/mixed123/badge.svg")
345 assert "failing" in response.content.decode()
346
347 def test_badge_nonexistent_project_returns_404(self, client):
348 response = client.get("/projects/nonexistent-project/fossil/api/status/abc/badge.svg")
349 assert response.status_code == 404
--- a/tests/test_starring.py
+++ b/tests/test_starring.py
@@ -0,0 +1,181 @@
1
+"""Tests for Project Starring: model, toggle view, explore page, and admin."""
2
+
3
+import pytest
4
+from django.contrib.auth.models import User
5
+from django.db import IntegrityError
6
+
7
+from projects.models import Project, ProjectStar
8
+
9
+# --- Model Tests ---
10
+
11
+
12
+@pytest.mark.django_db
13
+class TestProjectStarModel:
14
+ def test_create_star(self, admin_user, sample_project):
15
+ star = ProjectStar.objects.create(user=admin_user, project=sample_project)
16
+ assert star.pk is not None
17
+ assert star.user == admin_user
18
+ assert star.project == sample_project
19
+ assert str(star) == f"{admin_user} starred {sample_project}"
20
+
21
+ def test_unique_constraint(self, admin_user, sample_project):
22
+ ProjectStar.objects.create(user=admin_user, project=sample_project)
23
+ with pytest.raises(IntegrityError):
24
+ ProjectStar.objects.create(user=admin_user, project=sample_project)
25
+
26
+ def test_star_count_property(self, admin_user, viewer_user, sample_project):
27
+ assert sample_project.star_count == 0
28
+ ProjectStar.objects.create(user=admin_user, project=sample_project)
29
+ assert sample_project.star_count == 1
30
+ ProjectStar.objects.create(user=viewer_user, project=sample_project)
31
+ assert sample_project.star_count == 2
32
+
33
+ def test_star_cascade_on_user_delete(self, org, admin_user):
34
+ """Stars cascade-delete when the user is deleted."""
35
+ temp_user = User.objects.create_user(username="tempuser", password="testpass123")
36
+ project = Project.objects.create(name="Cascade Test", organization=org, created_by=admin_user)
37
+ ProjectStar.objects.create(user=temp_user, project=project)
38
+ temp_user.delete()
39
+ assert ProjectStar.objects.count() == 0
40
+
41
+ def test_multiple_users_can_star_same_project(self, admin_user, viewer_user, sample_project):
42
+ ProjectStar.objects.create(user=admin_user, project=sample_project)
43
+ ProjectStar.objects.create(user=viewer_user, project=sample_project)
44
+ assert ProjectStar.objects.filter(project=sample_project).count() == 2
45
+
46
+
47
+# --- Toggle Star View Tests ---
48
+
49
+
50
+@pytest.mark.django_db
51
+class TestToggleStarView:
52
+ def test_star_project(self, admin_client, admin_user, sample_project):
53
+ response = admin_client.post(f"/projects/{sample_project.slug}/star/")
54
+ assert response.status_code == 302
55
+ assert ProjectStar.objects.filter(user=admin_user, project=sample_project).exists()
56
+
57
+ def test_unstar_project(self, admin_client, admin_user, sample_project):
58
+ ProjectStar.objects.create(user=admin_user, project=sample_project)
59
+ response = admin_client.post(f"/projects/{sample_project.slug}/star/")
60
+ assert response.status_code == 302
61
+ assert not ProjectStar.objects.filter(user=admin_user, project=sample_project).exists()
62
+
63
+ def test_star_htmx_returns_partial(self, admin_client, admin_user, sample_project):
64
+ response = admin_client.post(f"/projects/{sample_project.slug}/star/", HTTP_HX_REQUEST="true")
65
+ assert response.status_code == 200
66
+ content = response.content.decode()
67
+ assert "star-button" in content
68
+ assert "Starred" in content # Just starred it
69
+ assert "<!DOCTYPE html>" not in content
70
+
71
+ def test_unstar_htmx_returns_partial(self, admin_client, admin_user, sample_project):
72
+ ProjectStar.objects.create(user=admin_user, project=sample_project)
73
+ response = admin_client.post(f"/projects/{sample_project.slug}/star/", HTTP_HX_REQUEST="true")
74
+ assert response.status_code == 200
75
+ content = response.content.decode()
76
+ assert "Star" in content
77
+
78
+ def test_star_denied_for_anon(self, client, sample_project):
79
+ response = client.post(f"/projects/{sample_project.slug}/star/")
80
+ assert response.status_code == 302 # Redirect to login
81
+
82
+ def test_star_404_for_deleted_project(self, admin_client, admin_user, sample_project):
83
+ sample_project.soft_delete(user=admin_user)
84
+ response = admin_client.post(f"/projects/{sample_project.slug}/star/")
85
+ assert response.status_code == 404
86
+
87
+ def test_star_shows_on_project_detail(self, admin_client, admin_user, sample_project):
88
+ ProjectStar.objects.create(user=admin_user, project=sample_project)
89
+ response = admin_client.get(f"/projects/{sample_project.slug}/")
90
+ assert response.status_code == 200
91
+ assert response.context["is_starred"] is True
92
+
93
+ def test_unstarred_shows_on_project_detail(self, admin_client, admin_user, sample_project):
94
+ response = admin_client.get(f"/projects/{sample_project.slug}/")
95
+ assert response.status_code == 200
96
+ assert response.context["is_starred"] is False
97
+
98
+
99
+# --- Explore View Tests ---
100
+
101
+
102
+@pytest.mark.django_db
103
+class TestExploreView:
104
+ def test_explore_accessible_to_anon(self, client, org, admin_user):
105
+ Project.objects.create(name="Public Project", organization=org, visibility="public", created_by=admin_user)
106
+ response = client.get("/explore/")
107
+ assert response.status_code == 200
108
+ assert "Public Project" in response.content.decode()
109
+
110
+ def test_explore_anon_only_sees_public(self, client, org, admin_user):
111
+ Project.objects.create(name="Public One", organization=org, visibility="public", created_by=admin_user)
112
+ Project.objects.create(name="Internal One", organization=org, visibility="internal", created_by=admin_user)
113
+ Project.objects.create(name="Private One", organization=org, visibility="private", created_by=admin_user)
114
+ response = client.get("/explore/")
115
+ content = response.content.decode()
116
+ assert "Public One" in content
117
+ assert "Internal One" not in content
118
+ assert "Private One" not in content
119
+
120
+ def test_explore_authenticated_sees_public_and_internal(self, admin_client, org, admin_user):
121
+ Project.objects.create(name="Public Two", organization=org, visibility="public", created_by=admin_user)
122
+ Project.objects.create(name="Internal Two", organization=org, visibility="internal", created_by=admin_user)
123
+ Project.objects.create(name="Private Two", organization=org, visibility="private", created_by=admin_user)
124
+ response = admin_client.get("/explore/")
125
+ # Check the explore queryset in context (not full page content, which includes sidebar)
126
+ explore_project_names = [p.name for p in response.context["projects"]]
127
+ assert "Public Two" in explore_project_names
128
+ assert "Internal Two" in explore_project_names
129
+ assert "Private Two" not in explore_project_names
130
+
131
+ def test_explore_sort_by_name(self, client, org, admin_user):
132
+ Project.objects.create(name="Zebra", organization=org, visibility="public", created_by=admin_user)
133
+ Project.objects.create(name="Alpha", organization=org, visibility="public", created_by=admin_user)
134
+ response = client.get("/explore/?sort=name")
135
+ content = response.content.decode()
136
+ assert content.index("Alpha") < content.index("Zebra")
137
+
138
+ def test_explore_sort_by_stars(self, client, org, admin_user):
139
+ p1 = Project.objects.create(name="Less Stars", organization=org, visibility="public", created_by=admin_user)
140
+ p2 = Project.objects.create(name="More Stars", organization=org, visibility="public", created_by=admin_user)
141
+ user1 = User.objects.create_user(username="u1", password="testpass123")
142
+ user2 = User.objects.create_user(username="u2", password="testpass123")
143
+ ProjectStar.objects.create(user=user1, project=p2)
144
+ ProjectStar.objects.create(user=user2, project=p2)
145
+ ProjectStar.objects.create(user=user1, project=p1)
146
+ response = client.get("/explore/?sort=stars")
147
+ content = response.content.decode()
148
+ assert content.index("More Stars") < content.index("Less Stars")
149
+
150
+ def test_explore_sort_by_recent(self, client, org, admin_user):
151
+ Project.objects.create(name="Old Project", organization=org, visibility="public", created_by=admin_user)
152
+ Project.objects.create(name="New Project", organization=org, visibility="public", created_by=admin_user)
153
+ response = client.get("/explore/?sort=recent")
154
+ content = response.content.decode()
155
+ assert content.index("New Project") < content.index("Old Project")
156
+
157
+ def test_explore_search(self, client, org, admin_user):
158
+ Project.objects.create(name="Fossil SCM", organization=org, visibility="public", created_by=admin_user)
159
+ Project.objects.create(name="Other Project", organization=org, visibility="public", created_by=admin_user)
160
+ response = client.get("/explore/?search=fossil")
161
+ content = response.content.decode()
162
+ assert "Fossil SCM" in content
163
+ assert "Other Project" not in content
164
+
165
+ def test_explore_excludes_deleted_projects(self, client, org, admin_user):
166
+ project = Project.objects.create(name="Deleted Project", organization=org, visibility="public", created_by=admin_user)
167
+ project.soft_delete(user=admin_user)
168
+ response = client.get("/explore/")
169
+ assert "Deleted Project" not in response.content.decode()
170
+
171
+ def test_explore_starred_ids_for_authenticated_user(self, admin_client, admin_user, org):
172
+ p1 = Project.objects.create(name="Starred P", organization=org, visibility="public", created_by=admin_user)
173
+ Project.objects.create(name="Unstarred P", organization=org, visibility="public", created_by=admin_user)
174
+ ProjectStar.objects.create(user=admin_user, project=p1)
175
+ response = admin_client.get("/explore/")
176
+ assert p1.id in response.context["starred_ids"]
177
+
178
+ def test_explore_sidebar_link_exists(self, admin_client):
179
+ response = admin_client.get("/dashboard/")
180
+ assert response.status_code == 200
181
+ assert "/explore/" in response.content.decode()
--- a/tests/test_starring.py
+++ b/tests/test_starring.py
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_starring.py
+++ b/tests/test_starring.py
@@ -0,0 +1,181 @@
1 """Tests for Project Starring: model, toggle view, explore page, and admin."""
2
3 import pytest
4 from django.contrib.auth.models import User
5 from django.db import IntegrityError
6
7 from projects.models import Project, ProjectStar
8
9 # --- Model Tests ---
10
11
12 @pytest.mark.django_db
13 class TestProjectStarModel:
14 def test_create_star(self, admin_user, sample_project):
15 star = ProjectStar.objects.create(user=admin_user, project=sample_project)
16 assert star.pk is not None
17 assert star.user == admin_user
18 assert star.project == sample_project
19 assert str(star) == f"{admin_user} starred {sample_project}"
20
21 def test_unique_constraint(self, admin_user, sample_project):
22 ProjectStar.objects.create(user=admin_user, project=sample_project)
23 with pytest.raises(IntegrityError):
24 ProjectStar.objects.create(user=admin_user, project=sample_project)
25
26 def test_star_count_property(self, admin_user, viewer_user, sample_project):
27 assert sample_project.star_count == 0
28 ProjectStar.objects.create(user=admin_user, project=sample_project)
29 assert sample_project.star_count == 1
30 ProjectStar.objects.create(user=viewer_user, project=sample_project)
31 assert sample_project.star_count == 2
32
33 def test_star_cascade_on_user_delete(self, org, admin_user):
34 """Stars cascade-delete when the user is deleted."""
35 temp_user = User.objects.create_user(username="tempuser", password="testpass123")
36 project = Project.objects.create(name="Cascade Test", organization=org, created_by=admin_user)
37 ProjectStar.objects.create(user=temp_user, project=project)
38 temp_user.delete()
39 assert ProjectStar.objects.count() == 0
40
41 def test_multiple_users_can_star_same_project(self, admin_user, viewer_user, sample_project):
42 ProjectStar.objects.create(user=admin_user, project=sample_project)
43 ProjectStar.objects.create(user=viewer_user, project=sample_project)
44 assert ProjectStar.objects.filter(project=sample_project).count() == 2
45
46
47 # --- Toggle Star View Tests ---
48
49
50 @pytest.mark.django_db
51 class TestToggleStarView:
52 def test_star_project(self, admin_client, admin_user, sample_project):
53 response = admin_client.post(f"/projects/{sample_project.slug}/star/")
54 assert response.status_code == 302
55 assert ProjectStar.objects.filter(user=admin_user, project=sample_project).exists()
56
57 def test_unstar_project(self, admin_client, admin_user, sample_project):
58 ProjectStar.objects.create(user=admin_user, project=sample_project)
59 response = admin_client.post(f"/projects/{sample_project.slug}/star/")
60 assert response.status_code == 302
61 assert not ProjectStar.objects.filter(user=admin_user, project=sample_project).exists()
62
63 def test_star_htmx_returns_partial(self, admin_client, admin_user, sample_project):
64 response = admin_client.post(f"/projects/{sample_project.slug}/star/", HTTP_HX_REQUEST="true")
65 assert response.status_code == 200
66 content = response.content.decode()
67 assert "star-button" in content
68 assert "Starred" in content # Just starred it
69 assert "<!DOCTYPE html>" not in content
70
71 def test_unstar_htmx_returns_partial(self, admin_client, admin_user, sample_project):
72 ProjectStar.objects.create(user=admin_user, project=sample_project)
73 response = admin_client.post(f"/projects/{sample_project.slug}/star/", HTTP_HX_REQUEST="true")
74 assert response.status_code == 200
75 content = response.content.decode()
76 assert "Star" in content
77
78 def test_star_denied_for_anon(self, client, sample_project):
79 response = client.post(f"/projects/{sample_project.slug}/star/")
80 assert response.status_code == 302 # Redirect to login
81
82 def test_star_404_for_deleted_project(self, admin_client, admin_user, sample_project):
83 sample_project.soft_delete(user=admin_user)
84 response = admin_client.post(f"/projects/{sample_project.slug}/star/")
85 assert response.status_code == 404
86
87 def test_star_shows_on_project_detail(self, admin_client, admin_user, sample_project):
88 ProjectStar.objects.create(user=admin_user, project=sample_project)
89 response = admin_client.get(f"/projects/{sample_project.slug}/")
90 assert response.status_code == 200
91 assert response.context["is_starred"] is True
92
93 def test_unstarred_shows_on_project_detail(self, admin_client, admin_user, sample_project):
94 response = admin_client.get(f"/projects/{sample_project.slug}/")
95 assert response.status_code == 200
96 assert response.context["is_starred"] is False
97
98
99 # --- Explore View Tests ---
100
101
102 @pytest.mark.django_db
103 class TestExploreView:
104 def test_explore_accessible_to_anon(self, client, org, admin_user):
105 Project.objects.create(name="Public Project", organization=org, visibility="public", created_by=admin_user)
106 response = client.get("/explore/")
107 assert response.status_code == 200
108 assert "Public Project" in response.content.decode()
109
110 def test_explore_anon_only_sees_public(self, client, org, admin_user):
111 Project.objects.create(name="Public One", organization=org, visibility="public", created_by=admin_user)
112 Project.objects.create(name="Internal One", organization=org, visibility="internal", created_by=admin_user)
113 Project.objects.create(name="Private One", organization=org, visibility="private", created_by=admin_user)
114 response = client.get("/explore/")
115 content = response.content.decode()
116 assert "Public One" in content
117 assert "Internal One" not in content
118 assert "Private One" not in content
119
120 def test_explore_authenticated_sees_public_and_internal(self, admin_client, org, admin_user):
121 Project.objects.create(name="Public Two", organization=org, visibility="public", created_by=admin_user)
122 Project.objects.create(name="Internal Two", organization=org, visibility="internal", created_by=admin_user)
123 Project.objects.create(name="Private Two", organization=org, visibility="private", created_by=admin_user)
124 response = admin_client.get("/explore/")
125 # Check the explore queryset in context (not full page content, which includes sidebar)
126 explore_project_names = [p.name for p in response.context["projects"]]
127 assert "Public Two" in explore_project_names
128 assert "Internal Two" in explore_project_names
129 assert "Private Two" not in explore_project_names
130
131 def test_explore_sort_by_name(self, client, org, admin_user):
132 Project.objects.create(name="Zebra", organization=org, visibility="public", created_by=admin_user)
133 Project.objects.create(name="Alpha", organization=org, visibility="public", created_by=admin_user)
134 response = client.get("/explore/?sort=name")
135 content = response.content.decode()
136 assert content.index("Alpha") < content.index("Zebra")
137
138 def test_explore_sort_by_stars(self, client, org, admin_user):
139 p1 = Project.objects.create(name="Less Stars", organization=org, visibility="public", created_by=admin_user)
140 p2 = Project.objects.create(name="More Stars", organization=org, visibility="public", created_by=admin_user)
141 user1 = User.objects.create_user(username="u1", password="testpass123")
142 user2 = User.objects.create_user(username="u2", password="testpass123")
143 ProjectStar.objects.create(user=user1, project=p2)
144 ProjectStar.objects.create(user=user2, project=p2)
145 ProjectStar.objects.create(user=user1, project=p1)
146 response = client.get("/explore/?sort=stars")
147 content = response.content.decode()
148 assert content.index("More Stars") < content.index("Less Stars")
149
150 def test_explore_sort_by_recent(self, client, org, admin_user):
151 Project.objects.create(name="Old Project", organization=org, visibility="public", created_by=admin_user)
152 Project.objects.create(name="New Project", organization=org, visibility="public", created_by=admin_user)
153 response = client.get("/explore/?sort=recent")
154 content = response.content.decode()
155 assert content.index("New Project") < content.index("Old Project")
156
157 def test_explore_search(self, client, org, admin_user):
158 Project.objects.create(name="Fossil SCM", organization=org, visibility="public", created_by=admin_user)
159 Project.objects.create(name="Other Project", organization=org, visibility="public", created_by=admin_user)
160 response = client.get("/explore/?search=fossil")
161 content = response.content.decode()
162 assert "Fossil SCM" in content
163 assert "Other Project" not in content
164
165 def test_explore_excludes_deleted_projects(self, client, org, admin_user):
166 project = Project.objects.create(name="Deleted Project", organization=org, visibility="public", created_by=admin_user)
167 project.soft_delete(user=admin_user)
168 response = client.get("/explore/")
169 assert "Deleted Project" not in response.content.decode()
170
171 def test_explore_starred_ids_for_authenticated_user(self, admin_client, admin_user, org):
172 p1 = Project.objects.create(name="Starred P", organization=org, visibility="public", created_by=admin_user)
173 Project.objects.create(name="Unstarred P", organization=org, visibility="public", created_by=admin_user)
174 ProjectStar.objects.create(user=admin_user, project=p1)
175 response = admin_client.get("/explore/")
176 assert p1.id in response.context["starred_ids"]
177
178 def test_explore_sidebar_link_exists(self, admin_client):
179 response = admin_client.get("/dashboard/")
180 assert response.status_code == 200
181 assert "/explore/" in response.content.decode()
--- a/tests/test_technotes.py
+++ b/tests/test_technotes.py
@@ -0,0 +1,383 @@
1
+import sqlite3
2
+from pathlib import Path
3
+from unittest.mock import MagicMock, patch
4
+
5
+import pytest
6
+
7
+from fossil.models import FossilRepository
8
+from fossil.reader import FossilReader
9
+
10
+# Reusable patch that makes FossilRepository.exists_on_disk return True
11
+_disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
12
+
13
+
14
+@pytest.fixture
15
+def fossil_repo_obj(sample_project):
16
+ """Return the auto-created FossilRepository for sample_project."""
17
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
18
+
19
+
20
+def _create_test_fossil_db(path: Path):
21
+ """Create a minimal .fossil SQLite database with the tables reader.py needs."""
22
+ conn = sqlite3.connect(str(path))
23
+ conn.execute(
24
+ """
25
+ CREATE TABLE blob (
26
+ rid INTEGER PRIMARY KEY,
27
+ uuid TEXT UNIQUE NOT NULL,
28
+ size INTEGER NOT NULL DEFAULT 0,
29
+ content BLOB
30
+ )
31
+ """
32
+ )
33
+ conn.execute(
34
+ """
35
+ CREATE TABLE event (
36
+ type TEXT,
37
+ mtime REAL,
38
+ objid INTEGER,
39
+ user TEXT,
40
+ comment TEXT
41
+ )
42
+ """
43
+ )
44
+ conn.commit()
45
+ return conn
46
+
47
+
48
+def _insert_technote(conn, rid, uuid, mtime, user, comment, body_content=""):
49
+ """Insert a technote event and blob into the test database.
50
+
51
+ Technotes use event.type = 'e'. The blob contains a Fossil wiki artifact
52
+ format: header cards followed by W <size>\\n<content>\\nZ <hash>.
53
+ """
54
+ import struct
55
+ import zlib
56
+
57
+ # Build a minimal Fossil wiki artifact containing the body
58
+ artifact = f"D 2024-01-01T00:00:00\nU {user}\nW {len(body_content.encode('utf-8'))}\n{body_content}\nZ 0000000000000000"
59
+ raw_bytes = artifact.encode("utf-8")
60
+
61
+ # Fossil stores blobs with a 4-byte big-endian size prefix + zlib compressed content
62
+ compressed = struct.pack(">I", len(raw_bytes)) + zlib.compress(raw_bytes)
63
+
64
+ conn.execute("INSERT INTO blob (rid, uuid, size, content) VALUES (?, ?, ?, ?)", (rid, uuid, len(raw_bytes), compressed))
65
+ conn.execute("INSERT INTO event (type, mtime, objid, user, comment) VALUES ('e', ?, ?, ?, ?)", (mtime, rid, user, comment))
66
+ conn.commit()
67
+
68
+
69
+@pytest.fixture
70
+def fossil_db(tmp_path):
71
+ """Create a temporary .fossil file with technote data for reader tests."""
72
+ db_path = tmp_path / "test.fossil"
73
+ conn = _create_test_fossil_db(db_path)
74
+ _insert_technote(conn, 100, "abc123def456", 2460676.5, "admin", "First technote", "# Hello\n\nThis is the body.")
75
+ _insert_technote(conn, 101, "xyz789ghi012", 2460677.5, "dev", "Second technote", "Another note body.")
76
+ conn.close()
77
+ return db_path
78
+
79
+
80
+def _make_reader_mock(**methods):
81
+ """Create a MagicMock that replaces FossilReader as a class.
82
+
83
+ The returned mock supports:
84
+ reader = FossilReader(path) # returns a mock instance
85
+ with reader: # context manager
86
+ reader.some_method() # returns configured value
87
+ """
88
+ mock_cls = MagicMock()
89
+ # The instance returned by calling the class
90
+ instance = MagicMock()
91
+ mock_cls.return_value = instance
92
+ # Context manager support: __enter__ returns the same instance
93
+ instance.__enter__ = MagicMock(return_value=instance)
94
+ instance.__exit__ = MagicMock(return_value=False)
95
+ for name, val in methods.items():
96
+ getattr(instance, name).return_value = val
97
+ return mock_cls
98
+
99
+
100
+# --- Reader unit tests (no Django DB needed) ---
101
+
102
+
103
+class TestGetTechnotes:
104
+ def test_returns_technotes(self, fossil_db):
105
+ reader = FossilReader(fossil_db)
106
+ with reader:
107
+ notes = reader.get_technotes()
108
+ assert len(notes) == 2
109
+ assert notes[0]["uuid"] == "xyz789ghi012" # Most recent first
110
+ assert notes[1]["uuid"] == "abc123def456"
111
+
112
+ def test_technote_fields(self, fossil_db):
113
+ reader = FossilReader(fossil_db)
114
+ with reader:
115
+ notes = reader.get_technotes()
116
+ note = notes[1] # The first inserted one
117
+ assert note["user"] == "admin"
118
+ assert note["comment"] == "First technote"
119
+ assert note["timestamp"] is not None
120
+
121
+ def test_empty_repo(self, tmp_path):
122
+ db_path = tmp_path / "empty.fossil"
123
+ conn = _create_test_fossil_db(db_path)
124
+ conn.close()
125
+ reader = FossilReader(db_path)
126
+ with reader:
127
+ notes = reader.get_technotes()
128
+ assert notes == []
129
+
130
+
131
+class TestGetTechnoteDetail:
132
+ def test_returns_detail_with_body(self, fossil_db):
133
+ reader = FossilReader(fossil_db)
134
+ with reader:
135
+ note = reader.get_technote_detail("abc123def456")
136
+ assert note is not None
137
+ assert note["uuid"] == "abc123def456"
138
+ assert note["comment"] == "First technote"
139
+ assert "# Hello" in note["body"]
140
+ assert "This is the body." in note["body"]
141
+
142
+ def test_prefix_match(self, fossil_db):
143
+ reader = FossilReader(fossil_db)
144
+ with reader:
145
+ note = reader.get_technote_detail("abc123")
146
+ assert note is not None
147
+ assert note["uuid"] == "abc123def456"
148
+
149
+ def test_not_found(self, fossil_db):
150
+ reader = FossilReader(fossil_db)
151
+ with reader:
152
+ note = reader.get_technote_detail("nonexistent")
153
+ assert note is None
154
+
155
+
156
+class TestGetUnversionedFiles:
157
+ def test_returns_files(self, tmp_path):
158
+ db_path = tmp_path / "uv.fossil"
159
+ conn = _create_test_fossil_db(db_path)
160
+ conn.execute(
161
+ """
162
+ CREATE TABLE unversioned (
163
+ uvid INTEGER PRIMARY KEY AUTOINCREMENT,
164
+ name TEXT UNIQUE,
165
+ rcvid INTEGER,
166
+ mtime DATETIME,
167
+ hash TEXT,
168
+ sz INTEGER,
169
+ encoding INT,
170
+ content BLOB
171
+ )
172
+ """
173
+ )
174
+ conn.execute(
175
+ "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)",
176
+ ("readme.txt", 1700000000, "abc123hash", 42, 0, b"file content"),
177
+ )
178
+ conn.execute(
179
+ "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)",
180
+ ("bin/app.tar.gz", 1700001000, "def456hash", 1024, 0, b"tarball"),
181
+ )
182
+ conn.commit()
183
+ conn.close()
184
+
185
+ reader = FossilReader(db_path)
186
+ with reader:
187
+ files = reader.get_unversioned_files()
188
+ assert len(files) == 2
189
+ assert files[0]["name"] == "bin/app.tar.gz" # Alphabetical
190
+ assert files[1]["name"] == "readme.txt"
191
+ assert files[1]["size"] == 42
192
+ assert files[1]["hash"] == "abc123hash"
193
+ assert files[1]["mtime"] is not None
194
+
195
+ def test_no_unversioned_table(self, tmp_path):
196
+ """Repos without unversioned content don't have the table -- should return empty."""
197
+ db_path = tmp_path / "no_uv.fossil"
198
+ conn = _create_test_fossil_db(db_path)
199
+ conn.close()
200
+ reader = FossilReader(db_path)
201
+ with reader:
202
+ files = reader.get_unversioned_files()
203
+ assert files == []
204
+
205
+ def test_deleted_files_excluded(self, tmp_path):
206
+ """Deleted UV files have empty hash -- should be excluded."""
207
+ db_path = tmp_path / "del_uv.fossil"
208
+ conn = _create_test_fossil_db(db_path)
209
+ conn.execute(
210
+ """
211
+ CREATE TABLE unversioned (
212
+ uvid INTEGER PRIMARY KEY AUTOINCREMENT,
213
+ name TEXT UNIQUE, rcvid INTEGER, mtime DATETIME,
214
+ hash TEXT, sz INTEGER, encoding INT, content BLOB
215
+ )
216
+ """
217
+ )
218
+ conn.execute(
219
+ "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)",
220
+ ("alive.txt", 1700000000, "somehash", 10),
221
+ )
222
+ conn.execute(
223
+ "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)",
224
+ ("deleted.txt", 1700000000, "", 0),
225
+ )
226
+ conn.commit()
227
+ conn.close()
228
+
229
+ reader = FossilReader(db_path)
230
+ with reader:
231
+ files = reader.get_unversioned_files()
232
+ assert len(files) == 1
233
+ assert files[0]["name"] == "alive.txt"
234
+
235
+
236
+# --- View tests (Django DB needed) ---
237
+
238
+
239
+@pytest.mark.django_db
240
+class TestTechnoteListView:
241
+ def test_list_page_loads(self, admin_client, sample_project, fossil_repo_obj):
242
+ mock = _make_reader_mock(get_technotes=[{"uuid": "abc123", "timestamp": "2024-01-01", "user": "admin", "comment": "Test note"}])
243
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
244
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/")
245
+ assert response.status_code == 200
246
+ content = response.content.decode()
247
+ assert "Technotes" in content
248
+ assert "Test note" in content
249
+
250
+ def test_list_shows_create_button_for_writer(self, admin_client, sample_project, fossil_repo_obj):
251
+ mock = _make_reader_mock(get_technotes=[])
252
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
253
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/")
254
+ assert response.status_code == 200
255
+ assert "New Technote" in response.content.decode()
256
+
257
+ def test_list_hides_create_button_for_reader(self, sample_project, fossil_repo_obj):
258
+ from django.contrib.auth.models import User
259
+ from django.test import Client
260
+
261
+ User.objects.create_user(username="reader_only", password="testpass123")
262
+ c = Client()
263
+ c.login(username="reader_only", password="testpass123")
264
+ sample_project.visibility = "public"
265
+ sample_project.save()
266
+ mock = _make_reader_mock(get_technotes=[])
267
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
268
+ response = c.get(f"/projects/{sample_project.slug}/fossil/technotes/")
269
+ assert response.status_code == 200
270
+ assert "New Technote" not in response.content.decode()
271
+
272
+ def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
273
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/")
274
+ assert response.status_code == 403
275
+
276
+
277
+@pytest.mark.django_db
278
+class TestTechnoteCreateView:
279
+ def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
280
+ with _disk_patch:
281
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/")
282
+ assert response.status_code == 200
283
+ assert "New Technote" in response.content.decode()
284
+
285
+ def test_create_technote(self, admin_client, sample_project, fossil_repo_obj):
286
+ mock_cli = MagicMock()
287
+ mock_cli.return_value.technote_create.return_value = True
288
+ with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
289
+ response = admin_client.post(
290
+ f"/projects/{sample_project.slug}/fossil/technotes/create/",
291
+ {"title": "My Note", "body": "Note body content", "timestamp": ""},
292
+ )
293
+ assert response.status_code == 302 # Redirect to list
294
+
295
+ def test_create_denied_for_anon(self, client, sample_project):
296
+ response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/")
297
+ assert response.status_code == 302 # Redirect to login
298
+
299
+ def test_create_denied_for_no_perm(self, no_perm_client, sample_project):
300
+ response = no_perm_client.post(
301
+ f"/projects/{sample_project.slug}/fossil/technotes/create/",
302
+ {"title": "Nope", "body": "denied"},
303
+ )
304
+ assert response.status_code == 403
305
+
306
+
307
+@pytest.mark.django_db
308
+class TestTechnoteDetailView:
309
+ def test_detail_page(self, admin_client, sample_project, fossil_repo_obj):
310
+ mock = _make_reader_mock(
311
+ get_technote_detail={
312
+ "uuid": "abc123def456",
313
+ "timestamp": "2024-01-01",
314
+ "user": "admin",
315
+ "comment": "Test technote",
316
+ "body": "# Hello\n\nBody text.",
317
+ }
318
+ )
319
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
320
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/")
321
+ assert response.status_code == 200
322
+ content = response.content.decode()
323
+ assert "Test technote" in content
324
+ assert "Body text" in content
325
+
326
+ def test_detail_not_found(self, admin_client, sample_project, fossil_repo_obj):
327
+ mock = _make_reader_mock(get_technote_detail=None)
328
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
329
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/nonexistent/")
330
+ assert response.status_code == 404
331
+
332
+ def test_detail_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
333
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/")
334
+ assert response.status_code == 403
335
+
336
+
337
+@pytest.mark.django_db
338
+class TestTechnoteEditView:
339
+ def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj):
340
+ mock = _make_reader_mock(
341
+ get_technote_detail={
342
+ "uuid": "abc123def456",
343
+ "timestamp": "2024-01-01",
344
+ "user": "admin",
345
+ "comment": "Test technote",
346
+ "body": "Existing body content",
347
+ }
348
+ )
349
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
350
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/")
351
+ assert response.status_code == 200
352
+ content = response.content.decode()
353
+ assert "Existing body content" in content
354
+
355
+ def test_edit_technote(self, admin_client, sample_project, fossil_repo_obj):
356
+ mock = _make_reader_mock(
357
+ get_technote_detail={
358
+ "uuid": "abc123def456",
359
+ "timestamp": "2024-01-01",
360
+ "user": "admin",
361
+ "comment": "Test technote",
362
+ "body": "Old body",
363
+ }
364
+ )
365
+ mock_cli = MagicMock()
366
+ mock_cli.return_value.technote_edit.return_value = True
367
+ with _disk_patch, patch("fossil.views.FossilReader", mock), patch("fossil.cli.FossilCLI", mock_cli):
368
+ response = admin_client.post(
369
+ f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/",
370
+ {"body": "Updated body content"},
371
+ )
372
+ assert response.status_code == 302 # Redirect to detail
373
+
374
+ def test_edit_denied_for_anon(self, client, sample_project):
375
+ response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/")
376
+ assert response.status_code == 302 # Redirect to login
377
+
378
+ def test_edit_denied_for_no_perm(self, no_perm_client, sample_project):
379
+ response = no_perm_client.post(
380
+ f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/",
381
+ {"body": "denied"},
382
+ )
383
+ assert response.status_code == 403
--- a/tests/test_technotes.py
+++ b/tests/test_technotes.py
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_technotes.py
+++ b/tests/test_technotes.py
@@ -0,0 +1,383 @@
1 import sqlite3
2 from pathlib import Path
3 from unittest.mock import MagicMock, patch
4
5 import pytest
6
7 from fossil.models import FossilRepository
8 from fossil.reader import FossilReader
9
10 # Reusable patch that makes FossilRepository.exists_on_disk return True
11 _disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
12
13
14 @pytest.fixture
15 def fossil_repo_obj(sample_project):
16 """Return the auto-created FossilRepository for sample_project."""
17 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
18
19
20 def _create_test_fossil_db(path: Path):
21 """Create a minimal .fossil SQLite database with the tables reader.py needs."""
22 conn = sqlite3.connect(str(path))
23 conn.execute(
24 """
25 CREATE TABLE blob (
26 rid INTEGER PRIMARY KEY,
27 uuid TEXT UNIQUE NOT NULL,
28 size INTEGER NOT NULL DEFAULT 0,
29 content BLOB
30 )
31 """
32 )
33 conn.execute(
34 """
35 CREATE TABLE event (
36 type TEXT,
37 mtime REAL,
38 objid INTEGER,
39 user TEXT,
40 comment TEXT
41 )
42 """
43 )
44 conn.commit()
45 return conn
46
47
48 def _insert_technote(conn, rid, uuid, mtime, user, comment, body_content=""):
49 """Insert a technote event and blob into the test database.
50
51 Technotes use event.type = 'e'. The blob contains a Fossil wiki artifact
52 format: header cards followed by W <size>\\n<content>\\nZ <hash>.
53 """
54 import struct
55 import zlib
56
57 # Build a minimal Fossil wiki artifact containing the body
58 artifact = f"D 2024-01-01T00:00:00\nU {user}\nW {len(body_content.encode('utf-8'))}\n{body_content}\nZ 0000000000000000"
59 raw_bytes = artifact.encode("utf-8")
60
61 # Fossil stores blobs with a 4-byte big-endian size prefix + zlib compressed content
62 compressed = struct.pack(">I", len(raw_bytes)) + zlib.compress(raw_bytes)
63
64 conn.execute("INSERT INTO blob (rid, uuid, size, content) VALUES (?, ?, ?, ?)", (rid, uuid, len(raw_bytes), compressed))
65 conn.execute("INSERT INTO event (type, mtime, objid, user, comment) VALUES ('e', ?, ?, ?, ?)", (mtime, rid, user, comment))
66 conn.commit()
67
68
69 @pytest.fixture
70 def fossil_db(tmp_path):
71 """Create a temporary .fossil file with technote data for reader tests."""
72 db_path = tmp_path / "test.fossil"
73 conn = _create_test_fossil_db(db_path)
74 _insert_technote(conn, 100, "abc123def456", 2460676.5, "admin", "First technote", "# Hello\n\nThis is the body.")
75 _insert_technote(conn, 101, "xyz789ghi012", 2460677.5, "dev", "Second technote", "Another note body.")
76 conn.close()
77 return db_path
78
79
80 def _make_reader_mock(**methods):
81 """Create a MagicMock that replaces FossilReader as a class.
82
83 The returned mock supports:
84 reader = FossilReader(path) # returns a mock instance
85 with reader: # context manager
86 reader.some_method() # returns configured value
87 """
88 mock_cls = MagicMock()
89 # The instance returned by calling the class
90 instance = MagicMock()
91 mock_cls.return_value = instance
92 # Context manager support: __enter__ returns the same instance
93 instance.__enter__ = MagicMock(return_value=instance)
94 instance.__exit__ = MagicMock(return_value=False)
95 for name, val in methods.items():
96 getattr(instance, name).return_value = val
97 return mock_cls
98
99
100 # --- Reader unit tests (no Django DB needed) ---
101
102
103 class TestGetTechnotes:
104 def test_returns_technotes(self, fossil_db):
105 reader = FossilReader(fossil_db)
106 with reader:
107 notes = reader.get_technotes()
108 assert len(notes) == 2
109 assert notes[0]["uuid"] == "xyz789ghi012" # Most recent first
110 assert notes[1]["uuid"] == "abc123def456"
111
112 def test_technote_fields(self, fossil_db):
113 reader = FossilReader(fossil_db)
114 with reader:
115 notes = reader.get_technotes()
116 note = notes[1] # The first inserted one
117 assert note["user"] == "admin"
118 assert note["comment"] == "First technote"
119 assert note["timestamp"] is not None
120
121 def test_empty_repo(self, tmp_path):
122 db_path = tmp_path / "empty.fossil"
123 conn = _create_test_fossil_db(db_path)
124 conn.close()
125 reader = FossilReader(db_path)
126 with reader:
127 notes = reader.get_technotes()
128 assert notes == []
129
130
131 class TestGetTechnoteDetail:
132 def test_returns_detail_with_body(self, fossil_db):
133 reader = FossilReader(fossil_db)
134 with reader:
135 note = reader.get_technote_detail("abc123def456")
136 assert note is not None
137 assert note["uuid"] == "abc123def456"
138 assert note["comment"] == "First technote"
139 assert "# Hello" in note["body"]
140 assert "This is the body." in note["body"]
141
142 def test_prefix_match(self, fossil_db):
143 reader = FossilReader(fossil_db)
144 with reader:
145 note = reader.get_technote_detail("abc123")
146 assert note is not None
147 assert note["uuid"] == "abc123def456"
148
149 def test_not_found(self, fossil_db):
150 reader = FossilReader(fossil_db)
151 with reader:
152 note = reader.get_technote_detail("nonexistent")
153 assert note is None
154
155
156 class TestGetUnversionedFiles:
157 def test_returns_files(self, tmp_path):
158 db_path = tmp_path / "uv.fossil"
159 conn = _create_test_fossil_db(db_path)
160 conn.execute(
161 """
162 CREATE TABLE unversioned (
163 uvid INTEGER PRIMARY KEY AUTOINCREMENT,
164 name TEXT UNIQUE,
165 rcvid INTEGER,
166 mtime DATETIME,
167 hash TEXT,
168 sz INTEGER,
169 encoding INT,
170 content BLOB
171 )
172 """
173 )
174 conn.execute(
175 "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)",
176 ("readme.txt", 1700000000, "abc123hash", 42, 0, b"file content"),
177 )
178 conn.execute(
179 "INSERT INTO unversioned (name, mtime, hash, sz, encoding, content) VALUES (?, ?, ?, ?, ?, ?)",
180 ("bin/app.tar.gz", 1700001000, "def456hash", 1024, 0, b"tarball"),
181 )
182 conn.commit()
183 conn.close()
184
185 reader = FossilReader(db_path)
186 with reader:
187 files = reader.get_unversioned_files()
188 assert len(files) == 2
189 assert files[0]["name"] == "bin/app.tar.gz" # Alphabetical
190 assert files[1]["name"] == "readme.txt"
191 assert files[1]["size"] == 42
192 assert files[1]["hash"] == "abc123hash"
193 assert files[1]["mtime"] is not None
194
195 def test_no_unversioned_table(self, tmp_path):
196 """Repos without unversioned content don't have the table -- should return empty."""
197 db_path = tmp_path / "no_uv.fossil"
198 conn = _create_test_fossil_db(db_path)
199 conn.close()
200 reader = FossilReader(db_path)
201 with reader:
202 files = reader.get_unversioned_files()
203 assert files == []
204
205 def test_deleted_files_excluded(self, tmp_path):
206 """Deleted UV files have empty hash -- should be excluded."""
207 db_path = tmp_path / "del_uv.fossil"
208 conn = _create_test_fossil_db(db_path)
209 conn.execute(
210 """
211 CREATE TABLE unversioned (
212 uvid INTEGER PRIMARY KEY AUTOINCREMENT,
213 name TEXT UNIQUE, rcvid INTEGER, mtime DATETIME,
214 hash TEXT, sz INTEGER, encoding INT, content BLOB
215 )
216 """
217 )
218 conn.execute(
219 "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)",
220 ("alive.txt", 1700000000, "somehash", 10),
221 )
222 conn.execute(
223 "INSERT INTO unversioned (name, mtime, hash, sz) VALUES (?, ?, ?, ?)",
224 ("deleted.txt", 1700000000, "", 0),
225 )
226 conn.commit()
227 conn.close()
228
229 reader = FossilReader(db_path)
230 with reader:
231 files = reader.get_unversioned_files()
232 assert len(files) == 1
233 assert files[0]["name"] == "alive.txt"
234
235
236 # --- View tests (Django DB needed) ---
237
238
239 @pytest.mark.django_db
240 class TestTechnoteListView:
241 def test_list_page_loads(self, admin_client, sample_project, fossil_repo_obj):
242 mock = _make_reader_mock(get_technotes=[{"uuid": "abc123", "timestamp": "2024-01-01", "user": "admin", "comment": "Test note"}])
243 with _disk_patch, patch("fossil.views.FossilReader", mock):
244 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/")
245 assert response.status_code == 200
246 content = response.content.decode()
247 assert "Technotes" in content
248 assert "Test note" in content
249
250 def test_list_shows_create_button_for_writer(self, admin_client, sample_project, fossil_repo_obj):
251 mock = _make_reader_mock(get_technotes=[])
252 with _disk_patch, patch("fossil.views.FossilReader", mock):
253 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/")
254 assert response.status_code == 200
255 assert "New Technote" in response.content.decode()
256
257 def test_list_hides_create_button_for_reader(self, sample_project, fossil_repo_obj):
258 from django.contrib.auth.models import User
259 from django.test import Client
260
261 User.objects.create_user(username="reader_only", password="testpass123")
262 c = Client()
263 c.login(username="reader_only", password="testpass123")
264 sample_project.visibility = "public"
265 sample_project.save()
266 mock = _make_reader_mock(get_technotes=[])
267 with _disk_patch, patch("fossil.views.FossilReader", mock):
268 response = c.get(f"/projects/{sample_project.slug}/fossil/technotes/")
269 assert response.status_code == 200
270 assert "New Technote" not in response.content.decode()
271
272 def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
273 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/")
274 assert response.status_code == 403
275
276
277 @pytest.mark.django_db
278 class TestTechnoteCreateView:
279 def test_get_form(self, admin_client, sample_project, fossil_repo_obj):
280 with _disk_patch:
281 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/")
282 assert response.status_code == 200
283 assert "New Technote" in response.content.decode()
284
285 def test_create_technote(self, admin_client, sample_project, fossil_repo_obj):
286 mock_cli = MagicMock()
287 mock_cli.return_value.technote_create.return_value = True
288 with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
289 response = admin_client.post(
290 f"/projects/{sample_project.slug}/fossil/technotes/create/",
291 {"title": "My Note", "body": "Note body content", "timestamp": ""},
292 )
293 assert response.status_code == 302 # Redirect to list
294
295 def test_create_denied_for_anon(self, client, sample_project):
296 response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/create/")
297 assert response.status_code == 302 # Redirect to login
298
299 def test_create_denied_for_no_perm(self, no_perm_client, sample_project):
300 response = no_perm_client.post(
301 f"/projects/{sample_project.slug}/fossil/technotes/create/",
302 {"title": "Nope", "body": "denied"},
303 )
304 assert response.status_code == 403
305
306
307 @pytest.mark.django_db
308 class TestTechnoteDetailView:
309 def test_detail_page(self, admin_client, sample_project, fossil_repo_obj):
310 mock = _make_reader_mock(
311 get_technote_detail={
312 "uuid": "abc123def456",
313 "timestamp": "2024-01-01",
314 "user": "admin",
315 "comment": "Test technote",
316 "body": "# Hello\n\nBody text.",
317 }
318 )
319 with _disk_patch, patch("fossil.views.FossilReader", mock):
320 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/")
321 assert response.status_code == 200
322 content = response.content.decode()
323 assert "Test technote" in content
324 assert "Body text" in content
325
326 def test_detail_not_found(self, admin_client, sample_project, fossil_repo_obj):
327 mock = _make_reader_mock(get_technote_detail=None)
328 with _disk_patch, patch("fossil.views.FossilReader", mock):
329 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/nonexistent/")
330 assert response.status_code == 404
331
332 def test_detail_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
333 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/")
334 assert response.status_code == 403
335
336
337 @pytest.mark.django_db
338 class TestTechnoteEditView:
339 def test_get_edit_form(self, admin_client, sample_project, fossil_repo_obj):
340 mock = _make_reader_mock(
341 get_technote_detail={
342 "uuid": "abc123def456",
343 "timestamp": "2024-01-01",
344 "user": "admin",
345 "comment": "Test technote",
346 "body": "Existing body content",
347 }
348 )
349 with _disk_patch, patch("fossil.views.FossilReader", mock):
350 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/")
351 assert response.status_code == 200
352 content = response.content.decode()
353 assert "Existing body content" in content
354
355 def test_edit_technote(self, admin_client, sample_project, fossil_repo_obj):
356 mock = _make_reader_mock(
357 get_technote_detail={
358 "uuid": "abc123def456",
359 "timestamp": "2024-01-01",
360 "user": "admin",
361 "comment": "Test technote",
362 "body": "Old body",
363 }
364 )
365 mock_cli = MagicMock()
366 mock_cli.return_value.technote_edit.return_value = True
367 with _disk_patch, patch("fossil.views.FossilReader", mock), patch("fossil.cli.FossilCLI", mock_cli):
368 response = admin_client.post(
369 f"/projects/{sample_project.slug}/fossil/technotes/abc123def456/edit/",
370 {"body": "Updated body content"},
371 )
372 assert response.status_code == 302 # Redirect to detail
373
374 def test_edit_denied_for_anon(self, client, sample_project):
375 response = client.get(f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/")
376 assert response.status_code == 302 # Redirect to login
377
378 def test_edit_denied_for_no_perm(self, no_perm_client, sample_project):
379 response = no_perm_client.post(
380 f"/projects/{sample_project.slug}/fossil/technotes/abc123/edit/",
381 {"body": "denied"},
382 )
383 assert response.status_code == 403
--- a/tests/test_unversioned.py
+++ b/tests/test_unversioned.py
@@ -0,0 +1,185 @@
1
+from unittest.mock import MagicMock, patch
2
+
3
+import pytest
4
+
5
+from fossil.models import FossilRepository
6
+
7
+# Reusable patch that makes FossilRepository.exists_on_disk return True
8
+_disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
9
+
10
+
11
+@pytest.fixture
12
+def fossil_repo_obj(sample_project):
13
+ """Return the auto-created FossilRepository for sample_project."""
14
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
+
16
+
17
+def _make_reader_mock(**methods):
18
+ """Create a MagicMock that replaces FossilReader as a class.
19
+
20
+ The returned mock supports:
21
+ reader = FossilReader(path) # returns a mock instance
22
+ with reader: # context manager
23
+ reader.some_method() # returns configured value
24
+ """
25
+ mock_cls = MagicMock()
26
+ instance = MagicMock()
27
+ mock_cls.return_value = instance
28
+ instance.__enter__ = MagicMock(return_value=instance)
29
+ instance.__exit__ = MagicMock(return_value=False)
30
+ for name, val in methods.items():
31
+ getattr(instance, name).return_value = val
32
+ return mock_cls
33
+
34
+
35
+@pytest.mark.django_db
36
+class TestUnversionedListView:
37
+ def test_list_page_loads(self, admin_client, sample_project, fossil_repo_obj):
38
+ mock = _make_reader_mock(
39
+ get_unversioned_files=[
40
+ {"name": "release.tar.gz", "size": 1024, "mtime": None, "hash": "abc"},
41
+ {"name": "readme.txt", "size": 42, "mtime": None, "hash": "def"},
42
+ ]
43
+ )
44
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
45
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/")
46
+ assert response.status_code == 200
47
+ content = response.content.decode()
48
+ assert "Unversioned Files" in content
49
+ assert "release.tar.gz" in content
50
+ assert "readme.txt" in content
51
+
52
+ def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
53
+ mock = _make_reader_mock(get_unversioned_files=[])
54
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
55
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/")
56
+ assert response.status_code == 200
57
+ assert "No unversioned files" in response.content.decode()
58
+
59
+ def test_list_shows_upload_for_admin(self, admin_client, sample_project, fossil_repo_obj):
60
+ mock = _make_reader_mock(get_unversioned_files=[])
61
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
62
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/")
63
+ assert response.status_code == 200
64
+ assert "Upload File" in response.content.decode()
65
+
66
+ def test_list_hides_upload_for_non_admin(self, sample_project, fossil_repo_obj):
67
+ """A user with write but not admin access should not see the upload form."""
68
+ from django.contrib.auth.models import User
69
+ from django.test import Client
70
+
71
+ from organization.models import Team
72
+ from projects.models import ProjectTeam
73
+
74
+ writer = User.objects.create_user(username="writer_only", password="testpass123")
75
+ team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=writer)
76
+ team.members.add(writer)
77
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=writer)
78
+
79
+ c = Client()
80
+ c.login(username="writer_only", password="testpass123")
81
+ mock = _make_reader_mock(get_unversioned_files=[])
82
+ with _disk_patch, patch("fossil.views.FossilReader", mock):
83
+ response = c.get(f"/projects/{sample_project.slug}/fossil/files/")
84
+ assert response.status_code == 200
85
+ assert "Upload File" not in response.content.decode()
86
+
87
+ def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
88
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/files/")
89
+ assert response.status_code == 403
90
+
91
+
92
+@pytest.mark.django_db
93
+class TestUnversionedDownloadView:
94
+ def test_download_file(self, admin_client, sample_project, fossil_repo_obj):
95
+ mock_cli = MagicMock()
96
+ mock_cli.return_value.uv_cat.return_value = b"file content here"
97
+ with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
98
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/readme.txt")
99
+ assert response.status_code == 200
100
+ assert response.content == b"file content here"
101
+ assert response["Content-Disposition"] == 'attachment; filename="readme.txt"'
102
+
103
+ def test_download_nested_path(self, admin_client, sample_project, fossil_repo_obj):
104
+ mock_cli = MagicMock()
105
+ mock_cli.return_value.uv_cat.return_value = b"tarball bytes"
106
+ with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
107
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/dist/app-v1.0.tar.gz")
108
+ assert response.status_code == 200
109
+ assert response["Content-Disposition"] == 'attachment; filename="app-v1.0.tar.gz"'
110
+
111
+ def test_download_not_found(self, admin_client, sample_project, fossil_repo_obj):
112
+ mock_cli = MagicMock()
113
+ mock_cli.return_value.uv_cat.side_effect = FileNotFoundError("Not found")
114
+ with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
115
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/missing.txt")
116
+ assert response.status_code == 404
117
+
118
+ def test_download_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
119
+ response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/files/download/secret.txt")
120
+ assert response.status_code == 403
121
+
122
+
123
+@pytest.mark.django_db
124
+class TestUnversionedUploadView:
125
+ def test_upload_file(self, admin_client, sample_project, fossil_repo_obj):
126
+ from django.core.files.uploadedfile import SimpleUploadedFile
127
+
128
+ uploaded = SimpleUploadedFile("artifact.bin", b"binary content", content_type="application/octet-stream")
129
+ mock_cli = MagicMock()
130
+ mock_cli.return_value.uv_add.return_value = True
131
+ with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
132
+ response = admin_client.post(
133
+ f"/projects/{sample_project.slug}/fossil/files/upload/",
134
+ {"file": uploaded},
135
+ )
136
+ assert response.status_code == 302 # Redirect to list
137
+ mock_cli.return_value.uv_add.assert_called_once()
138
+
139
+ def test_upload_no_file(self, admin_client, sample_project, fossil_repo_obj):
140
+ with _disk_patch:
141
+ response = admin_client.post(f"/projects/{sample_project.slug}/fossil/files/upload/")
142
+ assert response.status_code == 302 # Redirect back with error
143
+
144
+ def test_upload_get_redirects(self, admin_client, sample_project, fossil_repo_obj):
145
+ with _disk_patch:
146
+ response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/upload/")
147
+ assert response.status_code == 302
148
+
149
+ def test_upload_denied_for_anon(self, client, sample_project):
150
+ response = client.post(f"/projects/{sample_project.slug}/fossil/files/upload/")
151
+ assert response.status_code == 302 # Redirect to login
152
+
153
+ def test_upload_denied_for_writer(self, sample_project, fossil_repo_obj):
154
+ """Upload requires admin, not just write access."""
155
+ from django.contrib.auth.models import User
156
+ from django.test import Client
157
+
158
+ from organization.models import Team
159
+ from projects.models import ProjectTeam
160
+
161
+ writer = User.objects.create_user(username="writer_upl", password="testpass123")
162
+ team = Team.objects.create(name="UplWriters", organization=sample_project.organization, created_by=writer)
163
+ team.members.add(writer)
164
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=writer)
165
+
166
+ c = Client()
167
+ c.login(username="writer_upl", password="testpass123")
168
+ from django.core.files.uploadedfile import SimpleUploadedFile
169
+
170
+ uploaded = SimpleUploadedFile("hack.bin", b"nope", content_type="application/octet-stream")
171
+ response = c.post(
172
+ f"/projects/{sample_project.slug}/fossil/files/upload/",
173
+ {"file": uploaded},
174
+ )
175
+ assert response.status_code == 403
176
+
177
+ def test_upload_denied_for_no_perm(self, no_perm_client, sample_project):
178
+ from django.core.files.uploadedfile import SimpleUploadedFile
179
+
180
+ uploaded = SimpleUploadedFile("hack.bin", b"nope", content_type="application/octet-stream")
181
+ response = no_perm_client.post(
182
+ f"/projects/{sample_project.slug}/fossil/files/upload/",
183
+ {"file": uploaded},
184
+ )
185
+ assert response.status_code == 403
--- a/tests/test_unversioned.py
+++ b/tests/test_unversioned.py
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_unversioned.py
+++ b/tests/test_unversioned.py
@@ -0,0 +1,185 @@
1 from unittest.mock import MagicMock, patch
2
3 import pytest
4
5 from fossil.models import FossilRepository
6
7 # Reusable patch that makes FossilRepository.exists_on_disk return True
8 _disk_patch = patch("fossil.models.FossilRepository.exists_on_disk", new_callable=lambda: property(lambda self: True))
9
10
11 @pytest.fixture
12 def fossil_repo_obj(sample_project):
13 """Return the auto-created FossilRepository for sample_project."""
14 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
15
16
17 def _make_reader_mock(**methods):
18 """Create a MagicMock that replaces FossilReader as a class.
19
20 The returned mock supports:
21 reader = FossilReader(path) # returns a mock instance
22 with reader: # context manager
23 reader.some_method() # returns configured value
24 """
25 mock_cls = MagicMock()
26 instance = MagicMock()
27 mock_cls.return_value = instance
28 instance.__enter__ = MagicMock(return_value=instance)
29 instance.__exit__ = MagicMock(return_value=False)
30 for name, val in methods.items():
31 getattr(instance, name).return_value = val
32 return mock_cls
33
34
35 @pytest.mark.django_db
36 class TestUnversionedListView:
37 def test_list_page_loads(self, admin_client, sample_project, fossil_repo_obj):
38 mock = _make_reader_mock(
39 get_unversioned_files=[
40 {"name": "release.tar.gz", "size": 1024, "mtime": None, "hash": "abc"},
41 {"name": "readme.txt", "size": 42, "mtime": None, "hash": "def"},
42 ]
43 )
44 with _disk_patch, patch("fossil.views.FossilReader", mock):
45 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/")
46 assert response.status_code == 200
47 content = response.content.decode()
48 assert "Unversioned Files" in content
49 assert "release.tar.gz" in content
50 assert "readme.txt" in content
51
52 def test_list_empty(self, admin_client, sample_project, fossil_repo_obj):
53 mock = _make_reader_mock(get_unversioned_files=[])
54 with _disk_patch, patch("fossil.views.FossilReader", mock):
55 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/")
56 assert response.status_code == 200
57 assert "No unversioned files" in response.content.decode()
58
59 def test_list_shows_upload_for_admin(self, admin_client, sample_project, fossil_repo_obj):
60 mock = _make_reader_mock(get_unversioned_files=[])
61 with _disk_patch, patch("fossil.views.FossilReader", mock):
62 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/")
63 assert response.status_code == 200
64 assert "Upload File" in response.content.decode()
65
66 def test_list_hides_upload_for_non_admin(self, sample_project, fossil_repo_obj):
67 """A user with write but not admin access should not see the upload form."""
68 from django.contrib.auth.models import User
69 from django.test import Client
70
71 from organization.models import Team
72 from projects.models import ProjectTeam
73
74 writer = User.objects.create_user(username="writer_only", password="testpass123")
75 team = Team.objects.create(name="Writers", organization=sample_project.organization, created_by=writer)
76 team.members.add(writer)
77 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=writer)
78
79 c = Client()
80 c.login(username="writer_only", password="testpass123")
81 mock = _make_reader_mock(get_unversioned_files=[])
82 with _disk_patch, patch("fossil.views.FossilReader", mock):
83 response = c.get(f"/projects/{sample_project.slug}/fossil/files/")
84 assert response.status_code == 200
85 assert "Upload File" not in response.content.decode()
86
87 def test_list_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
88 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/files/")
89 assert response.status_code == 403
90
91
92 @pytest.mark.django_db
93 class TestUnversionedDownloadView:
94 def test_download_file(self, admin_client, sample_project, fossil_repo_obj):
95 mock_cli = MagicMock()
96 mock_cli.return_value.uv_cat.return_value = b"file content here"
97 with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
98 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/readme.txt")
99 assert response.status_code == 200
100 assert response.content == b"file content here"
101 assert response["Content-Disposition"] == 'attachment; filename="readme.txt"'
102
103 def test_download_nested_path(self, admin_client, sample_project, fossil_repo_obj):
104 mock_cli = MagicMock()
105 mock_cli.return_value.uv_cat.return_value = b"tarball bytes"
106 with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
107 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/dist/app-v1.0.tar.gz")
108 assert response.status_code == 200
109 assert response["Content-Disposition"] == 'attachment; filename="app-v1.0.tar.gz"'
110
111 def test_download_not_found(self, admin_client, sample_project, fossil_repo_obj):
112 mock_cli = MagicMock()
113 mock_cli.return_value.uv_cat.side_effect = FileNotFoundError("Not found")
114 with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
115 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/download/missing.txt")
116 assert response.status_code == 404
117
118 def test_download_denied_for_no_perm_on_private(self, no_perm_client, sample_project):
119 response = no_perm_client.get(f"/projects/{sample_project.slug}/fossil/files/download/secret.txt")
120 assert response.status_code == 403
121
122
123 @pytest.mark.django_db
124 class TestUnversionedUploadView:
125 def test_upload_file(self, admin_client, sample_project, fossil_repo_obj):
126 from django.core.files.uploadedfile import SimpleUploadedFile
127
128 uploaded = SimpleUploadedFile("artifact.bin", b"binary content", content_type="application/octet-stream")
129 mock_cli = MagicMock()
130 mock_cli.return_value.uv_add.return_value = True
131 with _disk_patch, patch("fossil.cli.FossilCLI", mock_cli):
132 response = admin_client.post(
133 f"/projects/{sample_project.slug}/fossil/files/upload/",
134 {"file": uploaded},
135 )
136 assert response.status_code == 302 # Redirect to list
137 mock_cli.return_value.uv_add.assert_called_once()
138
139 def test_upload_no_file(self, admin_client, sample_project, fossil_repo_obj):
140 with _disk_patch:
141 response = admin_client.post(f"/projects/{sample_project.slug}/fossil/files/upload/")
142 assert response.status_code == 302 # Redirect back with error
143
144 def test_upload_get_redirects(self, admin_client, sample_project, fossil_repo_obj):
145 with _disk_patch:
146 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/files/upload/")
147 assert response.status_code == 302
148
149 def test_upload_denied_for_anon(self, client, sample_project):
150 response = client.post(f"/projects/{sample_project.slug}/fossil/files/upload/")
151 assert response.status_code == 302 # Redirect to login
152
153 def test_upload_denied_for_writer(self, sample_project, fossil_repo_obj):
154 """Upload requires admin, not just write access."""
155 from django.contrib.auth.models import User
156 from django.test import Client
157
158 from organization.models import Team
159 from projects.models import ProjectTeam
160
161 writer = User.objects.create_user(username="writer_upl", password="testpass123")
162 team = Team.objects.create(name="UplWriters", organization=sample_project.organization, created_by=writer)
163 team.members.add(writer)
164 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=writer)
165
166 c = Client()
167 c.login(username="writer_upl", password="testpass123")
168 from django.core.files.uploadedfile import SimpleUploadedFile
169
170 uploaded = SimpleUploadedFile("hack.bin", b"nope", content_type="application/octet-stream")
171 response = c.post(
172 f"/projects/{sample_project.slug}/fossil/files/upload/",
173 {"file": uploaded},
174 )
175 assert response.status_code == 403
176
177 def test_upload_denied_for_no_perm(self, no_perm_client, sample_project):
178 from django.core.files.uploadedfile import SimpleUploadedFile
179
180 uploaded = SimpleUploadedFile("hack.bin", b"nope", content_type="application/octet-stream")
181 response = no_perm_client.post(
182 f"/projects/{sample_project.slug}/fossil/files/upload/",
183 {"file": uploaded},
184 )
185 assert response.status_code == 403

Keyboard Shortcuts

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