FossilRepo

Implement ticket + wiki sync to GitHub: TicketSyncMapping/WikiSyncMapping models, GitHub API client with rate limiting, sync tasks chained from run_git_sync

ragelink 2026-04-07 21:15 trunk
Commit 0e40dc289c280226c87959aebb87b14b872f096fa0bebe753251d10f7591accd
49 files changed +2 -2 +88 -5 +180 +80 +32 +216 +3 -9 +18 -16 +3 +8 -2 +9 +1 -1 +1 -1 +6 -1 +6 -1 +1 -1 +1 -1 +1 -1 +2 +1 +1 -1 +1 +6 -1 +1 +6 -1 +1 -1 +6 -1 +15 -15 +1 -1 +4 -3 +1 -1 +1 -1 +6 -3 +6 -3 +1 +6 -3 +3 -3 +73 -3
~ config/__pycache__/settings.cpython-314.pyc ~ config/__pycache__/urls.cpython-314.pyc ~ fossil/__pycache__/api_auth.cpython-314.pyc ~ fossil/__pycache__/api_views.cpython-314.pyc ~ fossil/__pycache__/cli.cpython-314.pyc ~ fossil/__pycache__/reader.cpython-314.pyc ~ fossil/__pycache__/sync_models.cpython-314.pyc ~ fossil/__pycache__/views.cpython-314.pyc ~ fossil/api_auth.py ~ fossil/api_views.py + fossil/github_api.py + fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py ~ fossil/migrations/__pycache__/0011_codereview_historicalcodereview_and_more.cpython-314.pyc ~ fossil/migrations/__pycache__/0012_alter_ticketclaim_unique_together.cpython-314.pyc ~ fossil/sync_models.py ~ fossil/tasks.py ~ fossil/views.py ~ projects/__pycache__/views.cpython-314.pyc ~ templates/accounts/profile.html ~ templates/accounts/ssh_keys.html ~ templates/base.html ~ templates/dashboard.html ~ templates/fossil/_project_nav.html ~ templates/fossil/api_token_create.html ~ templates/fossil/api_token_list.html ~ templates/fossil/branch_protection_list.html ~ templates/fossil/code_blame.html ~ templates/fossil/code_browser.html ~ templates/fossil/code_file.html ~ templates/fossil/compare.html ~ templates/fossil/forum_thread.html ~ templates/fossil/release_detail.html ~ templates/fossil/search.html ~ templates/fossil/technote_list.html ~ templates/fossil/ticket_detail.html ~ templates/fossil/ticket_fields_list.html ~ templates/fossil/ticket_list.html ~ templates/fossil/ticket_reports_list.html ~ templates/fossil/timeline.html ~ templates/fossil/unversioned_list.html ~ templates/includes/nav.html ~ templates/includes/nav_public.html ~ templates/includes/sidebar.html ~ templates/organization/team_list.html ~ templates/pages/page_list.html ~ templates/projects/explore.html ~ templates/projects/group_list.html ~ templates/projects/project_detail.html ~ tests/test_agent_coordination.py
--- config/__pycache__/settings.cpython-314.pyc
+++ config/__pycache__/settings.cpython-314.pyc
cannot compute difference between binary files
11
--- config/__pycache__/settings.cpython-314.pyc
+++ config/__pycache__/settings.cpython-314.pyc
0 annot compute difference between binary files
1
--- config/__pycache__/settings.cpython-314.pyc
+++ config/__pycache__/settings.cpython-314.pyc
0 annot compute difference between binary files
1
--- config/__pycache__/urls.cpython-314.pyc
+++ config/__pycache__/urls.cpython-314.pyc
cannot compute difference between binary files
11
--- config/__pycache__/urls.cpython-314.pyc
+++ config/__pycache__/urls.cpython-314.pyc
0 annot compute difference between binary files
1
--- config/__pycache__/urls.cpython-314.pyc
+++ config/__pycache__/urls.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/api_auth.cpython-314.pyc
+++ fossil/__pycache__/api_auth.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/api_auth.cpython-314.pyc
+++ fossil/__pycache__/api_auth.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/api_auth.cpython-314.pyc
+++ fossil/__pycache__/api_auth.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/api_views.cpython-314.pyc
+++ fossil/__pycache__/api_views.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/api_views.cpython-314.pyc
+++ fossil/__pycache__/api_views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/api_views.cpython-314.pyc
+++ fossil/__pycache__/api_views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/cli.cpython-314.pyc
+++ fossil/__pycache__/cli.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/cli.cpython-314.pyc
+++ fossil/__pycache__/cli.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/cli.cpython-314.pyc
+++ fossil/__pycache__/cli.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/reader.cpython-314.pyc
+++ fossil/__pycache__/reader.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/reader.cpython-314.pyc
+++ fossil/__pycache__/reader.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/reader.cpython-314.pyc
+++ fossil/__pycache__/reader.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/sync_models.cpython-314.pyc
+++ fossil/__pycache__/sync_models.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/sync_models.cpython-314.pyc
+++ fossil/__pycache__/sync_models.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/sync_models.cpython-314.pyc
+++ fossil/__pycache__/sync_models.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/views.cpython-314.pyc
+++ fossil/__pycache__/views.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/__pycache__/views.cpython-314.pyc
+++ fossil/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/__pycache__/views.cpython-314.pyc
+++ fossil/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/api_auth.py
+++ fossil/api_auth.py
@@ -78,11 +78,11 @@
7878
scopes = {s.strip().lower() for s in token_scopes.split(",") if s.strip()}
7979
8080
if "*" in scopes or "admin" in scopes:
8181
return True
8282
if required == "read":
83
- return bool(scopes & {"read", "write", "admin", "status:write"})
83
+ return bool(scopes & {"read", "write", "admin"})
8484
if required == "write":
8585
return "write" in scopes
8686
if required == "status:write":
87
- return bool(scopes & {"status:write", "write", "admin", "*"})
87
+ return bool(scopes & {"status:write", "write"})
8888
return False
8989
--- fossil/api_auth.py
+++ fossil/api_auth.py
@@ -78,11 +78,11 @@
78 scopes = {s.strip().lower() for s in token_scopes.split(",") if s.strip()}
79
80 if "*" in scopes or "admin" in scopes:
81 return True
82 if required == "read":
83 return bool(scopes & {"read", "write", "admin", "status:write"})
84 if required == "write":
85 return "write" in scopes
86 if required == "status:write":
87 return bool(scopes & {"status:write", "write", "admin", "*"})
88 return False
89
--- fossil/api_auth.py
+++ fossil/api_auth.py
@@ -78,11 +78,11 @@
78 scopes = {s.strip().lower() for s in token_scopes.split(",") if s.strip()}
79
80 if "*" in scopes or "admin" in scopes:
81 return True
82 if required == "read":
83 return bool(scopes & {"read", "write", "admin"})
84 if required == "write":
85 return "write" in scopes
86 if required == "status:write":
87 return bool(scopes & {"status:write", "write"})
88 return False
89
--- fossil/api_views.py
+++ fossil/api_views.py
@@ -23,11 +23,11 @@
2323
from django.views.decorators.http import require_GET
2424
2525
from fossil.api_auth import authenticate_request
2626
from fossil.models import FossilRepository
2727
from fossil.reader import FossilReader
28
-from projects.access import can_read_project, can_write_project
28
+from projects.access import can_admin_project, can_read_project, can_write_project
2929
from projects.models import Project
3030
3131
logger = logging.getLogger(__name__)
3232
3333
@@ -866,11 +866,10 @@
866866
"name": workspace.name,
867867
"branch": workspace.branch,
868868
"status": workspace.status,
869869
"agent_id": workspace.agent_id,
870870
"description": workspace.description,
871
- "checkout_path": workspace.checkout_path,
872871
"created_at": _isoformat(workspace.created_at),
873872
},
874873
status=201,
875874
)
876875
@@ -898,11 +897,10 @@
898897
"name": workspace.name,
899898
"branch": workspace.branch,
900899
"status": workspace.status,
901900
"agent_id": workspace.agent_id,
902901
"description": workspace.description,
903
- "checkout_path": workspace.checkout_path,
904902
"files_changed": workspace.files_changed,
905903
"commits_made": workspace.commits_made,
906904
"created_at": _isoformat(workspace.created_at),
907905
"updated_at": _isoformat(workspace.updated_at),
908906
}
@@ -1037,10 +1035,50 @@
10371035
data = json.loads(request.body) if request.body else {}
10381036
except (json.JSONDecodeError, ValueError):
10391037
data = {}
10401038
10411039
target_branch = (data.get("target_branch") or "trunk").strip()
1040
+
1041
+ # --- Branch protection enforcement ---
1042
+ is_admin = user is not None and can_admin_project(user, project)
1043
+ if not is_admin:
1044
+ from fossil.branch_protection import BranchProtection
1045
+
1046
+ for protection in BranchProtection.objects.filter(repository=repo, deleted_at__isnull=True):
1047
+ if protection.matches_branch(target_branch):
1048
+ if protection.restrict_push:
1049
+ return JsonResponse(
1050
+ {"error": f"Branch '{target_branch}' is protected: only admins can merge to it"},
1051
+ status=403,
1052
+ )
1053
+ if protection.require_status_checks:
1054
+ from fossil.ci import StatusCheck
1055
+
1056
+ for context in protection.get_required_contexts_list():
1057
+ latest = StatusCheck.objects.filter(repository=repo, context=context).order_by("-created_at").first()
1058
+ if not latest or latest.state != "success":
1059
+ return JsonResponse(
1060
+ {"error": f"Required status check '{context}' has not passed"},
1061
+ status=403,
1062
+ )
1063
+
1064
+ # --- Review gate enforcement ---
1065
+ from fossil.code_reviews import CodeReview
1066
+
1067
+ linked_review = CodeReview.objects.filter(repository=repo, workspace=workspace).order_by("-created_at").first()
1068
+ if linked_review is not None:
1069
+ if linked_review.status != "approved":
1070
+ return JsonResponse(
1071
+ {"error": f"Linked code review is '{linked_review.status}', must be approved before merging"},
1072
+ status=403,
1073
+ )
1074
+ elif not is_admin:
1075
+ # No review exists for this workspace — require one for non-admins
1076
+ return JsonResponse(
1077
+ {"error": "No approved code review found for this workspace; submit one before merging"},
1078
+ status=403,
1079
+ )
10421080
10431081
from fossil.cli import FossilCLI
10441082
10451083
cli = FossilCLI()
10461084
checkout_dir = workspace.checkout_path
@@ -1093,10 +1131,15 @@
10931131
10941132
workspace.status = "merged"
10951133
workspace.checkout_path = ""
10961134
workspace.save(update_fields=["status", "checkout_path", "updated_at", "version"])
10971135
1136
+ # Mark the linked review as merged
1137
+ if linked_review is not None and linked_review.status == "approved":
1138
+ linked_review.status = "merged"
1139
+ linked_review.save(update_fields=["status", "updated_at", "version"])
1140
+
10981141
return JsonResponse(
10991142
{
11001143
"name": workspace.name,
11011144
"branch": workspace.branch,
11021145
"status": workspace.status,
@@ -1269,16 +1312,28 @@
12691312
return err
12701313
12711314
if token is None and (user is None or not can_write_project(user, project)):
12721315
return JsonResponse({"error": "Write access required"}, status=403)
12731316
1317
+ try:
1318
+ data = json.loads(request.body) if request.body else {}
1319
+ except (json.JSONDecodeError, ValueError):
1320
+ data = {}
1321
+
1322
+ agent_id = (data.get("agent_id") or "").strip()
1323
+ if not agent_id:
1324
+ return JsonResponse({"error": "agent_id is required"}, status=400)
1325
+
12741326
from fossil.agent_claims import TicketClaim
12751327
12761328
claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
12771329
if claim is None:
12781330
return JsonResponse({"error": "No active claim for this ticket"}, status=404)
12791331
1332
+ if claim.agent_id != agent_id:
1333
+ return JsonResponse({"error": "Only the claiming agent can release this ticket"}, status=403)
1334
+
12801335
claim.status = "released"
12811336
claim.released_at = timezone.now()
12821337
claim.save(update_fields=["status", "released_at", "updated_at", "version"])
12831338
# Soft-delete to free the unique constraint slot for future claims
12841339
claim.soft_delete(user=user)
@@ -1322,16 +1377,23 @@
13221377
try:
13231378
data = json.loads(request.body)
13241379
except (json.JSONDecodeError, ValueError):
13251380
return JsonResponse({"error": "Invalid JSON body"}, status=400)
13261381
1382
+ agent_id = (data.get("agent_id") or "").strip()
1383
+ if not agent_id:
1384
+ return JsonResponse({"error": "agent_id is required"}, status=400)
1385
+
13271386
from fossil.agent_claims import TicketClaim
13281387
13291388
claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
13301389
if claim is None:
13311390
return JsonResponse({"error": "No active claim for this ticket"}, status=404)
13321391
1392
+ if claim.agent_id != agent_id:
1393
+ return JsonResponse({"error": "Only the claiming agent can submit work for this ticket"}, status=403)
1394
+
13331395
if claim.status != "claimed":
13341396
return JsonResponse({"error": f"Claim is already {claim.status}"}, status=409)
13351397
13361398
summary = (data.get("summary") or "").strip()
13371399
files_changed = data.get("files_changed") or []
@@ -1600,21 +1662,31 @@
16001662
if workspace_name:
16011663
from fossil.workspaces import AgentWorkspace
16021664
16031665
workspace_obj = AgentWorkspace.objects.filter(repository=repo, name=workspace_name).first()
16041666
1667
+ # If linking to a ticket, verify the caller owns the claim
1668
+ ticket_uuid = (data.get("ticket_uuid") or "").strip()
1669
+ review_agent_id = (data.get("agent_id") or "").strip()
1670
+ if ticket_uuid:
1671
+ from fossil.agent_claims import TicketClaim
1672
+
1673
+ claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
1674
+ if claim is not None and review_agent_id and claim.agent_id != review_agent_id:
1675
+ return JsonResponse({"error": "Cannot create a review for a ticket claimed by another agent"}, status=403)
1676
+
16051677
from fossil.code_reviews import CodeReview
16061678
16071679
review = CodeReview.objects.create(
16081680
repository=repo,
16091681
workspace=workspace_obj,
16101682
title=title,
16111683
description=data.get("description", ""),
16121684
diff=diff,
16131685
files_changed=data.get("files_changed", []),
1614
- agent_id=data.get("agent_id", ""),
1615
- ticket_uuid=data.get("ticket_uuid", ""),
1686
+ agent_id=review_agent_id,
1687
+ ticket_uuid=ticket_uuid,
16161688
created_by=user,
16171689
)
16181690
16191691
return JsonResponse(
16201692
{
@@ -1823,10 +1895,21 @@
18231895
if review is None:
18241896
return JsonResponse({"error": "Review not found"}, status=404)
18251897
18261898
if review.status == "merged":
18271899
return JsonResponse({"error": "Review is already merged"}, status=409)
1900
+
1901
+ # Prevent self-approval: token-based callers (agents) cannot approve their own review.
1902
+ # Session-auth users (human reviewers) are allowed since they represent human oversight.
1903
+ if token is not None and review.agent_id:
1904
+ try:
1905
+ data = json.loads(request.body) if request.body else {}
1906
+ except (json.JSONDecodeError, ValueError):
1907
+ data = {}
1908
+ approver_agent_id = (data.get("agent_id") or "").strip()
1909
+ if approver_agent_id and approver_agent_id == review.agent_id:
1910
+ return JsonResponse({"error": "Cannot approve your own review"}, status=403)
18281911
18291912
review.status = "approved"
18301913
review.save(update_fields=["status", "updated_at", "version"])
18311914
18321915
return JsonResponse({"id": review.pk, "status": review.status})
18331916
18341917
ADDED fossil/github_api.py
18351918
ADDED fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py
--- fossil/api_views.py
+++ fossil/api_views.py
@@ -23,11 +23,11 @@
23 from django.views.decorators.http import require_GET
24
25 from fossil.api_auth import authenticate_request
26 from fossil.models import FossilRepository
27 from fossil.reader import FossilReader
28 from projects.access import can_read_project, can_write_project
29 from projects.models import Project
30
31 logger = logging.getLogger(__name__)
32
33
@@ -866,11 +866,10 @@
866 "name": workspace.name,
867 "branch": workspace.branch,
868 "status": workspace.status,
869 "agent_id": workspace.agent_id,
870 "description": workspace.description,
871 "checkout_path": workspace.checkout_path,
872 "created_at": _isoformat(workspace.created_at),
873 },
874 status=201,
875 )
876
@@ -898,11 +897,10 @@
898 "name": workspace.name,
899 "branch": workspace.branch,
900 "status": workspace.status,
901 "agent_id": workspace.agent_id,
902 "description": workspace.description,
903 "checkout_path": workspace.checkout_path,
904 "files_changed": workspace.files_changed,
905 "commits_made": workspace.commits_made,
906 "created_at": _isoformat(workspace.created_at),
907 "updated_at": _isoformat(workspace.updated_at),
908 }
@@ -1037,10 +1035,50 @@
1037 data = json.loads(request.body) if request.body else {}
1038 except (json.JSONDecodeError, ValueError):
1039 data = {}
1040
1041 target_branch = (data.get("target_branch") or "trunk").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1042
1043 from fossil.cli import FossilCLI
1044
1045 cli = FossilCLI()
1046 checkout_dir = workspace.checkout_path
@@ -1093,10 +1131,15 @@
1093
1094 workspace.status = "merged"
1095 workspace.checkout_path = ""
1096 workspace.save(update_fields=["status", "checkout_path", "updated_at", "version"])
1097
 
 
 
 
 
1098 return JsonResponse(
1099 {
1100 "name": workspace.name,
1101 "branch": workspace.branch,
1102 "status": workspace.status,
@@ -1269,16 +1312,28 @@
1269 return err
1270
1271 if token is None and (user is None or not can_write_project(user, project)):
1272 return JsonResponse({"error": "Write access required"}, status=403)
1273
 
 
 
 
 
 
 
 
 
1274 from fossil.agent_claims import TicketClaim
1275
1276 claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
1277 if claim is None:
1278 return JsonResponse({"error": "No active claim for this ticket"}, status=404)
1279
 
 
 
1280 claim.status = "released"
1281 claim.released_at = timezone.now()
1282 claim.save(update_fields=["status", "released_at", "updated_at", "version"])
1283 # Soft-delete to free the unique constraint slot for future claims
1284 claim.soft_delete(user=user)
@@ -1322,16 +1377,23 @@
1322 try:
1323 data = json.loads(request.body)
1324 except (json.JSONDecodeError, ValueError):
1325 return JsonResponse({"error": "Invalid JSON body"}, status=400)
1326
 
 
 
 
1327 from fossil.agent_claims import TicketClaim
1328
1329 claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
1330 if claim is None:
1331 return JsonResponse({"error": "No active claim for this ticket"}, status=404)
1332
 
 
 
1333 if claim.status != "claimed":
1334 return JsonResponse({"error": f"Claim is already {claim.status}"}, status=409)
1335
1336 summary = (data.get("summary") or "").strip()
1337 files_changed = data.get("files_changed") or []
@@ -1600,21 +1662,31 @@
1600 if workspace_name:
1601 from fossil.workspaces import AgentWorkspace
1602
1603 workspace_obj = AgentWorkspace.objects.filter(repository=repo, name=workspace_name).first()
1604
 
 
 
 
 
 
 
 
 
 
1605 from fossil.code_reviews import CodeReview
1606
1607 review = CodeReview.objects.create(
1608 repository=repo,
1609 workspace=workspace_obj,
1610 title=title,
1611 description=data.get("description", ""),
1612 diff=diff,
1613 files_changed=data.get("files_changed", []),
1614 agent_id=data.get("agent_id", ""),
1615 ticket_uuid=data.get("ticket_uuid", ""),
1616 created_by=user,
1617 )
1618
1619 return JsonResponse(
1620 {
@@ -1823,10 +1895,21 @@
1823 if review is None:
1824 return JsonResponse({"error": "Review not found"}, status=404)
1825
1826 if review.status == "merged":
1827 return JsonResponse({"error": "Review is already merged"}, status=409)
 
 
 
 
 
 
 
 
 
 
 
1828
1829 review.status = "approved"
1830 review.save(update_fields=["status", "updated_at", "version"])
1831
1832 return JsonResponse({"id": review.pk, "status": review.status})
1833
1834 DDED fossil/github_api.py
1835 DDED fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py
--- fossil/api_views.py
+++ fossil/api_views.py
@@ -23,11 +23,11 @@
23 from django.views.decorators.http import require_GET
24
25 from fossil.api_auth import authenticate_request
26 from fossil.models import FossilRepository
27 from fossil.reader import FossilReader
28 from projects.access import can_admin_project, can_read_project, can_write_project
29 from projects.models import Project
30
31 logger = logging.getLogger(__name__)
32
33
@@ -866,11 +866,10 @@
866 "name": workspace.name,
867 "branch": workspace.branch,
868 "status": workspace.status,
869 "agent_id": workspace.agent_id,
870 "description": workspace.description,
 
871 "created_at": _isoformat(workspace.created_at),
872 },
873 status=201,
874 )
875
@@ -898,11 +897,10 @@
897 "name": workspace.name,
898 "branch": workspace.branch,
899 "status": workspace.status,
900 "agent_id": workspace.agent_id,
901 "description": workspace.description,
 
902 "files_changed": workspace.files_changed,
903 "commits_made": workspace.commits_made,
904 "created_at": _isoformat(workspace.created_at),
905 "updated_at": _isoformat(workspace.updated_at),
906 }
@@ -1037,10 +1035,50 @@
1035 data = json.loads(request.body) if request.body else {}
1036 except (json.JSONDecodeError, ValueError):
1037 data = {}
1038
1039 target_branch = (data.get("target_branch") or "trunk").strip()
1040
1041 # --- Branch protection enforcement ---
1042 is_admin = user is not None and can_admin_project(user, project)
1043 if not is_admin:
1044 from fossil.branch_protection import BranchProtection
1045
1046 for protection in BranchProtection.objects.filter(repository=repo, deleted_at__isnull=True):
1047 if protection.matches_branch(target_branch):
1048 if protection.restrict_push:
1049 return JsonResponse(
1050 {"error": f"Branch '{target_branch}' is protected: only admins can merge to it"},
1051 status=403,
1052 )
1053 if protection.require_status_checks:
1054 from fossil.ci import StatusCheck
1055
1056 for context in protection.get_required_contexts_list():
1057 latest = StatusCheck.objects.filter(repository=repo, context=context).order_by("-created_at").first()
1058 if not latest or latest.state != "success":
1059 return JsonResponse(
1060 {"error": f"Required status check '{context}' has not passed"},
1061 status=403,
1062 )
1063
1064 # --- Review gate enforcement ---
1065 from fossil.code_reviews import CodeReview
1066
1067 linked_review = CodeReview.objects.filter(repository=repo, workspace=workspace).order_by("-created_at").first()
1068 if linked_review is not None:
1069 if linked_review.status != "approved":
1070 return JsonResponse(
1071 {"error": f"Linked code review is '{linked_review.status}', must be approved before merging"},
1072 status=403,
1073 )
1074 elif not is_admin:
1075 # No review exists for this workspace — require one for non-admins
1076 return JsonResponse(
1077 {"error": "No approved code review found for this workspace; submit one before merging"},
1078 status=403,
1079 )
1080
1081 from fossil.cli import FossilCLI
1082
1083 cli = FossilCLI()
1084 checkout_dir = workspace.checkout_path
@@ -1093,10 +1131,15 @@
1131
1132 workspace.status = "merged"
1133 workspace.checkout_path = ""
1134 workspace.save(update_fields=["status", "checkout_path", "updated_at", "version"])
1135
1136 # Mark the linked review as merged
1137 if linked_review is not None and linked_review.status == "approved":
1138 linked_review.status = "merged"
1139 linked_review.save(update_fields=["status", "updated_at", "version"])
1140
1141 return JsonResponse(
1142 {
1143 "name": workspace.name,
1144 "branch": workspace.branch,
1145 "status": workspace.status,
@@ -1269,16 +1312,28 @@
1312 return err
1313
1314 if token is None and (user is None or not can_write_project(user, project)):
1315 return JsonResponse({"error": "Write access required"}, status=403)
1316
1317 try:
1318 data = json.loads(request.body) if request.body else {}
1319 except (json.JSONDecodeError, ValueError):
1320 data = {}
1321
1322 agent_id = (data.get("agent_id") or "").strip()
1323 if not agent_id:
1324 return JsonResponse({"error": "agent_id is required"}, status=400)
1325
1326 from fossil.agent_claims import TicketClaim
1327
1328 claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
1329 if claim is None:
1330 return JsonResponse({"error": "No active claim for this ticket"}, status=404)
1331
1332 if claim.agent_id != agent_id:
1333 return JsonResponse({"error": "Only the claiming agent can release this ticket"}, status=403)
1334
1335 claim.status = "released"
1336 claim.released_at = timezone.now()
1337 claim.save(update_fields=["status", "released_at", "updated_at", "version"])
1338 # Soft-delete to free the unique constraint slot for future claims
1339 claim.soft_delete(user=user)
@@ -1322,16 +1377,23 @@
1377 try:
1378 data = json.loads(request.body)
1379 except (json.JSONDecodeError, ValueError):
1380 return JsonResponse({"error": "Invalid JSON body"}, status=400)
1381
1382 agent_id = (data.get("agent_id") or "").strip()
1383 if not agent_id:
1384 return JsonResponse({"error": "agent_id is required"}, status=400)
1385
1386 from fossil.agent_claims import TicketClaim
1387
1388 claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
1389 if claim is None:
1390 return JsonResponse({"error": "No active claim for this ticket"}, status=404)
1391
1392 if claim.agent_id != agent_id:
1393 return JsonResponse({"error": "Only the claiming agent can submit work for this ticket"}, status=403)
1394
1395 if claim.status != "claimed":
1396 return JsonResponse({"error": f"Claim is already {claim.status}"}, status=409)
1397
1398 summary = (data.get("summary") or "").strip()
1399 files_changed = data.get("files_changed") or []
@@ -1600,21 +1662,31 @@
1662 if workspace_name:
1663 from fossil.workspaces import AgentWorkspace
1664
1665 workspace_obj = AgentWorkspace.objects.filter(repository=repo, name=workspace_name).first()
1666
1667 # If linking to a ticket, verify the caller owns the claim
1668 ticket_uuid = (data.get("ticket_uuid") or "").strip()
1669 review_agent_id = (data.get("agent_id") or "").strip()
1670 if ticket_uuid:
1671 from fossil.agent_claims import TicketClaim
1672
1673 claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first()
1674 if claim is not None and review_agent_id and claim.agent_id != review_agent_id:
1675 return JsonResponse({"error": "Cannot create a review for a ticket claimed by another agent"}, status=403)
1676
1677 from fossil.code_reviews import CodeReview
1678
1679 review = CodeReview.objects.create(
1680 repository=repo,
1681 workspace=workspace_obj,
1682 title=title,
1683 description=data.get("description", ""),
1684 diff=diff,
1685 files_changed=data.get("files_changed", []),
1686 agent_id=review_agent_id,
1687 ticket_uuid=ticket_uuid,
1688 created_by=user,
1689 )
1690
1691 return JsonResponse(
1692 {
@@ -1823,10 +1895,21 @@
1895 if review is None:
1896 return JsonResponse({"error": "Review not found"}, status=404)
1897
1898 if review.status == "merged":
1899 return JsonResponse({"error": "Review is already merged"}, status=409)
1900
1901 # Prevent self-approval: token-based callers (agents) cannot approve their own review.
1902 # Session-auth users (human reviewers) are allowed since they represent human oversight.
1903 if token is not None and review.agent_id:
1904 try:
1905 data = json.loads(request.body) if request.body else {}
1906 except (json.JSONDecodeError, ValueError):
1907 data = {}
1908 approver_agent_id = (data.get("agent_id") or "").strip()
1909 if approver_agent_id and approver_agent_id == review.agent_id:
1910 return JsonResponse({"error": "Cannot approve your own review"}, status=403)
1911
1912 review.status = "approved"
1913 review.save(update_fields=["status", "updated_at", "version"])
1914
1915 return JsonResponse({"id": review.pk, "status": review.status})
1916
1917 DDED fossil/github_api.py
1918 DDED fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py
--- a/fossil/github_api.py
+++ b/fossil/github_api.py
@@ -0,0 +1,180 @@
1
+"""GitHub REST API client for ticket and wiki sync.
2
+
3
+Handles rate limiting (1 req/sec, exponential backoff on 403/429),
4
+owner/repo parsing from git URLs, and Issue + Contents endpoints.
5
+"""
6
+
7
+import hashlib
8
+import logging
9
+import re
10
+import time
11
+
12
+import requests
13
+
14
+logger = logging.getLogger(__name__)
15
+
16
+GITHUB_API = "https://api.github.com"
17
+
18
+
19
+def parse_github_repo(git_url: str) -> tuple[str, str] | None:
20
+ """Extract (owner, repo) from a GitHub remote URL.
21
+
22
+ Handles:
23
+ https://github.com/owner/repo.git
24
+ https://github.com/owner/repo
25
+ [email protected]:owner/repo.git
26
+ """
27
+ patterns = [
28
+ r"github\.com[/:]([^/]+)/([^/.]+?)(?:\.git)?$",
29
+ ]
30
+ for pat in patterns:
31
+ m = re.search(pat, git_url)
32
+ if m:
33
+ return m.group(1), m.group(2)
34
+ return None
35
+
36
+
37
+class GitHubClient:
38
+ """Rate-limited GitHub API client."""
39
+
40
+ def __init__(self, token: str, min_interval: float = 1.0):
41
+ self.token = token
42
+ self.min_interval = min_interval
43
+ self._last_request_at = 0.0
44
+ self.session = requests.Session()
45
+ self.session.headers.update(
46
+ {
47
+ "Authorization": f"Bearer {token}",
48
+ "Accept": "application/vnd.github+json",
49
+ "X-GitHub-Api-Version": "2022-11-28",
50
+ }
51
+ )
52
+
53
+ def _throttle(self):
54
+ elapsed = time.monotonic() - self._last_request_at
55
+ if elapsed < self.min_interval:
56
+ time.sleep(self.min_interval - elapsed)
57
+
58
+ def _request(self, method: str, path: str, max_retries: int = 3, **kwargs) -> requests.Response:
59
+ """Make a rate-limited request with exponential backoff on 403/429."""
60
+ url = f"{GITHUB_API}{path}" if path.startswith("/") else path
61
+
62
+ for attempt in range(max_retries):
63
+ self._throttle()
64
+ self._last_request_at = time.monotonic()
65
+
66
+ resp = self.session.request(method, url, timeout=30, **kwargs)
67
+
68
+ if resp.status_code in (403, 429):
69
+ retry_after = int(resp.headers.get("Retry-After", 0))
70
+ wait = max(retry_after, 2 ** (attempt + 1))
71
+ logger.warning("GitHub rate limited (%s), waiting %ds (attempt %d)", resp.status_code, wait, attempt + 1)
72
+ time.sleep(wait)
73
+ continue
74
+
75
+ return resp
76
+
77
+ return resp # return last response even if still rate-limited
78
+
79
+ def create_issue(self, owner: str, repo: str, title: str, body: str, state: str = "open") -> dict:
80
+ """Create a GitHub issue. Returns {number, url, error}."""
81
+ resp = self._request("POST", f"/repos/{owner}/{repo}/issues", json={"title": title, "body": body})
82
+
83
+ if resp.status_code == 201:
84
+ data = resp.json()
85
+ result = {"number": data["number"], "url": data["html_url"], "error": ""}
86
+ # Close if Fossil status maps to closed
87
+ if state == "closed":
88
+ self.update_issue(owner, repo, data["number"], state="closed")
89
+ return result
90
+
91
+ return {"number": 0, "url": "", "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
92
+
93
+ def update_issue(self, owner: str, repo: str, issue_number: int, title: str = "", body: str = "", state: str = "") -> dict:
94
+ """Update a GitHub issue. Returns {success, error}."""
95
+ payload = {}
96
+ if title:
97
+ payload["title"] = title
98
+ if body:
99
+ payload["body"] = body
100
+ if state:
101
+ payload["state"] = state
102
+
103
+ resp = self._request("PATCH", f"/repos/{owner}/{repo}/issues/{issue_number}", json=payload)
104
+
105
+ if resp.status_code == 200:
106
+ return {"success": True, "error": ""}
107
+ return {"success": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
108
+
109
+ def get_file_sha(self, owner: str, repo: str, path: str) -> str:
110
+ """Get the SHA of an existing file (needed for updates). Returns '' if not found."""
111
+ resp = self._request("GET", f"/repos/{owner}/{repo}/contents/{path}")
112
+ if resp.status_code == 200:
113
+ return resp.json().get("sha", "")
114
+ return ""
115
+
116
+ def create_or_update_file(self, owner: str, repo: str, path: str, content: str, message: str) -> dict:
117
+ """Create or update a file via the GitHub Contents API. Returns {success, sha, error}."""
118
+ import base64
119
+
120
+ encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
121
+ payload = {"message": message, "content": encoded}
122
+
123
+ # Check if file exists to get its SHA (required for updates)
124
+ existing_sha = self.get_file_sha(owner, repo, path)
125
+ if existing_sha:
126
+ payload["sha"] = existing_sha
127
+
128
+ resp = self._request("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=payload)
129
+
130
+ if resp.status_code in (200, 201):
131
+ data = resp.json()
132
+ return {"success": True, "sha": data.get("content", {}).get("sha", ""), "error": ""}
133
+ return {"success": False, "sha": "", "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
134
+
135
+
136
+def fossil_status_to_github(fossil_status: str) -> str:
137
+ """Map Fossil ticket status to GitHub issue state."""
138
+ closed_statuses = {"closed", "fixed", "resolved", "wontfix", "unable_to_reproduce", "works_as_designed", "deferred"}
139
+ return "closed" if fossil_status.lower().strip() in closed_statuses else "open"
140
+
141
+
142
+def format_ticket_body(ticket, comments: list[dict] | None = None) -> str:
143
+ """Format a Fossil ticket as GitHub issue body markdown."""
144
+ parts = []
145
+
146
+ if ticket.body:
147
+ parts.append(ticket.body)
148
+
149
+ # Metadata table
150
+ meta = []
151
+ if ticket.type:
152
+ meta.append(f"| Type | {ticket.type} |")
153
+ if ticket.priority:
154
+ meta.append(f"| Priority | {ticket.priority} |")
155
+ if ticket.severity:
156
+ meta.append(f"| Severity | {ticket.severity} |")
157
+ if ticket.subsystem:
158
+ meta.append(f"| Subsystem | {ticket.subsystem} |")
159
+ if ticket.resolution:
160
+ meta.append(f"| Resolution | {ticket.resolution} |")
161
+ if ticket.owner:
162
+ meta.append(f"| Owner | {ticket.owner} |")
163
+
164
+ if meta:
165
+ parts.append("\n---\n**Fossil metadata**\n\n| Field | Value |\n|---|---|\n" + "\n".join(meta))
166
+
167
+ if comments:
168
+ parts.append("\n---\n**Comments**\n")
169
+ for c in comments:
170
+ ts = c["timestamp"].strftime("%Y-%m-%d %H:%M") if c.get("timestamp") else ""
171
+ user = c.get("user", "")
172
+ parts.append(f"**{user}** ({ts}):\n> {c['comment']}\n")
173
+
174
+ parts.append(f"\n---\n*Synced from Fossil ticket `{ticket.uuid[:10]}`*")
175
+ return "\n\n".join(parts)
176
+
177
+
178
+def content_hash(text: str) -> str:
179
+ """SHA-256 hash of content for change detection."""
180
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
--- a/fossil/github_api.py
+++ b/fossil/github_api.py
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/github_api.py
+++ b/fossil/github_api.py
@@ -0,0 +1,180 @@
1 """GitHub REST API client for ticket and wiki sync.
2
3 Handles rate limiting (1 req/sec, exponential backoff on 403/429),
4 owner/repo parsing from git URLs, and Issue + Contents endpoints.
5 """
6
7 import hashlib
8 import logging
9 import re
10 import time
11
12 import requests
13
14 logger = logging.getLogger(__name__)
15
16 GITHUB_API = "https://api.github.com"
17
18
19 def parse_github_repo(git_url: str) -> tuple[str, str] | None:
20 """Extract (owner, repo) from a GitHub remote URL.
21
22 Handles:
23 https://github.com/owner/repo.git
24 https://github.com/owner/repo
25 [email protected]:owner/repo.git
26 """
27 patterns = [
28 r"github\.com[/:]([^/]+)/([^/.]+?)(?:\.git)?$",
29 ]
30 for pat in patterns:
31 m = re.search(pat, git_url)
32 if m:
33 return m.group(1), m.group(2)
34 return None
35
36
37 class GitHubClient:
38 """Rate-limited GitHub API client."""
39
40 def __init__(self, token: str, min_interval: float = 1.0):
41 self.token = token
42 self.min_interval = min_interval
43 self._last_request_at = 0.0
44 self.session = requests.Session()
45 self.session.headers.update(
46 {
47 "Authorization": f"Bearer {token}",
48 "Accept": "application/vnd.github+json",
49 "X-GitHub-Api-Version": "2022-11-28",
50 }
51 )
52
53 def _throttle(self):
54 elapsed = time.monotonic() - self._last_request_at
55 if elapsed < self.min_interval:
56 time.sleep(self.min_interval - elapsed)
57
58 def _request(self, method: str, path: str, max_retries: int = 3, **kwargs) -> requests.Response:
59 """Make a rate-limited request with exponential backoff on 403/429."""
60 url = f"{GITHUB_API}{path}" if path.startswith("/") else path
61
62 for attempt in range(max_retries):
63 self._throttle()
64 self._last_request_at = time.monotonic()
65
66 resp = self.session.request(method, url, timeout=30, **kwargs)
67
68 if resp.status_code in (403, 429):
69 retry_after = int(resp.headers.get("Retry-After", 0))
70 wait = max(retry_after, 2 ** (attempt + 1))
71 logger.warning("GitHub rate limited (%s), waiting %ds (attempt %d)", resp.status_code, wait, attempt + 1)
72 time.sleep(wait)
73 continue
74
75 return resp
76
77 return resp # return last response even if still rate-limited
78
79 def create_issue(self, owner: str, repo: str, title: str, body: str, state: str = "open") -> dict:
80 """Create a GitHub issue. Returns {number, url, error}."""
81 resp = self._request("POST", f"/repos/{owner}/{repo}/issues", json={"title": title, "body": body})
82
83 if resp.status_code == 201:
84 data = resp.json()
85 result = {"number": data["number"], "url": data["html_url"], "error": ""}
86 # Close if Fossil status maps to closed
87 if state == "closed":
88 self.update_issue(owner, repo, data["number"], state="closed")
89 return result
90
91 return {"number": 0, "url": "", "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
92
93 def update_issue(self, owner: str, repo: str, issue_number: int, title: str = "", body: str = "", state: str = "") -> dict:
94 """Update a GitHub issue. Returns {success, error}."""
95 payload = {}
96 if title:
97 payload["title"] = title
98 if body:
99 payload["body"] = body
100 if state:
101 payload["state"] = state
102
103 resp = self._request("PATCH", f"/repos/{owner}/{repo}/issues/{issue_number}", json=payload)
104
105 if resp.status_code == 200:
106 return {"success": True, "error": ""}
107 return {"success": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
108
109 def get_file_sha(self, owner: str, repo: str, path: str) -> str:
110 """Get the SHA of an existing file (needed for updates). Returns '' if not found."""
111 resp = self._request("GET", f"/repos/{owner}/{repo}/contents/{path}")
112 if resp.status_code == 200:
113 return resp.json().get("sha", "")
114 return ""
115
116 def create_or_update_file(self, owner: str, repo: str, path: str, content: str, message: str) -> dict:
117 """Create or update a file via the GitHub Contents API. Returns {success, sha, error}."""
118 import base64
119
120 encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
121 payload = {"message": message, "content": encoded}
122
123 # Check if file exists to get its SHA (required for updates)
124 existing_sha = self.get_file_sha(owner, repo, path)
125 if existing_sha:
126 payload["sha"] = existing_sha
127
128 resp = self._request("PUT", f"/repos/{owner}/{repo}/contents/{path}", json=payload)
129
130 if resp.status_code in (200, 201):
131 data = resp.json()
132 return {"success": True, "sha": data.get("content", {}).get("sha", ""), "error": ""}
133 return {"success": False, "sha": "", "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
134
135
136 def fossil_status_to_github(fossil_status: str) -> str:
137 """Map Fossil ticket status to GitHub issue state."""
138 closed_statuses = {"closed", "fixed", "resolved", "wontfix", "unable_to_reproduce", "works_as_designed", "deferred"}
139 return "closed" if fossil_status.lower().strip() in closed_statuses else "open"
140
141
142 def format_ticket_body(ticket, comments: list[dict] | None = None) -> str:
143 """Format a Fossil ticket as GitHub issue body markdown."""
144 parts = []
145
146 if ticket.body:
147 parts.append(ticket.body)
148
149 # Metadata table
150 meta = []
151 if ticket.type:
152 meta.append(f"| Type | {ticket.type} |")
153 if ticket.priority:
154 meta.append(f"| Priority | {ticket.priority} |")
155 if ticket.severity:
156 meta.append(f"| Severity | {ticket.severity} |")
157 if ticket.subsystem:
158 meta.append(f"| Subsystem | {ticket.subsystem} |")
159 if ticket.resolution:
160 meta.append(f"| Resolution | {ticket.resolution} |")
161 if ticket.owner:
162 meta.append(f"| Owner | {ticket.owner} |")
163
164 if meta:
165 parts.append("\n---\n**Fossil metadata**\n\n| Field | Value |\n|---|---|\n" + "\n".join(meta))
166
167 if comments:
168 parts.append("\n---\n**Comments**\n")
169 for c in comments:
170 ts = c["timestamp"].strftime("%Y-%m-%d %H:%M") if c.get("timestamp") else ""
171 user = c.get("user", "")
172 parts.append(f"**{user}** ({ts}):\n> {c['comment']}\n")
173
174 parts.append(f"\n---\n*Synced from Fossil ticket `{ticket.uuid[:10]}`*")
175 return "\n\n".join(parts)
176
177
178 def content_hash(text: str) -> str:
179 """SHA-256 hash of content for change detection."""
180 return hashlib.sha256(text.encode("utf-8")).hexdigest()
--- a/fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py
+++ b/fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py
@@ -0,0 +1,80 @@
1
+# Generated by Django 5.2.12 on 2026-04-07 21:05
2
+
3
+import django.db.models.deletion
4
+from django.db import migrations, models
5
+
6
+
7
+class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("fossil", "0012_alter_ticketclaim_unique_together"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.CreateModel(
14
+ name="TicketSyncMapping",
15
+ fields=[
16
+ (
17
+ "id",
18
+ models.BigAutoField(
19
+ auto_created=True,
20
+ primary_key=True,
21
+ serialize=False,
22
+ verbose_name="ID",
23
+ ),
24
+ ),
25
+ ("fossil_ticket_uuid", models.CharField(max_length=64)),
26
+ ("github_issue_number", models.PositiveIntegerField()),
27
+ (
28
+ "fossil_status",
29
+ models.CharField(blank=True, default="", max_length=50),
30
+ ),
31
+ ("last_synced_at", models.DateTimeField(auto_now=True)),
32
+ (
33
+ "mirror",
34
+ models.ForeignKey(
35
+ on_delete=django.db.models.deletion.CASCADE,
36
+ related_name="ticket_mappings",
37
+ to="fossil.gitmirror",
38
+ ),
39
+ ),
40
+ ],
41
+ options={
42
+ "unique_together": {("mirror", "fossil_ticket_uuid")},
43
+ },
44
+ ),
45
+ migrations.CreateModel(
46
+ name="WikiSyncMapping",
47
+ fields=[
48
+ (
49
+ "id",
50
+ models.BigAutoField(
51
+ auto_created=True,
52
+ primary_key=True,
53
+ serialize=False,
54
+ verbose_name="ID",
55
+ ),
56
+ ),
57
+ ("fossil_page_name", models.CharField(max_length=500)),
58
+ (
59
+ "content_hash",
60
+ models.CharField(blank=True, default="", max_length=64),
61
+ ),
62
+ (
63
+ "github_path",
64
+ models.CharField(blank=True, default="", max_length=500),
65
+ ),
66
+ ("last_synced_at", models.DateTimeField(auto_now=True)),
67
+ (
68
+ "mirror",
69
+ models.ForeignKey(
70
+ on_delete=django.db.models.deletion.CASCADE,
71
+ related_name="wiki_mappings",
72
+ to="fossil.gitmirror",
73
+ ),
74
+ ),
75
+ ],
76
+ options={
77
+ "unique_together": {("mirror", "fossil_page_name")},
78
+ },
79
+ ),
80
+ ]
--- a/fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py
+++ b/fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py
+++ b/fossil/migrations/0013_ticketsyncmapping_wikisyncmapping.py
@@ -0,0 +1,80 @@
1 # Generated by Django 5.2.12 on 2026-04-07 21:05
2
3 import django.db.models.deletion
4 from django.db import migrations, models
5
6
7 class Migration(migrations.Migration):
8 dependencies = [
9 ("fossil", "0012_alter_ticketclaim_unique_together"),
10 ]
11
12 operations = [
13 migrations.CreateModel(
14 name="TicketSyncMapping",
15 fields=[
16 (
17 "id",
18 models.BigAutoField(
19 auto_created=True,
20 primary_key=True,
21 serialize=False,
22 verbose_name="ID",
23 ),
24 ),
25 ("fossil_ticket_uuid", models.CharField(max_length=64)),
26 ("github_issue_number", models.PositiveIntegerField()),
27 (
28 "fossil_status",
29 models.CharField(blank=True, default="", max_length=50),
30 ),
31 ("last_synced_at", models.DateTimeField(auto_now=True)),
32 (
33 "mirror",
34 models.ForeignKey(
35 on_delete=django.db.models.deletion.CASCADE,
36 related_name="ticket_mappings",
37 to="fossil.gitmirror",
38 ),
39 ),
40 ],
41 options={
42 "unique_together": {("mirror", "fossil_ticket_uuid")},
43 },
44 ),
45 migrations.CreateModel(
46 name="WikiSyncMapping",
47 fields=[
48 (
49 "id",
50 models.BigAutoField(
51 auto_created=True,
52 primary_key=True,
53 serialize=False,
54 verbose_name="ID",
55 ),
56 ),
57 ("fossil_page_name", models.CharField(max_length=500)),
58 (
59 "content_hash",
60 models.CharField(blank=True, default="", max_length=64),
61 ),
62 (
63 "github_path",
64 models.CharField(blank=True, default="", max_length=500),
65 ),
66 ("last_synced_at", models.DateTimeField(auto_now=True)),
67 (
68 "mirror",
69 models.ForeignKey(
70 on_delete=django.db.models.deletion.CASCADE,
71 related_name="wiki_mappings",
72 to="fossil.gitmirror",
73 ),
74 ),
75 ],
76 options={
77 "unique_together": {("mirror", "fossil_page_name")},
78 },
79 ),
80 ]
--- fossil/migrations/__pycache__/0011_codereview_historicalcodereview_and_more.cpython-314.pyc
+++ fossil/migrations/__pycache__/0011_codereview_historicalcodereview_and_more.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/migrations/__pycache__/0011_codereview_historicalcodereview_and_more.cpython-314.pyc
+++ fossil/migrations/__pycache__/0011_codereview_historicalcodereview_and_more.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/migrations/__pycache__/0011_codereview_historicalcodereview_and_more.cpython-314.pyc
+++ fossil/migrations/__pycache__/0011_codereview_historicalcodereview_and_more.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/migrations/__pycache__/0012_alter_ticketclaim_unique_together.cpython-314.pyc
+++ fossil/migrations/__pycache__/0012_alter_ticketclaim_unique_together.cpython-314.pyc
cannot compute difference between binary files
11
--- fossil/migrations/__pycache__/0012_alter_ticketclaim_unique_together.cpython-314.pyc
+++ fossil/migrations/__pycache__/0012_alter_ticketclaim_unique_together.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/migrations/__pycache__/0012_alter_ticketclaim_unique_together.cpython-314.pyc
+++ fossil/migrations/__pycache__/0012_alter_ticketclaim_unique_together.cpython-314.pyc
0 annot compute difference between binary files
1
--- fossil/sync_models.py
+++ fossil/sync_models.py
@@ -92,5 +92,37 @@
9292
class Meta:
9393
ordering = ["-started_at"]
9494
9595
def __str__(self):
9696
return f"{self.mirror} @ {self.started_at:%Y-%m-%d %H:%M}"
97
+
98
+
99
+class TicketSyncMapping(models.Model):
100
+ """Maps a Fossil ticket UUID to a GitHub issue number for a given mirror."""
101
+
102
+ mirror = models.ForeignKey(GitMirror, on_delete=models.CASCADE, related_name="ticket_mappings")
103
+ fossil_ticket_uuid = models.CharField(max_length=64)
104
+ github_issue_number = models.PositiveIntegerField()
105
+ fossil_status = models.CharField(max_length=50, blank=True, default="")
106
+ last_synced_at = models.DateTimeField(auto_now=True)
107
+
108
+ class Meta:
109
+ unique_together = [("mirror", "fossil_ticket_uuid")]
110
+
111
+ def __str__(self):
112
+ return f"{self.fossil_ticket_uuid[:10]} → #{self.github_issue_number}"
113
+
114
+
115
+class WikiSyncMapping(models.Model):
116
+ """Maps a Fossil wiki page to its synced state for a given mirror."""
117
+
118
+ mirror = models.ForeignKey(GitMirror, on_delete=models.CASCADE, related_name="wiki_mappings")
119
+ fossil_page_name = models.CharField(max_length=500)
120
+ content_hash = models.CharField(max_length=64, blank=True, default="")
121
+ github_path = models.CharField(max_length=500, blank=True, default="")
122
+ last_synced_at = models.DateTimeField(auto_now=True)
123
+
124
+ class Meta:
125
+ unique_together = [("mirror", "fossil_page_name")]
126
+
127
+ def __str__(self):
128
+ return self.fossil_page_name
97129
--- fossil/sync_models.py
+++ fossil/sync_models.py
@@ -92,5 +92,37 @@
92 class Meta:
93 ordering = ["-started_at"]
94
95 def __str__(self):
96 return f"{self.mirror} @ {self.started_at:%Y-%m-%d %H:%M}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
--- fossil/sync_models.py
+++ fossil/sync_models.py
@@ -92,5 +92,37 @@
92 class Meta:
93 ordering = ["-started_at"]
94
95 def __str__(self):
96 return f"{self.mirror} @ {self.started_at:%Y-%m-%d %H:%M}"
97
98
99 class TicketSyncMapping(models.Model):
100 """Maps a Fossil ticket UUID to a GitHub issue number for a given mirror."""
101
102 mirror = models.ForeignKey(GitMirror, on_delete=models.CASCADE, related_name="ticket_mappings")
103 fossil_ticket_uuid = models.CharField(max_length=64)
104 github_issue_number = models.PositiveIntegerField()
105 fossil_status = models.CharField(max_length=50, blank=True, default="")
106 last_synced_at = models.DateTimeField(auto_now=True)
107
108 class Meta:
109 unique_together = [("mirror", "fossil_ticket_uuid")]
110
111 def __str__(self):
112 return f"{self.fossil_ticket_uuid[:10]} → #{self.github_issue_number}"
113
114
115 class WikiSyncMapping(models.Model):
116 """Maps a Fossil wiki page to its synced state for a given mirror."""
117
118 mirror = models.ForeignKey(GitMirror, on_delete=models.CASCADE, related_name="wiki_mappings")
119 fossil_page_name = models.CharField(max_length=500)
120 content_hash = models.CharField(max_length=64, blank=True, default="")
121 github_path = models.CharField(max_length=500, blank=True, default="")
122 last_synced_at = models.DateTimeField(auto_now=True)
123
124 class Meta:
125 unique_together = [("mirror", "fossil_page_name")]
126
127 def __str__(self):
128 return self.fossil_page_name
129
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -185,10 +185,16 @@
185185
]
186186
)
187187
188188
if result["success"]:
189189
logger.info("Git sync success for %s → %s", repo.filename, mirror.git_remote_url)
190
+
191
+ # Chain ticket and wiki sync if enabled
192
+ if mirror.sync_tickets:
193
+ sync_tickets_to_github.delay(mirror.id)
194
+ if mirror.sync_wiki:
195
+ sync_wiki_to_github.delay(mirror.id)
190196
else:
191197
logger.warning("Git sync failed for %s: %s", repo.filename, result["message"][:200])
192198
193199
except Exception:
194200
logger.exception("Git sync error for %s", repo.filename)
@@ -371,5 +377,215 @@
371377
body=entry.comment or "",
372378
url=url,
373379
)
374380
except Exception:
375381
logger.exception("Notification dispatch error for %s", repo.filename)
382
+
383
+
384
+@shared_task(name="fossil.sync_tickets_to_github")
385
+def sync_tickets_to_github(mirror_id: int):
386
+ """Sync Fossil tickets to GitHub Issues for a given mirror.
387
+
388
+ Processes up to 20 tickets per run. Resumes from where it left off
389
+ by checking TicketSyncMapping for already-synced tickets.
390
+ """
391
+ from django.utils import timezone
392
+
393
+ from fossil.github_api import GitHubClient, format_ticket_body, fossil_status_to_github, parse_github_repo
394
+ from fossil.reader import FossilReader
395
+ from fossil.sync_models import GitMirror, SyncLog, TicketSyncMapping
396
+
397
+ try:
398
+ mirror = GitMirror.objects.get(pk=mirror_id, deleted_at__isnull=True)
399
+ except GitMirror.DoesNotExist:
400
+ return
401
+
402
+ repo = mirror.repository
403
+ if not repo.exists_on_disk:
404
+ return
405
+
406
+ parsed = parse_github_repo(mirror.git_remote_url)
407
+ if not parsed:
408
+ logger.warning("Cannot parse GitHub owner/repo from %s", mirror.git_remote_url)
409
+ return
410
+
411
+ owner, repo_name = parsed
412
+ token = mirror.auth_credential
413
+ if not token:
414
+ logger.warning("No auth token for mirror %s, skipping ticket sync", mirror_id)
415
+ return
416
+
417
+ log = SyncLog.objects.create(mirror=mirror, triggered_by="ticket_sync")
418
+ client = GitHubClient(token)
419
+ synced_count = 0
420
+ errors = []
421
+
422
+ try:
423
+ with FossilReader(repo.full_path) as reader:
424
+ tickets = reader.get_tickets(limit=500)
425
+
426
+ # Get existing mappings for this mirror
427
+ existing = {m.fossil_ticket_uuid: m for m in TicketSyncMapping.objects.filter(mirror=mirror)}
428
+
429
+ processed = 0
430
+ for ticket in tickets:
431
+ if processed >= 20:
432
+ break
433
+
434
+ mapping = existing.get(ticket.uuid)
435
+
436
+ # Skip if already synced and status hasn't changed
437
+ if mapping and mapping.fossil_status == ticket.status:
438
+ continue
439
+
440
+ processed += 1
441
+
442
+ # Get full ticket detail + comments
443
+ with FossilReader(repo.full_path) as reader:
444
+ detail = reader.get_ticket_detail(ticket.uuid)
445
+ comments = reader.get_ticket_comments(ticket.uuid)
446
+
447
+ if not detail:
448
+ continue
449
+
450
+ gh_state = fossil_status_to_github(detail.status)
451
+ body = format_ticket_body(detail, comments)
452
+
453
+ if mapping:
454
+ # Update existing issue
455
+ result = client.update_issue(owner, repo_name, mapping.github_issue_number, title=detail.title, body=body, state=gh_state)
456
+ if result["success"]:
457
+ mapping.fossil_status = detail.status
458
+ mapping.save()
459
+ synced_count += 1
460
+ logger.info("Updated GitHub issue #%d for ticket %s", mapping.github_issue_number, ticket.uuid[:10])
461
+ else:
462
+ errors.append(f"Update #{mapping.github_issue_number}: {result['error']}")
463
+ else:
464
+ # Create new issue
465
+ result = client.create_issue(owner, repo_name, detail.title, body, state=gh_state)
466
+ if result["number"]:
467
+ TicketSyncMapping.objects.create(
468
+ mirror=mirror,
469
+ fossil_ticket_uuid=ticket.uuid,
470
+ github_issue_number=result["number"],
471
+ fossil_status=detail.status,
472
+ )
473
+ synced_count += 1
474
+ logger.info("Created GitHub issue #%d for ticket %s", result["number"], ticket.uuid[:10])
475
+ else:
476
+ errors.append(f"Create '{detail.title[:30]}': {result['error']}")
477
+
478
+ log.status = "success" if not errors else "failed"
479
+ log.artifacts_synced = synced_count
480
+ log.message = f"Synced {synced_count} tickets." + (f" Errors: {'; '.join(errors[:5])}" if errors else "")
481
+ log.completed_at = timezone.now()
482
+ log.save()
483
+
484
+ except Exception:
485
+ logger.exception("Ticket sync error for mirror %s", mirror_id)
486
+ log.status = "failed"
487
+ log.message = "Unexpected error during ticket sync"
488
+ log.completed_at = timezone.now()
489
+ log.save()
490
+
491
+
492
+@shared_task(name="fossil.sync_wiki_to_github")
493
+def sync_wiki_to_github(mirror_id: int):
494
+ """Sync Fossil wiki pages to a wiki/ directory in the GitHub repo.
495
+
496
+ Uses the GitHub Contents API to create/update markdown files.
497
+ Only syncs pages whose content has changed (hash comparison).
498
+ """
499
+ from django.utils import timezone
500
+
501
+ from fossil.github_api import GitHubClient, content_hash, parse_github_repo
502
+ from fossil.reader import FossilReader
503
+ from fossil.sync_models import GitMirror, SyncLog, WikiSyncMapping
504
+
505
+ try:
506
+ mirror = GitMirror.objects.get(pk=mirror_id, deleted_at__isnull=True)
507
+ except GitMirror.DoesNotExist:
508
+ return
509
+
510
+ repo = mirror.repository
511
+ if not repo.exists_on_disk:
512
+ return
513
+
514
+ parsed = parse_github_repo(mirror.git_remote_url)
515
+ if not parsed:
516
+ logger.warning("Cannot parse GitHub owner/repo from %s", mirror.git_remote_url)
517
+ return
518
+
519
+ owner, repo_name = parsed
520
+ token = mirror.auth_credential
521
+ if not token:
522
+ logger.warning("No auth token for mirror %s, skipping wiki sync", mirror_id)
523
+ return
524
+
525
+ log = SyncLog.objects.create(mirror=mirror, triggered_by="wiki_sync")
526
+ client = GitHubClient(token)
527
+ synced_count = 0
528
+ errors = []
529
+
530
+ try:
531
+ with FossilReader(repo.full_path) as reader:
532
+ pages = reader.get_wiki_pages()
533
+
534
+ existing = {m.fossil_page_name: m for m in WikiSyncMapping.objects.filter(mirror=mirror)}
535
+
536
+ for page in pages:
537
+ # Read full page content
538
+ with FossilReader(repo.full_path) as reader:
539
+ full_page = reader.get_wiki_page(page.name)
540
+
541
+ if not full_page or not full_page.content.strip():
542
+ continue
543
+
544
+ current_hash = content_hash(full_page.content)
545
+ mapping = existing.get(page.name)
546
+
547
+ # Skip if content hasn't changed
548
+ if mapping and mapping.content_hash == current_hash:
549
+ continue
550
+
551
+ # Sanitize page name for file path
552
+ safe_name = page.name.replace(" ", "-").replace("/", "-")
553
+ file_path = f"wiki/{safe_name}.md"
554
+
555
+ result = client.create_or_update_file(
556
+ owner,
557
+ repo_name,
558
+ file_path,
559
+ full_page.content,
560
+ f"Sync wiki page: {page.name}",
561
+ )
562
+
563
+ if result["success"]:
564
+ if mapping:
565
+ mapping.content_hash = current_hash
566
+ mapping.github_path = file_path
567
+ mapping.save()
568
+ else:
569
+ WikiSyncMapping.objects.create(
570
+ mirror=mirror,
571
+ fossil_page_name=page.name,
572
+ content_hash=current_hash,
573
+ github_path=file_path,
574
+ )
575
+ synced_count += 1
576
+ logger.info("Synced wiki page '%s' to %s", page.name, file_path)
577
+ else:
578
+ errors.append(f"'{page.name}': {result['error']}")
579
+
580
+ log.status = "success" if not errors else "failed"
581
+ log.artifacts_synced = synced_count
582
+ log.message = f"Synced {synced_count} wiki pages." + (f" Errors: {'; '.join(errors[:5])}" if errors else "")
583
+ log.completed_at = timezone.now()
584
+ log.save()
585
+
586
+ except Exception:
587
+ logger.exception("Wiki sync error for mirror %s", mirror_id)
588
+ log.status = "failed"
589
+ log.message = "Unexpected error during wiki sync"
590
+ log.completed_at = timezone.now()
591
+ log.save()
376592
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -185,10 +185,16 @@
185 ]
186 )
187
188 if result["success"]:
189 logger.info("Git sync success for %s → %s", repo.filename, mirror.git_remote_url)
 
 
 
 
 
 
190 else:
191 logger.warning("Git sync failed for %s: %s", repo.filename, result["message"][:200])
192
193 except Exception:
194 logger.exception("Git sync error for %s", repo.filename)
@@ -371,5 +377,215 @@
371 body=entry.comment or "",
372 url=url,
373 )
374 except Exception:
375 logger.exception("Notification dispatch error for %s", repo.filename)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -185,10 +185,16 @@
185 ]
186 )
187
188 if result["success"]:
189 logger.info("Git sync success for %s → %s", repo.filename, mirror.git_remote_url)
190
191 # Chain ticket and wiki sync if enabled
192 if mirror.sync_tickets:
193 sync_tickets_to_github.delay(mirror.id)
194 if mirror.sync_wiki:
195 sync_wiki_to_github.delay(mirror.id)
196 else:
197 logger.warning("Git sync failed for %s: %s", repo.filename, result["message"][:200])
198
199 except Exception:
200 logger.exception("Git sync error for %s", repo.filename)
@@ -371,5 +377,215 @@
377 body=entry.comment or "",
378 url=url,
379 )
380 except Exception:
381 logger.exception("Notification dispatch error for %s", repo.filename)
382
383
384 @shared_task(name="fossil.sync_tickets_to_github")
385 def sync_tickets_to_github(mirror_id: int):
386 """Sync Fossil tickets to GitHub Issues for a given mirror.
387
388 Processes up to 20 tickets per run. Resumes from where it left off
389 by checking TicketSyncMapping for already-synced tickets.
390 """
391 from django.utils import timezone
392
393 from fossil.github_api import GitHubClient, format_ticket_body, fossil_status_to_github, parse_github_repo
394 from fossil.reader import FossilReader
395 from fossil.sync_models import GitMirror, SyncLog, TicketSyncMapping
396
397 try:
398 mirror = GitMirror.objects.get(pk=mirror_id, deleted_at__isnull=True)
399 except GitMirror.DoesNotExist:
400 return
401
402 repo = mirror.repository
403 if not repo.exists_on_disk:
404 return
405
406 parsed = parse_github_repo(mirror.git_remote_url)
407 if not parsed:
408 logger.warning("Cannot parse GitHub owner/repo from %s", mirror.git_remote_url)
409 return
410
411 owner, repo_name = parsed
412 token = mirror.auth_credential
413 if not token:
414 logger.warning("No auth token for mirror %s, skipping ticket sync", mirror_id)
415 return
416
417 log = SyncLog.objects.create(mirror=mirror, triggered_by="ticket_sync")
418 client = GitHubClient(token)
419 synced_count = 0
420 errors = []
421
422 try:
423 with FossilReader(repo.full_path) as reader:
424 tickets = reader.get_tickets(limit=500)
425
426 # Get existing mappings for this mirror
427 existing = {m.fossil_ticket_uuid: m for m in TicketSyncMapping.objects.filter(mirror=mirror)}
428
429 processed = 0
430 for ticket in tickets:
431 if processed >= 20:
432 break
433
434 mapping = existing.get(ticket.uuid)
435
436 # Skip if already synced and status hasn't changed
437 if mapping and mapping.fossil_status == ticket.status:
438 continue
439
440 processed += 1
441
442 # Get full ticket detail + comments
443 with FossilReader(repo.full_path) as reader:
444 detail = reader.get_ticket_detail(ticket.uuid)
445 comments = reader.get_ticket_comments(ticket.uuid)
446
447 if not detail:
448 continue
449
450 gh_state = fossil_status_to_github(detail.status)
451 body = format_ticket_body(detail, comments)
452
453 if mapping:
454 # Update existing issue
455 result = client.update_issue(owner, repo_name, mapping.github_issue_number, title=detail.title, body=body, state=gh_state)
456 if result["success"]:
457 mapping.fossil_status = detail.status
458 mapping.save()
459 synced_count += 1
460 logger.info("Updated GitHub issue #%d for ticket %s", mapping.github_issue_number, ticket.uuid[:10])
461 else:
462 errors.append(f"Update #{mapping.github_issue_number}: {result['error']}")
463 else:
464 # Create new issue
465 result = client.create_issue(owner, repo_name, detail.title, body, state=gh_state)
466 if result["number"]:
467 TicketSyncMapping.objects.create(
468 mirror=mirror,
469 fossil_ticket_uuid=ticket.uuid,
470 github_issue_number=result["number"],
471 fossil_status=detail.status,
472 )
473 synced_count += 1
474 logger.info("Created GitHub issue #%d for ticket %s", result["number"], ticket.uuid[:10])
475 else:
476 errors.append(f"Create '{detail.title[:30]}': {result['error']}")
477
478 log.status = "success" if not errors else "failed"
479 log.artifacts_synced = synced_count
480 log.message = f"Synced {synced_count} tickets." + (f" Errors: {'; '.join(errors[:5])}" if errors else "")
481 log.completed_at = timezone.now()
482 log.save()
483
484 except Exception:
485 logger.exception("Ticket sync error for mirror %s", mirror_id)
486 log.status = "failed"
487 log.message = "Unexpected error during ticket sync"
488 log.completed_at = timezone.now()
489 log.save()
490
491
492 @shared_task(name="fossil.sync_wiki_to_github")
493 def sync_wiki_to_github(mirror_id: int):
494 """Sync Fossil wiki pages to a wiki/ directory in the GitHub repo.
495
496 Uses the GitHub Contents API to create/update markdown files.
497 Only syncs pages whose content has changed (hash comparison).
498 """
499 from django.utils import timezone
500
501 from fossil.github_api import GitHubClient, content_hash, parse_github_repo
502 from fossil.reader import FossilReader
503 from fossil.sync_models import GitMirror, SyncLog, WikiSyncMapping
504
505 try:
506 mirror = GitMirror.objects.get(pk=mirror_id, deleted_at__isnull=True)
507 except GitMirror.DoesNotExist:
508 return
509
510 repo = mirror.repository
511 if not repo.exists_on_disk:
512 return
513
514 parsed = parse_github_repo(mirror.git_remote_url)
515 if not parsed:
516 logger.warning("Cannot parse GitHub owner/repo from %s", mirror.git_remote_url)
517 return
518
519 owner, repo_name = parsed
520 token = mirror.auth_credential
521 if not token:
522 logger.warning("No auth token for mirror %s, skipping wiki sync", mirror_id)
523 return
524
525 log = SyncLog.objects.create(mirror=mirror, triggered_by="wiki_sync")
526 client = GitHubClient(token)
527 synced_count = 0
528 errors = []
529
530 try:
531 with FossilReader(repo.full_path) as reader:
532 pages = reader.get_wiki_pages()
533
534 existing = {m.fossil_page_name: m for m in WikiSyncMapping.objects.filter(mirror=mirror)}
535
536 for page in pages:
537 # Read full page content
538 with FossilReader(repo.full_path) as reader:
539 full_page = reader.get_wiki_page(page.name)
540
541 if not full_page or not full_page.content.strip():
542 continue
543
544 current_hash = content_hash(full_page.content)
545 mapping = existing.get(page.name)
546
547 # Skip if content hasn't changed
548 if mapping and mapping.content_hash == current_hash:
549 continue
550
551 # Sanitize page name for file path
552 safe_name = page.name.replace(" ", "-").replace("/", "-")
553 file_path = f"wiki/{safe_name}.md"
554
555 result = client.create_or_update_file(
556 owner,
557 repo_name,
558 file_path,
559 full_page.content,
560 f"Sync wiki page: {page.name}",
561 )
562
563 if result["success"]:
564 if mapping:
565 mapping.content_hash = current_hash
566 mapping.github_path = file_path
567 mapping.save()
568 else:
569 WikiSyncMapping.objects.create(
570 mirror=mirror,
571 fossil_page_name=page.name,
572 content_hash=current_hash,
573 github_path=file_path,
574 )
575 synced_count += 1
576 logger.info("Synced wiki page '%s' to %s", page.name, file_path)
577 else:
578 errors.append(f"'{page.name}': {result['error']}")
579
580 log.status = "success" if not errors else "failed"
581 log.artifacts_synced = synced_count
582 log.message = f"Synced {synced_count} wiki pages." + (f" Errors: {'; '.join(errors[:5])}" if errors else "")
583 log.completed_at = timezone.now()
584 log.save()
585
586 except Exception:
587 logger.exception("Wiki sync error for mirror %s", mirror_id)
588 log.status = "failed"
589 log.message = "Unexpected error during wiki sync"
590 log.completed_at = timezone.now()
591 log.save()
592
+3 -9
--- fossil/views.py
+++ fossil/views.py
@@ -277,19 +277,13 @@
277277
return f'href="{base}/docs/{m.group(1)}"'
278278
return match.group(0)
279279
280280
html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/home(/[^"]*)"', replace_external_fossil, html)
281281
282
- # Also rewrite fossil-scm.org/forum links to our local forum
283
- def replace_external_forum(match):
284
- path = match.group(1)
285
- m = re.match(r"/forumpost/([0-9a-f]+)", path)
286
- if m:
287
- return f'href="{base}/forum/{m.group(1)}/"'
288
- return f'href="{base}/forum/"'
289
-
290
- html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/forum(/[^"]*)"', replace_external_forum, html)
282
+ # Do NOT rewrite fossil-scm.org/forum links — that's a separate Fossil
283
+ # instance. If we have it locally as a different project, the user can
284
+ # navigate there directly. Rewriting cross-repo links is fragile.
291285
return html
292286
293287
294288
def _get_repo_and_reader(slug, request=None, require="read"):
295289
"""Return (project, fossil_repo, reader) or raise 404/403.
296290
--- fossil/views.py
+++ fossil/views.py
@@ -277,19 +277,13 @@
277 return f'href="{base}/docs/{m.group(1)}"'
278 return match.group(0)
279
280 html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/home(/[^"]*)"', replace_external_fossil, html)
281
282 # Also rewrite fossil-scm.org/forum links to our local forum
283 def replace_external_forum(match):
284 path = match.group(1)
285 m = re.match(r"/forumpost/([0-9a-f]+)", path)
286 if m:
287 return f'href="{base}/forum/{m.group(1)}/"'
288 return f'href="{base}/forum/"'
289
290 html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/forum(/[^"]*)"', replace_external_forum, html)
291 return html
292
293
294 def _get_repo_and_reader(slug, request=None, require="read"):
295 """Return (project, fossil_repo, reader) or raise 404/403.
296
--- fossil/views.py
+++ fossil/views.py
@@ -277,19 +277,13 @@
277 return f'href="{base}/docs/{m.group(1)}"'
278 return match.group(0)
279
280 html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/home(/[^"]*)"', replace_external_fossil, html)
281
282 # Do NOT rewrite fossil-scm.org/forum links — that's a separate Fossil
283 # instance. If we have it locally as a different project, the user can
284 # navigate there directly. Rewriting cross-repo links is fragile.
 
 
 
 
 
 
285 return html
286
287
288 def _get_repo_and_reader(slug, request=None, require="read"):
289 """Return (project, fossil_repo, reader) or raise 404/403.
290
--- projects/__pycache__/views.cpython-314.pyc
+++ projects/__pycache__/views.cpython-314.pyc
cannot compute difference between binary files
11
--- projects/__pycache__/views.cpython-314.pyc
+++ projects/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- projects/__pycache__/views.cpython-314.pyc
+++ projects/__pycache__/views.cpython-314.pyc
0 annot compute difference between binary files
1
--- templates/accounts/profile.html
+++ templates/accounts/profile.html
@@ -1,26 +1,28 @@
11
{% extends "base.html" %}
22
{% block title %}Profile — Fossilrepo{% endblock %}
33
44
{% block content %}
55
<!-- Profile Header -->
6
-<div class="flex items-center gap-5 mb-8">
7
- <div class="flex-shrink-0 h-16 w-16 rounded-full bg-brand flex items-center justify-center text-white text-2xl font-bold">
8
- {{ user.username|make_list|first|upper }}
9
- </div>
10
- <div class="flex-1 min-w-0">
11
- <h1 class="text-2xl font-bold text-gray-100 truncate">
12
- {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user.username }}{% endif %}
13
- </h1>
14
- <div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
15
- {% if user_profile.handle %}
16
- <span>@{{ user_profile.handle }}</span>
17
- {% endif %}
18
- <span>{{ user.email }}</span>
19
- </div>
20
- </div>
21
- <div class="flex items-center gap-2">
6
+<div class="sm:flex sm:items-center gap-5 mb-8">
7
+ <div class="flex items-center gap-4 sm:gap-5 flex-1 min-w-0">
8
+ <div class="flex-shrink-0 h-16 w-16 rounded-full bg-brand flex items-center justify-center text-white text-2xl font-bold">
9
+ {{ user.username|make_list|first|upper }}
10
+ </div>
11
+ <div class="min-w-0">
12
+ <h1 class="text-2xl font-bold text-gray-100 truncate">
13
+ {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user.username }}{% endif %}
14
+ </h1>
15
+ <div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
16
+ {% if user_profile.handle %}
17
+ <span>@{{ user_profile.handle }}</span>
18
+ {% endif %}
19
+ <span>{{ user.email }}</span>
20
+ </div>
21
+ </div>
22
+ </div>
23
+ <div class="flex items-center gap-2 mt-4 sm:mt-0">
2224
<a href="{% url 'accounts:profile_edit' %}"
2325
class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-600">
2426
Edit Profile
2527
</a>
2628
<a href="{% url 'organization:user_password' username=user.username %}"
2729
--- templates/accounts/profile.html
+++ templates/accounts/profile.html
@@ -1,26 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Profile — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <!-- Profile Header -->
6 <div class="flex items-center gap-5 mb-8">
7 <div class="flex-shrink-0 h-16 w-16 rounded-full bg-brand flex items-center justify-center text-white text-2xl font-bold">
8 {{ user.username|make_list|first|upper }}
9 </div>
10 <div class="flex-1 min-w-0">
11 <h1 class="text-2xl font-bold text-gray-100 truncate">
12 {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user.username }}{% endif %}
13 </h1>
14 <div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
15 {% if user_profile.handle %}
16 <span>@{{ user_profile.handle }}</span>
17 {% endif %}
18 <span>{{ user.email }}</span>
19 </div>
20 </div>
21 <div class="flex items-center gap-2">
 
 
22 <a href="{% url 'accounts:profile_edit' %}"
23 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-600">
24 Edit Profile
25 </a>
26 <a href="{% url 'organization:user_password' username=user.username %}"
27
--- templates/accounts/profile.html
+++ templates/accounts/profile.html
@@ -1,26 +1,28 @@
1 {% extends "base.html" %}
2 {% block title %}Profile — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <!-- Profile Header -->
6 <div class="sm:flex sm:items-center gap-5 mb-8">
7 <div class="flex items-center gap-4 sm:gap-5 flex-1 min-w-0">
8 <div class="flex-shrink-0 h-16 w-16 rounded-full bg-brand flex items-center justify-center text-white text-2xl font-bold">
9 {{ user.username|make_list|first|upper }}
10 </div>
11 <div class="min-w-0">
12 <h1 class="text-2xl font-bold text-gray-100 truncate">
13 {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user.username }}{% endif %}
14 </h1>
15 <div class="flex items-center gap-3 mt-1 text-sm text-gray-400">
16 {% if user_profile.handle %}
17 <span>@{{ user_profile.handle }}</span>
18 {% endif %}
19 <span>{{ user.email }}</span>
20 </div>
21 </div>
22 </div>
23 <div class="flex items-center gap-2 mt-4 sm:mt-0">
24 <a href="{% url 'accounts:profile_edit' %}"
25 class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-600">
26 Edit Profile
27 </a>
28 <a href="{% url 'organization:user_password' username=user.username %}"
29
--- templates/accounts/ssh_keys.html
+++ templates/accounts/ssh_keys.html
@@ -1,9 +1,12 @@
11
{% extends "base.html" %}
22
{% block title %}SSH Keys — Fossilrepo{% endblock %}
33
44
{% block content %}
5
+<div class="mb-4">
6
+ <a href="{% url 'accounts:profile' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Profile</a>
7
+</div>
58
<h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1>
69
710
<div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6">
811
<h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2>
912
<form method="post" class="space-y-4">
1013
--- templates/accounts/ssh_keys.html
+++ templates/accounts/ssh_keys.html
@@ -1,9 +1,12 @@
1 {% extends "base.html" %}
2 {% block title %}SSH Keys — Fossilrepo{% endblock %}
3
4 {% block content %}
 
 
 
5 <h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1>
6
7 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6">
8 <h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2>
9 <form method="post" class="space-y-4">
10
--- templates/accounts/ssh_keys.html
+++ templates/accounts/ssh_keys.html
@@ -1,9 +1,12 @@
1 {% extends "base.html" %}
2 {% block title %}SSH Keys — Fossilrepo{% endblock %}
3
4 {% block content %}
5 <div class="mb-4">
6 <a href="{% url 'accounts:profile' %}" class="text-sm text-brand-light hover:text-brand">&larr; Back to Profile</a>
7 </div>
8 <h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1>
9
10 <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6">
11 <h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2>
12 <form method="post" class="space-y-4">
13
--- templates/base.html
+++ templates/base.html
@@ -45,10 +45,16 @@
4545
focus:border-brand focus:ring-brand sm:text-sm;
4646
}
4747
}
4848
</style>
4949
<style>
50
+ /* Focus visible outlines for keyboard navigation */
51
+ a:focus-visible, button:focus-visible, [role="button"]:focus-visible {
52
+ outline: 2px solid #DC394C;
53
+ outline-offset: 2px;
54
+ border-radius: 4px;
55
+ }
5056
/* HTMX loading indicator: hidden by default, shown when htmx is in flight */
5157
.htmx-indicator { display: none; }
5258
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-flex; }
5359
/* Spinner next to search inputs during HTMX requests */
5460
.search-wrap { position: relative; display: inline-flex; align-items: center; }
@@ -165,14 +171,14 @@
165171
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
166172
{% if messages %}
167173
<div id="messages" class="mb-4 space-y-2">
168174
{% for message in messages %}
169175
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
170
- x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}">
176
+ x-transition role="status" class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}">
171177
<div class="flex justify-between">
172178
<p class="text-sm font-medium">{{ message }}</p>
173
- <button @click="show = false" class="ml-3 text-sm font-medium underline">&times;</button>
179
+ <button @click="show = false" aria-label="Dismiss" class="ml-3 text-sm font-medium underline">&times;</button>
174180
</div>
175181
</div>
176182
{% endfor %}
177183
</div>
178184
{% endif %}
179185
--- templates/base.html
+++ templates/base.html
@@ -45,10 +45,16 @@
45 focus:border-brand focus:ring-brand sm:text-sm;
46 }
47 }
48 </style>
49 <style>
 
 
 
 
 
 
50 /* HTMX loading indicator: hidden by default, shown when htmx is in flight */
51 .htmx-indicator { display: none; }
52 .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-flex; }
53 /* Spinner next to search inputs during HTMX requests */
54 .search-wrap { position: relative; display: inline-flex; align-items: center; }
@@ -165,14 +171,14 @@
165 <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
166 {% if messages %}
167 <div id="messages" class="mb-4 space-y-2">
168 {% for message in messages %}
169 <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
170 x-transition class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}">
171 <div class="flex justify-between">
172 <p class="text-sm font-medium">{{ message }}</p>
173 <button @click="show = false" class="ml-3 text-sm font-medium underline">&times;</button>
174 </div>
175 </div>
176 {% endfor %}
177 </div>
178 {% endif %}
179
--- templates/base.html
+++ templates/base.html
@@ -45,10 +45,16 @@
45 focus:border-brand focus:ring-brand sm:text-sm;
46 }
47 }
48 </style>
49 <style>
50 /* Focus visible outlines for keyboard navigation */
51 a:focus-visible, button:focus-visible, [role="button"]:focus-visible {
52 outline: 2px solid #DC394C;
53 outline-offset: 2px;
54 border-radius: 4px;
55 }
56 /* HTMX loading indicator: hidden by default, shown when htmx is in flight */
57 .htmx-indicator { display: none; }
58 .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-flex; }
59 /* Spinner next to search inputs during HTMX requests */
60 .search-wrap { position: relative; display: inline-flex; align-items: center; }
@@ -165,14 +171,14 @@
171 <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
172 {% if messages %}
173 <div id="messages" class="mb-4 space-y-2">
174 {% for message in messages %}
175 <div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 4000)"
176 x-transition role="status" class="rounded-md p-4 {% if message.tags == 'success' %}bg-green-900/50 text-green-300 border border-green-700{% elif message.tags == 'error' %}bg-red-900/50 text-red-300 border border-red-700{% else %}bg-gray-800 text-gray-300 border border-gray-700{% endif %}">
177 <div class="flex justify-between">
178 <p class="text-sm font-medium">{{ message }}</p>
179 <button @click="show = false" aria-label="Dismiss" class="ml-3 text-sm font-medium underline">&times;</button>
180 </div>
181 </div>
182 {% endfor %}
183 </div>
184 {% endif %}
185
--- templates/dashboard.html
+++ templates/dashboard.html
@@ -86,10 +86,19 @@
8686
</div>
8787
</div>
8888
{% endfor %}
8989
</div>
9090
</div>
91
+ {% elif not system_activity_json or system_activity_json == "[]" %}
92
+ <!-- Empty state when no activity exists -->
93
+ <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
94
+ <svg class="mx-auto h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
95
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
96
+ </svg>
97
+ <h3 class="mt-2 text-sm font-semibold text-gray-300">No recent activity</h3>
98
+ <p class="mt-1 text-sm text-gray-500">Activity from your projects will appear here once repositories have checkins.</p>
99
+ </div>
91100
{% endif %}
92101
</div>
93102
94103
<!-- Sidebar -->
95104
<div class="space-y-4">
96105
--- templates/dashboard.html
+++ templates/dashboard.html
@@ -86,10 +86,19 @@
86 </div>
87 </div>
88 {% endfor %}
89 </div>
90 </div>
 
 
 
 
 
 
 
 
 
91 {% endif %}
92 </div>
93
94 <!-- Sidebar -->
95 <div class="space-y-4">
96
--- templates/dashboard.html
+++ templates/dashboard.html
@@ -86,10 +86,19 @@
86 </div>
87 </div>
88 {% endfor %}
89 </div>
90 </div>
91 {% elif not system_activity_json or system_activity_json == "[]" %}
92 <!-- Empty state when no activity exists -->
93 <div class="rounded-lg bg-gray-800 border border-gray-700 p-8 text-center">
94 <svg class="mx-auto h-12 w-12 text-gray-600" fill="none" viewBox="0 0 24 24" stroke-width="1" stroke="currentColor">
95 <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
96 </svg>
97 <h3 class="mt-2 text-sm font-semibold text-gray-300">No recent activity</h3>
98 <p class="mt-1 text-sm text-gray-500">Activity from your projects will appear here once repositories have checkins.</p>
99 </div>
100 {% endif %}
101 </div>
102
103 <!-- Sidebar -->
104 <div class="space-y-4">
105
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -1,6 +1,6 @@
1
-<nav class="flex space-x-1 border-b border-gray-700 mb-6 overflow-x-auto">
1
+<nav aria-label="Project sections" class="flex space-x-1 border-b border-gray-700 mb-6 overflow-x-auto">
22
<a href="{% url 'projects:detail' slug=project.slug %}"
33
class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'overview' %}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 %}">
44
Overview
55
</a>
66
<a href="{% url 'fossil:code' slug=project.slug %}"
77
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -1,6 +1,6 @@
1 <nav class="flex space-x-1 border-b border-gray-700 mb-6 overflow-x-auto">
2 <a href="{% url 'projects:detail' slug=project.slug %}"
3 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'overview' %}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 %}">
4 Overview
5 </a>
6 <a href="{% url 'fossil:code' slug=project.slug %}"
7
--- templates/fossil/_project_nav.html
+++ templates/fossil/_project_nav.html
@@ -1,6 +1,6 @@
1 <nav aria-label="Project sections" class="flex space-x-1 border-b border-gray-700 mb-6 overflow-x-auto">
2 <a href="{% url 'projects:detail' slug=project.slug %}"
3 class="px-4 py-2 text-sm font-medium rounded-t-md whitespace-nowrap {% if active_tab == 'overview' %}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 %}">
4 Overview
5 </a>
6 <a href="{% url 'fossil:code' slug=project.slug %}"
7
--- templates/fossil/api_token_create.html
+++ templates/fossil/api_token_create.html
@@ -47,11 +47,11 @@
4747
4848
<div>
4949
<label class="block text-sm font-medium text-gray-300 mb-1">Permissions</label>
5050
<input type="text" name="permissions" value="status:write" placeholder="status:write"
5151
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">
52
- <p class="mt-1 text-xs text-gray-500">Comma-separated permissions. Available: <code class="text-gray-400">status:write</code></p>
52
+ <p class="mt-1 text-xs text-gray-500">Comma-separated permissions. Available: <code class="text-gray-400">read</code>, <code class="text-gray-400">write</code>, <code class="text-gray-400">status:write</code> (CI status reporting only), <code class="text-gray-400">admin</code></p>
5353
</div>
5454
5555
<div>
5656
<label class="block text-sm font-medium text-gray-300 mb-1">Expiration</label>
5757
<input type="datetime-local" name="expires_at"
5858
--- templates/fossil/api_token_create.html
+++ templates/fossil/api_token_create.html
@@ -47,11 +47,11 @@
47
48 <div>
49 <label class="block text-sm font-medium text-gray-300 mb-1">Permissions</label>
50 <input type="text" name="permissions" value="status:write" placeholder="status:write"
51 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">
52 <p class="mt-1 text-xs text-gray-500">Comma-separated permissions. Available: <code class="text-gray-400">status:write</code></p>
53 </div>
54
55 <div>
56 <label class="block text-sm font-medium text-gray-300 mb-1">Expiration</label>
57 <input type="datetime-local" name="expires_at"
58
--- templates/fossil/api_token_create.html
+++ templates/fossil/api_token_create.html
@@ -47,11 +47,11 @@
47
48 <div>
49 <label class="block text-sm font-medium text-gray-300 mb-1">Permissions</label>
50 <input type="text" name="permissions" value="status:write" placeholder="status:write"
51 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">
52 <p class="mt-1 text-xs text-gray-500">Comma-separated permissions. Available: <code class="text-gray-400">read</code>, <code class="text-gray-400">write</code>, <code class="text-gray-400">status:write</code> (CI status reporting only), <code class="text-gray-400">admin</code></p>
53 </div>
54
55 <div>
56 <label class="block text-sm font-medium text-gray-300 mb-1">Expiration</label>
57 <input type="datetime-local" name="expires_at"
58
--- templates/fossil/api_token_list.html
+++ templates/fossil/api_token_list.html
@@ -9,20 +9,25 @@
99
<div>
1010
<h2 class="text-lg font-semibold text-gray-200">API Tokens</h2>
1111
<p class="text-sm text-gray-500 mt-1">Tokens for CI/CD systems and automation. Used with Bearer authentication.</p>
1212
</div>
1313
<div class="flex items-center gap-3">
14
+ <span class="search-wrap">
1415
<input type="search"
1516
name="search"
1617
value="{{ search }}"
1718
placeholder="Search tokens..."
19
+ aria-label="Search API tokens"
1820
class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
1921
hx-get="{% url 'fossil:api_tokens' slug=project.slug %}"
2022
hx-trigger="input changed delay:300ms, search"
2123
hx-target="#token-content"
2224
hx-swap="innerHTML"
23
- hx-push-url="true" />
25
+ hx-push-url="true"
26
+ hx-indicator="closest .search-wrap" />
27
+ <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
28
+ </span>
2429
<a href="{% url 'fossil:api_token_create' slug=project.slug %}"
2530
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">
2631
Generate Token
2732
</a>
2833
</div>
2934
--- templates/fossil/api_token_list.html
+++ templates/fossil/api_token_list.html
@@ -9,20 +9,25 @@
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 authentication.</p>
12 </div>
13 <div class="flex items-center gap-3">
 
14 <input type="search"
15 name="search"
16 value="{{ search }}"
17 placeholder="Search tokens..."
 
18 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
19 hx-get="{% url 'fossil:api_tokens' slug=project.slug %}"
20 hx-trigger="input changed delay:300ms, search"
21 hx-target="#token-content"
22 hx-swap="innerHTML"
23 hx-push-url="true" />
 
 
 
24 <a href="{% url 'fossil:api_token_create' slug=project.slug %}"
25 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">
26 Generate Token
27 </a>
28 </div>
29
--- templates/fossil/api_token_list.html
+++ templates/fossil/api_token_list.html
@@ -9,20 +9,25 @@
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 authentication.</p>
12 </div>
13 <div class="flex items-center gap-3">
14 <span class="search-wrap">
15 <input type="search"
16 name="search"
17 value="{{ search }}"
18 placeholder="Search tokens..."
19 aria-label="Search API tokens"
20 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
21 hx-get="{% url 'fossil:api_tokens' slug=project.slug %}"
22 hx-trigger="input changed delay:300ms, search"
23 hx-target="#token-content"
24 hx-swap="innerHTML"
25 hx-push-url="true"
26 hx-indicator="closest .search-wrap" />
27 <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
28 </span>
29 <a href="{% url 'fossil:api_token_create' slug=project.slug %}"
30 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">
31 Generate Token
32 </a>
33 </div>
34
--- templates/fossil/branch_protection_list.html
+++ templates/fossil/branch_protection_list.html
@@ -9,20 +9,25 @@
99
<div>
1010
<h2 class="text-lg font-semibold text-gray-200">Branch Protection Rules</h2>
1111
<p class="text-sm text-gray-500 mt-1">Protect branches from unreviewed changes and require CI checks to pass.</p>
1212
</div>
1313
<div class="flex items-center gap-3">
14
+ <span class="search-wrap">
1415
<input type="search"
1516
name="search"
1617
value="{{ search }}"
1718
placeholder="Search rules..."
19
+ aria-label="Search branch protection rules"
1820
class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
1921
hx-get="{% url 'fossil:branch_protections' slug=project.slug %}"
2022
hx-trigger="input changed delay:300ms, search"
2123
hx-target="#protection-content"
2224
hx-swap="innerHTML"
23
- hx-push-url="true" />
25
+ hx-push-url="true"
26
+ hx-indicator="closest .search-wrap" />
27
+ <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
28
+ </span>
2429
<a href="{% url 'fossil:branch_protection_create' slug=project.slug %}"
2530
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">
2631
Add Rule
2732
</a>
2833
</div>
2934
--- templates/fossil/branch_protection_list.html
+++ templates/fossil/branch_protection_list.html
@@ -9,20 +9,25 @@
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 <div class="flex items-center gap-3">
 
14 <input type="search"
15 name="search"
16 value="{{ search }}"
17 placeholder="Search rules..."
 
18 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
19 hx-get="{% url 'fossil:branch_protections' slug=project.slug %}"
20 hx-trigger="input changed delay:300ms, search"
21 hx-target="#protection-content"
22 hx-swap="innerHTML"
23 hx-push-url="true" />
 
 
 
24 <a href="{% url 'fossil:branch_protection_create' slug=project.slug %}"
25 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">
26 Add Rule
27 </a>
28 </div>
29
--- templates/fossil/branch_protection_list.html
+++ templates/fossil/branch_protection_list.html
@@ -9,20 +9,25 @@
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 <div class="flex items-center gap-3">
14 <span class="search-wrap">
15 <input type="search"
16 name="search"
17 value="{{ search }}"
18 placeholder="Search rules..."
19 aria-label="Search branch protection rules"
20 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
21 hx-get="{% url 'fossil:branch_protections' slug=project.slug %}"
22 hx-trigger="input changed delay:300ms, search"
23 hx-target="#protection-content"
24 hx-swap="innerHTML"
25 hx-push-url="true"
26 hx-indicator="closest .search-wrap" />
27 <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
28 </span>
29 <a href="{% url 'fossil:branch_protection_create' slug=project.slug %}"
30 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">
31 Add Rule
32 </a>
33 </div>
34
--- templates/fossil/code_blame.html
+++ templates/fossil/code_blame.html
@@ -18,11 +18,11 @@
1818
{% block content %}
1919
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
2020
{% include "fossil/_project_nav.html" %}
2121
2222
<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
23
- <div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
23
+ <div class="px-4 py-3 border-b border-gray-700 flex flex-wrap items-center justify-between gap-2">
2424
<div class="flex items-center gap-1 text-sm font-mono">
2525
<a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a>
2626
{% for crumb in file_breadcrumbs %}
2727
<span class="text-gray-600">/</span>
2828
{% if forloop.last %}
2929
--- templates/fossil/code_blame.html
+++ templates/fossil/code_blame.html
@@ -18,11 +18,11 @@
18 {% block content %}
19 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
20 {% include "fossil/_project_nav.html" %}
21
22 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
23 <div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
24 <div class="flex items-center gap-1 text-sm font-mono">
25 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a>
26 {% for crumb in file_breadcrumbs %}
27 <span class="text-gray-600">/</span>
28 {% if forloop.last %}
29
--- templates/fossil/code_blame.html
+++ templates/fossil/code_blame.html
@@ -18,11 +18,11 @@
18 {% block content %}
19 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
20 {% include "fossil/_project_nav.html" %}
21
22 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
23 <div class="px-4 py-3 border-b border-gray-700 flex flex-wrap items-center justify-between gap-2">
24 <div class="flex items-center gap-1 text-sm font-mono">
25 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a>
26 {% for crumb in file_breadcrumbs %}
27 <span class="text-gray-600">/</span>
28 {% if forloop.last %}
29
--- templates/fossil/code_browser.html
+++ templates/fossil/code_browser.html
@@ -9,11 +9,11 @@
99
1010
<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
1111
<!-- Breadcrumb + commit bar -->
1212
<div class="px-4 py-3 border-b border-gray-700">
1313
<!-- Breadcrumbs -->
14
- <div class="flex items-center justify-between">
14
+ <div class="flex flex-wrap items-center justify-between gap-2">
1515
<div class="flex items-center gap-1 text-sm min-w-0">
1616
<a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand font-medium">{{ project.name }}</a>
1717
{% for crumb in breadcrumbs %}
1818
<span class="text-gray-600">/</span>
1919
{% if forloop.last %}
2020
--- templates/fossil/code_browser.html
+++ templates/fossil/code_browser.html
@@ -9,11 +9,11 @@
9
10 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
11 <!-- Breadcrumb + commit bar -->
12 <div class="px-4 py-3 border-b border-gray-700">
13 <!-- Breadcrumbs -->
14 <div class="flex items-center justify-between">
15 <div class="flex items-center gap-1 text-sm min-w-0">
16 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand font-medium">{{ project.name }}</a>
17 {% for crumb in breadcrumbs %}
18 <span class="text-gray-600">/</span>
19 {% if forloop.last %}
20
--- templates/fossil/code_browser.html
+++ templates/fossil/code_browser.html
@@ -9,11 +9,11 @@
9
10 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
11 <!-- Breadcrumb + commit bar -->
12 <div class="px-4 py-3 border-b border-gray-700">
13 <!-- Breadcrumbs -->
14 <div class="flex flex-wrap items-center justify-between gap-2">
15 <div class="flex items-center gap-1 text-sm min-w-0">
16 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand font-medium">{{ project.name }}</a>
17 {% for crumb in breadcrumbs %}
18 <span class="text-gray-600">/</span>
19 {% if forloop.last %}
20
--- templates/fossil/code_file.html
+++ templates/fossil/code_file.html
@@ -43,11 +43,11 @@
4343
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
4444
{% include "fossil/_project_nav.html" %}
4545
4646
<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
4747
<!-- File path breadcrumb + stats -->
48
- <div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
48
+ <div class="px-4 py-3 border-b border-gray-700 flex flex-wrap items-center justify-between gap-2">
4949
<div class="flex items-center gap-1 text-sm font-mono">
5050
<a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a>
5151
{% for crumb in file_breadcrumbs %}
5252
<span class="text-gray-600">/</span>
5353
{% if forloop.last %}
5454
--- templates/fossil/code_file.html
+++ templates/fossil/code_file.html
@@ -43,11 +43,11 @@
43 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
44 {% include "fossil/_project_nav.html" %}
45
46 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
47 <!-- File path breadcrumb + stats -->
48 <div class="px-4 py-3 border-b border-gray-700 flex items-center justify-between">
49 <div class="flex items-center gap-1 text-sm font-mono">
50 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a>
51 {% for crumb in file_breadcrumbs %}
52 <span class="text-gray-600">/</span>
53 {% if forloop.last %}
54
--- templates/fossil/code_file.html
+++ templates/fossil/code_file.html
@@ -43,11 +43,11 @@
43 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
44 {% include "fossil/_project_nav.html" %}
45
46 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
47 <!-- File path breadcrumb + stats -->
48 <div class="px-4 py-3 border-b border-gray-700 flex flex-wrap items-center justify-between gap-2">
49 <div class="flex items-center gap-1 text-sm font-mono">
50 <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a>
51 {% for crumb in file_breadcrumbs %}
52 <span class="text-gray-600">/</span>
53 {% if forloop.last %}
54
--- templates/fossil/compare.html
+++ templates/fossil/compare.html
@@ -39,15 +39,17 @@
3939
<h2 class="text-lg font-semibold text-gray-200 mb-3">Compare Checkins</h2>
4040
<form method="get" class="flex items-end gap-3">
4141
<div class="flex-1">
4242
<label class="block text-xs text-gray-500 mb-1">From (older)</label>
4343
<input type="text" name="from" value="{{ from_uuid }}" placeholder="Checkin hash..."
44
+ aria-label="From checkin hash"
4445
class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 text-sm px-3 py-2 font-mono focus:border-brand focus:ring-brand">
4546
</div>
4647
<div class="flex-1">
4748
<label class="block text-xs text-gray-500 mb-1">To (newer)</label>
4849
<input type="text" name="to" value="{{ to_uuid }}" placeholder="Checkin hash..."
50
+ aria-label="To checkin hash"
4951
class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 text-sm px-3 py-2 font-mono focus:border-brand focus:ring-brand">
5052
</div>
5153
<button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Compare</button>
5254
</form>
5355
</div>
5456
--- templates/fossil/compare.html
+++ templates/fossil/compare.html
@@ -39,15 +39,17 @@
39 <h2 class="text-lg font-semibold text-gray-200 mb-3">Compare Checkins</h2>
40 <form method="get" class="flex items-end gap-3">
41 <div class="flex-1">
42 <label class="block text-xs text-gray-500 mb-1">From (older)</label>
43 <input type="text" name="from" value="{{ from_uuid }}" placeholder="Checkin hash..."
 
44 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 text-sm px-3 py-2 font-mono focus:border-brand focus:ring-brand">
45 </div>
46 <div class="flex-1">
47 <label class="block text-xs text-gray-500 mb-1">To (newer)</label>
48 <input type="text" name="to" value="{{ to_uuid }}" placeholder="Checkin hash..."
 
49 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 text-sm px-3 py-2 font-mono focus:border-brand focus:ring-brand">
50 </div>
51 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Compare</button>
52 </form>
53 </div>
54
--- templates/fossil/compare.html
+++ templates/fossil/compare.html
@@ -39,15 +39,17 @@
39 <h2 class="text-lg font-semibold text-gray-200 mb-3">Compare Checkins</h2>
40 <form method="get" class="flex items-end gap-3">
41 <div class="flex-1">
42 <label class="block text-xs text-gray-500 mb-1">From (older)</label>
43 <input type="text" name="from" value="{{ from_uuid }}" placeholder="Checkin hash..."
44 aria-label="From checkin hash"
45 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 text-sm px-3 py-2 font-mono focus:border-brand focus:ring-brand">
46 </div>
47 <div class="flex-1">
48 <label class="block text-xs text-gray-500 mb-1">To (newer)</label>
49 <input type="text" name="to" value="{{ to_uuid }}" placeholder="Checkin hash..."
50 aria-label="To checkin hash"
51 class="w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 text-sm px-3 py-2 font-mono focus:border-brand focus:ring-brand">
52 </div>
53 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Compare</button>
54 </form>
55 </div>
56
--- templates/fossil/forum_thread.html
+++ templates/fossil/forum_thread.html
@@ -43,10 +43,11 @@
4343
<div class="mt-6">
4444
<h3 class="text-sm font-semibold text-gray-300 mb-3">Reply</h3>
4545
<form method="post" action="{% url 'fossil:forum_reply' slug=project.slug post_id=thread_uuid %}" class="space-y-3 rounded-lg bg-gray-800 p-5 border border-gray-700">
4646
{% csrf_token %}
4747
<textarea name="body" rows="6" required placeholder="Write your reply in Markdown..."
48
+ aria-label="Write a reply"
4849
class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono"></textarea>
4950
<div class="flex justify-end">
5051
<button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
5152
Post Reply
5253
</button>
5354
--- templates/fossil/forum_thread.html
+++ templates/fossil/forum_thread.html
@@ -43,10 +43,11 @@
43 <div class="mt-6">
44 <h3 class="text-sm font-semibold text-gray-300 mb-3">Reply</h3>
45 <form method="post" action="{% url 'fossil:forum_reply' slug=project.slug post_id=thread_uuid %}" class="space-y-3 rounded-lg bg-gray-800 p-5 border border-gray-700">
46 {% csrf_token %}
47 <textarea name="body" rows="6" required placeholder="Write your reply in Markdown..."
 
48 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono"></textarea>
49 <div class="flex justify-end">
50 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
51 Post Reply
52 </button>
53
--- templates/fossil/forum_thread.html
+++ templates/fossil/forum_thread.html
@@ -43,10 +43,11 @@
43 <div class="mt-6">
44 <h3 class="text-sm font-semibold text-gray-300 mb-3">Reply</h3>
45 <form method="post" action="{% url 'fossil:forum_reply' slug=project.slug post_id=thread_uuid %}" class="space-y-3 rounded-lg bg-gray-800 p-5 border border-gray-700">
46 {% csrf_token %}
47 <textarea name="body" rows="6" required placeholder="Write your reply in Markdown..."
48 aria-label="Write a reply"
49 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono"></textarea>
50 <div class="flex justify-end">
51 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">
52 Post Reply
53 </button>
54
--- templates/fossil/release_detail.html
+++ templates/fossil/release_detail.html
@@ -95,11 +95,11 @@
9595
</h3>
9696
9797
{% if assets %}
9898
<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
9999
<table class="min-w-full divide-y divide-gray-700">
100
- <thead class="bg-gray-900/50">
100
+ <thead class="bg-gray-900">
101101
<tr>
102102
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th>
103103
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th>
104104
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Downloads</th>
105105
<th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th>
106106
--- templates/fossil/release_detail.html
+++ templates/fossil/release_detail.html
@@ -95,11 +95,11 @@
95 </h3>
96
97 {% if assets %}
98 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
99 <table class="min-w-full divide-y divide-gray-700">
100 <thead class="bg-gray-900/50">
101 <tr>
102 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th>
103 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th>
104 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Downloads</th>
105 <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th>
106
--- templates/fossil/release_detail.html
+++ templates/fossil/release_detail.html
@@ -95,11 +95,11 @@
95 </h3>
96
97 {% if assets %}
98 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
99 <table class="min-w-full divide-y divide-gray-700">
100 <thead class="bg-gray-900">
101 <tr>
102 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th>
103 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th>
104 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Downloads</th>
105 <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th>
106
--- templates/fossil/search.html
+++ templates/fossil/search.html
@@ -7,10 +7,11 @@
77
{% include "fossil/_project_nav.html" %}
88
99
<form method="get" class="mb-6">
1010
<div class="flex gap-2">
1111
<input type="text" name="q" value="{{ query }}" placeholder="Search checkins, tickets, wiki..."
12
+ aria-label="Search repository"
1213
class="flex-1 rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-4 py-2"
1314
autofocus>
1415
<button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Search</button>
1516
</div>
1617
</form>
1718
--- templates/fossil/search.html
+++ templates/fossil/search.html
@@ -7,10 +7,11 @@
7 {% include "fossil/_project_nav.html" %}
8
9 <form method="get" class="mb-6">
10 <div class="flex gap-2">
11 <input type="text" name="q" value="{{ query }}" placeholder="Search checkins, tickets, wiki..."
 
12 class="flex-1 rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-4 py-2"
13 autofocus>
14 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Search</button>
15 </div>
16 </form>
17
--- templates/fossil/search.html
+++ templates/fossil/search.html
@@ -7,10 +7,11 @@
7 {% include "fossil/_project_nav.html" %}
8
9 <form method="get" class="mb-6">
10 <div class="flex gap-2">
11 <input type="text" name="q" value="{{ query }}" placeholder="Search checkins, tickets, wiki..."
12 aria-label="Search repository"
13 class="flex-1 rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm px-4 py-2"
14 autofocus>
15 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white hover:bg-brand-hover">Search</button>
16 </div>
17 </form>
18
--- templates/fossil/technote_list.html
+++ templates/fossil/technote_list.html
@@ -7,20 +7,25 @@
77
{% include "fossil/_project_nav.html" %}
88
99
<div class="flex items-center justify-between mb-6">
1010
<h2 class="text-lg font-semibold text-gray-200">Technotes</h2>
1111
<div class="flex items-center gap-3">
12
+ <span class="search-wrap">
1213
<input type="search"
1314
name="search"
1415
value="{{ search }}"
1516
placeholder="Search technotes..."
17
+ aria-label="Search technotes"
1618
class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
1719
hx-get="{% url 'fossil:technotes' slug=project.slug %}"
1820
hx-trigger="input changed delay:300ms, search"
1921
hx-target="#technote-content"
2022
hx-swap="innerHTML"
21
- hx-push-url="true" />
23
+ hx-push-url="true"
24
+ hx-indicator="closest .search-wrap" />
25
+ <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
26
+ </span>
2227
{% if has_write %}
2328
<a href="{% url 'fossil:technote_create' slug=project.slug %}"
2429
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">
2530
New Technote
2631
</a>
2732
--- templates/fossil/technote_list.html
+++ templates/fossil/technote_list.html
@@ -7,20 +7,25 @@
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="flex items-center justify-between mb-6">
10 <h2 class="text-lg font-semibold text-gray-200">Technotes</h2>
11 <div class="flex items-center gap-3">
 
12 <input type="search"
13 name="search"
14 value="{{ search }}"
15 placeholder="Search technotes..."
 
16 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
17 hx-get="{% url 'fossil:technotes' slug=project.slug %}"
18 hx-trigger="input changed delay:300ms, search"
19 hx-target="#technote-content"
20 hx-swap="innerHTML"
21 hx-push-url="true" />
 
 
 
22 {% if has_write %}
23 <a href="{% url 'fossil:technote_create' slug=project.slug %}"
24 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">
25 New Technote
26 </a>
27
--- templates/fossil/technote_list.html
+++ templates/fossil/technote_list.html
@@ -7,20 +7,25 @@
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="flex items-center justify-between mb-6">
10 <h2 class="text-lg font-semibold text-gray-200">Technotes</h2>
11 <div class="flex items-center gap-3">
12 <span class="search-wrap">
13 <input type="search"
14 name="search"
15 value="{{ search }}"
16 placeholder="Search technotes..."
17 aria-label="Search technotes"
18 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
19 hx-get="{% url 'fossil:technotes' slug=project.slug %}"
20 hx-trigger="input changed delay:300ms, search"
21 hx-target="#technote-content"
22 hx-swap="innerHTML"
23 hx-push-url="true"
24 hx-indicator="closest .search-wrap" />
25 <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
26 </span>
27 {% if has_write %}
28 <a href="{% url 'fossil:technote_create' slug=project.slug %}"
29 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">
30 New Technote
31 </a>
32
--- templates/fossil/ticket_detail.html
+++ templates/fossil/ticket_detail.html
@@ -100,10 +100,11 @@
100100
<div class="mt-6">
101101
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Add Comment</h3>
102102
<form method="post" action="{% url 'fossil:ticket_comment' slug=project.slug ticket_uuid=ticket.uuid %}" class="rounded-lg bg-gray-800 border border-gray-700 p-4">
103103
{% csrf_token %}
104104
<textarea name="comment" rows="4" required placeholder="Write a comment (Markdown supported)..."
105
+ aria-label="Add a comment"
105106
class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono"></textarea>
106107
<div class="mt-3 flex justify-end">
107108
<button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">Comment</button>
108109
</div>
109110
</form>
110111
--- templates/fossil/ticket_detail.html
+++ templates/fossil/ticket_detail.html
@@ -100,10 +100,11 @@
100 <div class="mt-6">
101 <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Add Comment</h3>
102 <form method="post" action="{% url 'fossil:ticket_comment' slug=project.slug ticket_uuid=ticket.uuid %}" class="rounded-lg bg-gray-800 border border-gray-700 p-4">
103 {% csrf_token %}
104 <textarea name="comment" rows="4" required placeholder="Write a comment (Markdown supported)..."
 
105 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono"></textarea>
106 <div class="mt-3 flex justify-end">
107 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">Comment</button>
108 </div>
109 </form>
110
--- templates/fossil/ticket_detail.html
+++ templates/fossil/ticket_detail.html
@@ -100,10 +100,11 @@
100 <div class="mt-6">
101 <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Add Comment</h3>
102 <form method="post" action="{% url 'fossil:ticket_comment' slug=project.slug ticket_uuid=ticket.uuid %}" class="rounded-lg bg-gray-800 border border-gray-700 p-4">
103 {% csrf_token %}
104 <textarea name="comment" rows="4" required placeholder="Write a comment (Markdown supported)..."
105 aria-label="Add a comment"
106 class="w-full rounded-md border-gray-700 bg-gray-900 text-gray-100 shadow-sm focus:border-brand focus:ring-brand sm:text-sm font-mono"></textarea>
107 <div class="mt-3 flex justify-end">
108 <button type="submit" class="rounded-md bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover">Comment</button>
109 </div>
110 </form>
111
--- templates/fossil/ticket_fields_list.html
+++ templates/fossil/ticket_fields_list.html
@@ -6,20 +6,25 @@
66
{% include "fossil/_project_nav.html" %}
77
88
<div class="flex items-center justify-between mb-6">
99
<h2 class="text-lg font-semibold text-gray-200">Custom Ticket Fields</h2>
1010
<div class="flex items-center gap-3">
11
+ <span class="search-wrap">
1112
<input type="search"
1213
name="search"
1314
value="{{ search }}"
1415
placeholder="Search fields..."
16
+ aria-label="Search custom fields"
1517
class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
1618
hx-get="{% url 'fossil:ticket_fields' slug=project.slug %}"
1719
hx-trigger="input changed delay:300ms, search"
1820
hx-target="#fields-content"
1921
hx-swap="innerHTML"
20
- hx-push-url="true" />
22
+ hx-push-url="true"
23
+ hx-indicator="closest .search-wrap" />
24
+ <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
25
+ </span>
2126
<a href="{% url 'fossil:ticket_field_create' slug=project.slug %}"
2227
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">
2328
Add Field
2429
</a>
2530
</div>
2631
--- templates/fossil/ticket_fields_list.html
+++ templates/fossil/ticket_fields_list.html
@@ -6,20 +6,25 @@
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">Custom Ticket Fields</h2>
10 <div class="flex items-center gap-3">
 
11 <input type="search"
12 name="search"
13 value="{{ search }}"
14 placeholder="Search fields..."
 
15 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
16 hx-get="{% url 'fossil:ticket_fields' slug=project.slug %}"
17 hx-trigger="input changed delay:300ms, search"
18 hx-target="#fields-content"
19 hx-swap="innerHTML"
20 hx-push-url="true" />
 
 
 
21 <a href="{% url 'fossil:ticket_field_create' slug=project.slug %}"
22 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">
23 Add Field
24 </a>
25 </div>
26
--- templates/fossil/ticket_fields_list.html
+++ templates/fossil/ticket_fields_list.html
@@ -6,20 +6,25 @@
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">Custom Ticket Fields</h2>
10 <div class="flex items-center gap-3">
11 <span class="search-wrap">
12 <input type="search"
13 name="search"
14 value="{{ search }}"
15 placeholder="Search fields..."
16 aria-label="Search custom fields"
17 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
18 hx-get="{% url 'fossil:ticket_fields' slug=project.slug %}"
19 hx-trigger="input changed delay:300ms, search"
20 hx-target="#fields-content"
21 hx-swap="innerHTML"
22 hx-push-url="true"
23 hx-indicator="closest .search-wrap" />
24 <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
25 </span>
26 <a href="{% url 'fossil:ticket_field_create' slug=project.slug %}"
27 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">
28 Add Field
29 </a>
30 </div>
31
--- templates/fossil/ticket_list.html
+++ templates/fossil/ticket_list.html
@@ -3,11 +3,11 @@
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
-<div class="flex items-center justify-between mb-4">
8
+<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
99
<div class="flex items-center gap-2 text-xs text-gray-500">
1010
<span>Status:</span>
1111
<a href="{% url 'fossil:tickets' slug=project.slug %}"
1212
class="rounded-full px-2.5 py-1 {% if not status_filter %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
1313
<a href="{% url 'fossil:tickets' slug=project.slug %}?status=Open"
1414
--- templates/fossil/ticket_list.html
+++ templates/fossil/ticket_list.html
@@ -3,11 +3,11 @@
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-4">
9 <div class="flex items-center gap-2 text-xs text-gray-500">
10 <span>Status:</span>
11 <a href="{% url 'fossil:tickets' slug=project.slug %}"
12 class="rounded-full px-2.5 py-1 {% if not status_filter %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
13 <a href="{% url 'fossil:tickets' slug=project.slug %}?status=Open"
14
--- templates/fossil/ticket_list.html
+++ templates/fossil/ticket_list.html
@@ -3,11 +3,11 @@
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 flex-wrap items-center justify-between gap-3 mb-4">
9 <div class="flex items-center gap-2 text-xs text-gray-500">
10 <span>Status:</span>
11 <a href="{% url 'fossil:tickets' slug=project.slug %}"
12 class="rounded-full px-2.5 py-1 {% if not status_filter %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
13 <a href="{% url 'fossil:tickets' slug=project.slug %}?status=Open"
14
--- templates/fossil/ticket_reports_list.html
+++ templates/fossil/ticket_reports_list.html
@@ -6,20 +6,25 @@
66
{% include "fossil/_project_nav.html" %}
77
88
<div class="flex items-center justify-between mb-6">
99
<h2 class="text-lg font-semibold text-gray-200">Ticket Reports</h2>
1010
<div class="flex items-center gap-3">
11
+ <span class="search-wrap">
1112
<input type="search"
1213
name="search"
1314
value="{{ search }}"
1415
placeholder="Search reports..."
16
+ aria-label="Search ticket reports"
1517
class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
1618
hx-get="{% url 'fossil:ticket_reports' slug=project.slug %}"
1719
hx-trigger="input changed delay:300ms, search"
1820
hx-target="#reports-content"
1921
hx-swap="innerHTML"
20
- hx-push-url="true" />
22
+ hx-push-url="true"
23
+ hx-indicator="closest .search-wrap" />
24
+ <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
25
+ </span>
2126
{% if can_admin %}
2227
<a href="{% url 'fossil:ticket_report_create' slug=project.slug %}"
2328
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">
2429
Create Report
2530
</a>
2631
--- templates/fossil/ticket_reports_list.html
+++ templates/fossil/ticket_reports_list.html
@@ -6,20 +6,25 @@
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">Ticket Reports</h2>
10 <div class="flex items-center gap-3">
 
11 <input type="search"
12 name="search"
13 value="{{ search }}"
14 placeholder="Search reports..."
 
15 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
16 hx-get="{% url 'fossil:ticket_reports' slug=project.slug %}"
17 hx-trigger="input changed delay:300ms, search"
18 hx-target="#reports-content"
19 hx-swap="innerHTML"
20 hx-push-url="true" />
 
 
 
21 {% if can_admin %}
22 <a href="{% url 'fossil:ticket_report_create' slug=project.slug %}"
23 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">
24 Create Report
25 </a>
26
--- templates/fossil/ticket_reports_list.html
+++ templates/fossil/ticket_reports_list.html
@@ -6,20 +6,25 @@
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">Ticket Reports</h2>
10 <div class="flex items-center gap-3">
11 <span class="search-wrap">
12 <input type="search"
13 name="search"
14 value="{{ search }}"
15 placeholder="Search reports..."
16 aria-label="Search ticket reports"
17 class="rounded-md border-gray-700 bg-gray-800 text-gray-100 shadow-sm focus:border-brand focus:ring-brand text-sm px-3 py-1.5"
18 hx-get="{% url 'fossil:ticket_reports' slug=project.slug %}"
19 hx-trigger="input changed delay:300ms, search"
20 hx-target="#reports-content"
21 hx-swap="innerHTML"
22 hx-push-url="true"
23 hx-indicator="closest .search-wrap" />
24 <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
25 </span>
26 {% if can_admin %}
27 <a href="{% url 'fossil:ticket_report_create' slug=project.slug %}"
28 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">
29 Create Report
30 </a>
31
--- templates/fossil/timeline.html
+++ templates/fossil/timeline.html
@@ -5,25 +5,25 @@
55
{% include "fossil/_live_reload.html" %}
66
<h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
77
{% include "fossil/_project_nav.html" %}
88
99
<div class="flex items-center justify-between mb-4">
10
-<div class="flex items-center gap-2 text-xs text-gray-500">
11
- <span>Filter:</span>
12
- <a href="{% url 'fossil:timeline' slug=project.slug %}"
13
- class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
14
- <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci"
15
- class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a>
16
- <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w"
17
- class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a>
18
- <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t"
19
- class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a>
20
-</div>
21
-<a href="{% url 'fossil:timeline_rss' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light flex items-center gap-1" title="RSS Feed">
22
- <svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/></svg>
23
- RSS
24
-</a>
10
+ <div class="flex items-center gap-2 text-xs text-gray-500">
11
+ <span>Filter:</span>
12
+ <a href="{% url 'fossil:timeline' slug=project.slug %}"
13
+ class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
14
+ <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci"
15
+ class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a>
16
+ <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w"
17
+ class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a>
18
+ <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t"
19
+ class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a>
20
+ </div>
21
+ <a href="{% url 'fossil:timeline_rss' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light flex items-center gap-1" title="RSS Feed">
22
+ <svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/></svg>
23
+ RSS
24
+ </a>
2525
</div>
2626
2727
{% include "fossil/partials/timeline_entries.html" %}
2828
2929
{% if entries|length == 50 %}
3030
--- templates/fossil/timeline.html
+++ templates/fossil/timeline.html
@@ -5,25 +5,25 @@
5 {% include "fossil/_live_reload.html" %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="flex items-center justify-between mb-4">
10 <div class="flex items-center gap-2 text-xs text-gray-500">
11 <span>Filter:</span>
12 <a href="{% url 'fossil:timeline' slug=project.slug %}"
13 class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
14 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci"
15 class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a>
16 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w"
17 class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a>
18 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t"
19 class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a>
20 </div>
21 <a href="{% url 'fossil:timeline_rss' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light flex items-center gap-1" title="RSS Feed">
22 <svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/></svg>
23 RSS
24 </a>
25 </div>
26
27 {% include "fossil/partials/timeline_entries.html" %}
28
29 {% if entries|length == 50 %}
30
--- templates/fossil/timeline.html
+++ templates/fossil/timeline.html
@@ -5,25 +5,25 @@
5 {% include "fossil/_live_reload.html" %}
6 <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1>
7 {% include "fossil/_project_nav.html" %}
8
9 <div class="flex items-center justify-between mb-4">
10 <div class="flex items-center gap-2 text-xs text-gray-500">
11 <span>Filter:</span>
12 <a href="{% url 'fossil:timeline' slug=project.slug %}"
13 class="rounded-full px-2.5 py-1 {% if not event_type %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">All</a>
14 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=ci"
15 class="rounded-full px-2.5 py-1 {% if event_type == 'ci' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Checkins</a>
16 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=w"
17 class="rounded-full px-2.5 py-1 {% if event_type == 'w' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Wiki</a>
18 <a href="{% url 'fossil:timeline' slug=project.slug %}?type=t"
19 class="rounded-full px-2.5 py-1 {% if event_type == 't' %}bg-brand text-white{% else %}bg-gray-800 text-gray-400 hover:text-white border border-gray-700{% endif %}">Tickets</a>
20 </div>
21 <a href="{% url 'fossil:timeline_rss' slug=project.slug %}" class="text-xs text-gray-500 hover:text-brand-light flex items-center gap-1" title="RSS Feed">
22 <svg class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20C5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1Z"/></svg>
23 RSS
24 </a>
25 </div>
26
27 {% include "fossil/partials/timeline_entries.html" %}
28
29 {% if entries|length == 50 %}
30
--- templates/fossil/unversioned_list.html
+++ templates/fossil/unversioned_list.html
@@ -28,11 +28,11 @@
2828
2929
<div id="unversioned-content">
3030
{% if files %}
3131
<div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
3232
<table class="min-w-full divide-y divide-gray-700">
33
- <thead class="bg-gray-900/50">
33
+ <thead class="bg-gray-900">
3434
<tr>
3535
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th>
3636
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th>
3737
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Modified</th>
3838
<th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th>
3939
--- templates/fossil/unversioned_list.html
+++ templates/fossil/unversioned_list.html
@@ -28,11 +28,11 @@
28
29 <div id="unversioned-content">
30 {% if files %}
31 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
32 <table class="min-w-full divide-y divide-gray-700">
33 <thead class="bg-gray-900/50">
34 <tr>
35 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th>
36 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th>
37 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Modified</th>
38 <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th>
39
--- templates/fossil/unversioned_list.html
+++ templates/fossil/unversioned_list.html
@@ -28,11 +28,11 @@
28
29 <div id="unversioned-content">
30 {% if files %}
31 <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700">
32 <table class="min-w-full divide-y divide-gray-700">
33 <thead class="bg-gray-900">
34 <tr>
35 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th>
36 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th>
37 <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Modified</th>
38 <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th>
39
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -1,12 +1,12 @@
11
{% load static %}
2
-<nav class="bg-gray-900 border-b border-gray-700">
2
+<nav aria-label="Main navigation" class="bg-gray-900 border-b border-gray-700">
33
<div class="px-4 sm:px-6 lg:px-8">
44
<div class="flex h-14 items-center justify-between">
55
<div class="flex items-center gap-3">
66
<!-- Mobile sidebar toggle -->
7
- <button @click="mobileSidebar = !mobileSidebar" class="lg:hidden rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800">
7
+ <button @click="mobileSidebar = !mobileSidebar" aria-label="Toggle sidebar" class="lg:hidden rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800">
88
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
99
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
1010
</svg>
1111
</button>
1212
<a href="{% url 'dashboard' %}" class="flex-shrink-0">
@@ -23,10 +23,11 @@
2323
</button>
2424
<div x-show="open" @click.outside="open = false" @keydown.escape.window="open = false" x-transition
2525
class="absolute right-0 z-20 mt-2 w-80 rounded-lg bg-gray-800 shadow-lg ring-1 ring-gray-700 p-3">
2626
<form method="get" action="{% if request.resolver_match.kwargs.slug %}/projects/{{ request.resolver_match.kwargs.slug }}/fossil/search/{% else %}/projects/fossil-scm/fossil/search/{% endif %}">
2727
<input type="text" name="q" x-ref="searchInput" placeholder="Search checkins, tickets, wiki..."
28
+ aria-label="Search checkins, tickets, wiki"
2829
class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
2930
</form>
3031
</div>
3132
</div>
3233
<!-- Theme toggle -->
@@ -41,11 +42,11 @@
4142
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
4243
</svg>
4344
</button>
4445
<!-- User menu -->
4546
<div class="relative" x-data="{ open: false }">
46
- <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white">
47
+ <button @click="open = !open" aria-label="User menu" class="flex items-center text-sm text-gray-400 hover:text-white">
4748
{{ user.get_full_name|default:user.username }}
4849
<svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
4950
</button>
5051
<div x-show="open" @click.outside="open = false" x-transition
5152
class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
5253
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -1,12 +1,12 @@
1 {% load static %}
2 <nav class="bg-gray-900 border-b border-gray-700">
3 <div class="px-4 sm:px-6 lg:px-8">
4 <div class="flex h-14 items-center justify-between">
5 <div class="flex items-center gap-3">
6 <!-- Mobile sidebar toggle -->
7 <button @click="mobileSidebar = !mobileSidebar" class="lg:hidden rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800">
8 <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
9 <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
10 </svg>
11 </button>
12 <a href="{% url 'dashboard' %}" class="flex-shrink-0">
@@ -23,10 +23,11 @@
23 </button>
24 <div x-show="open" @click.outside="open = false" @keydown.escape.window="open = false" x-transition
25 class="absolute right-0 z-20 mt-2 w-80 rounded-lg bg-gray-800 shadow-lg ring-1 ring-gray-700 p-3">
26 <form method="get" action="{% if request.resolver_match.kwargs.slug %}/projects/{{ request.resolver_match.kwargs.slug }}/fossil/search/{% else %}/projects/fossil-scm/fossil/search/{% endif %}">
27 <input type="text" name="q" x-ref="searchInput" placeholder="Search checkins, tickets, wiki..."
 
28 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
29 </form>
30 </div>
31 </div>
32 <!-- Theme toggle -->
@@ -41,11 +42,11 @@
41 <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
42 </svg>
43 </button>
44 <!-- User menu -->
45 <div class="relative" x-data="{ open: false }">
46 <button @click="open = !open" class="flex items-center text-sm text-gray-400 hover:text-white">
47 {{ user.get_full_name|default:user.username }}
48 <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
49 </button>
50 <div x-show="open" @click.outside="open = false" x-transition
51 class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
52
--- templates/includes/nav.html
+++ templates/includes/nav.html
@@ -1,12 +1,12 @@
1 {% load static %}
2 <nav aria-label="Main navigation" class="bg-gray-900 border-b border-gray-700">
3 <div class="px-4 sm:px-6 lg:px-8">
4 <div class="flex h-14 items-center justify-between">
5 <div class="flex items-center gap-3">
6 <!-- Mobile sidebar toggle -->
7 <button @click="mobileSidebar = !mobileSidebar" aria-label="Toggle sidebar" class="lg:hidden rounded-md p-2 text-gray-400 hover:text-white hover:bg-gray-800">
8 <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
9 <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
10 </svg>
11 </button>
12 <a href="{% url 'dashboard' %}" class="flex-shrink-0">
@@ -23,10 +23,11 @@
23 </button>
24 <div x-show="open" @click.outside="open = false" @keydown.escape.window="open = false" x-transition
25 class="absolute right-0 z-20 mt-2 w-80 rounded-lg bg-gray-800 shadow-lg ring-1 ring-gray-700 p-3">
26 <form method="get" action="{% if request.resolver_match.kwargs.slug %}/projects/{{ request.resolver_match.kwargs.slug }}/fossil/search/{% else %}/projects/fossil-scm/fossil/search/{% endif %}">
27 <input type="text" name="q" x-ref="searchInput" placeholder="Search checkins, tickets, wiki..."
28 aria-label="Search checkins, tickets, wiki"
29 class="w-full rounded-md border-gray-600 bg-gray-900 text-gray-100 text-sm px-3 py-2 focus:border-brand focus:ring-brand">
30 </form>
31 </div>
32 </div>
33 <!-- Theme toggle -->
@@ -41,11 +42,11 @@
42 <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
43 </svg>
44 </button>
45 <!-- User menu -->
46 <div class="relative" x-data="{ open: false }">
47 <button @click="open = !open" aria-label="User menu" class="flex items-center text-sm text-gray-400 hover:text-white">
48 {{ user.get_full_name|default:user.username }}
49 <svg class="ml-1 h-4 w-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd"/></svg>
50 </button>
51 <div x-show="open" @click.outside="open = false" x-transition
52 class="absolute right-0 z-10 mt-2 w-48 rounded-md bg-gray-800 py-1 shadow-lg ring-1 ring-gray-700">
53
--- templates/includes/nav_public.html
+++ templates/includes/nav_public.html
@@ -1,6 +1,6 @@
1
-<nav class="bg-gray-900 border-b border-gray-700">
1
+<nav aria-label="Main navigation" class="bg-gray-900 border-b border-gray-700">
22
<div class="px-4 sm:px-6 lg:px-8">
33
<div class="flex h-14 items-center justify-between">
44
<div class="flex items-center gap-6">
55
<a href="/" class="flex items-center gap-2">
66
<svg class="h-6 w-6 text-brand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
77
--- templates/includes/nav_public.html
+++ templates/includes/nav_public.html
@@ -1,6 +1,6 @@
1 <nav class="bg-gray-900 border-b border-gray-700">
2 <div class="px-4 sm:px-6 lg:px-8">
3 <div class="flex h-14 items-center justify-between">
4 <div class="flex items-center gap-6">
5 <a href="/" class="flex items-center gap-2">
6 <svg class="h-6 w-6 text-brand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
7
--- templates/includes/nav_public.html
+++ templates/includes/nav_public.html
@@ -1,6 +1,6 @@
1 <nav aria-label="Main navigation" class="bg-gray-900 border-b border-gray-700">
2 <div class="px-4 sm:px-6 lg:px-8">
3 <div class="flex h-14 items-center justify-between">
4 <div class="flex items-center gap-6">
5 <a href="/" class="flex items-center gap-2">
6 <svg class="h-6 w-6 text-brand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
7
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -1,6 +1,6 @@
1
-<aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }"
1
+<aside aria-label="Sidebar navigation" x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }"
22
:class="collapsed ? 'w-14' : 'w-60'"
33
class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
44
55
<nav class="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
66
77
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -1,6 +1,6 @@
1 <aside x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }"
2 :class="collapsed ? 'w-14' : 'w-60'"
3 class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
4
5 <nav class="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
6
7
--- templates/includes/sidebar.html
+++ templates/includes/sidebar.html
@@ -1,6 +1,6 @@
1 <aside aria-label="Sidebar navigation" x-data="{ collapsed: localStorage.getItem('sidebarCollapsed') === 'true', projectsOpen: true, docsOpen: false }"
2 :class="collapsed ? 'w-14' : 'w-60'"
3 class="hidden lg:flex lg:flex-col flex-shrink-0 bg-gray-900 border-r border-gray-700 overflow-y-auto overflow-x-hidden transition-all duration-200">
4
5 <nav class="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
6
7
--- templates/organization/team_list.html
+++ templates/organization/team_list.html
@@ -14,21 +14,24 @@
1414
New Team
1515
</a>
1616
{% endif %}
1717
</div>
1818
19
-<div class="mb-4">
19
+<div class="mb-4 search-wrap max-w-md">
2020
<input type="search"
2121
name="search"
2222
value="{{ search }}"
2323
placeholder="Search teams..."
24
- class="w-full 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"
24
+ aria-label="Search teams"
25
+ 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"
2526
hx-get="{% url 'organization:team_list' %}"
2627
hx-trigger="input changed delay:300ms, search"
2728
hx-target="#team-table"
2829
hx-swap="outerHTML"
29
- hx-push-url="true" />
30
+ hx-push-url="true"
31
+ hx-indicator="closest .search-wrap" />
32
+ <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
3033
</div>
3134
3235
{% include "organization/partials/team_table.html" %}
3336
{% include "includes/_pagination.html" %}
3437
{% endblock %}
3538
--- templates/organization/team_list.html
+++ templates/organization/team_list.html
@@ -14,21 +14,24 @@
14 New Team
15 </a>
16 {% endif %}
17 </div>
18
19 <div class="mb-4">
20 <input type="search"
21 name="search"
22 value="{{ search }}"
23 placeholder="Search teams..."
24 class="w-full 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"
 
25 hx-get="{% url 'organization:team_list' %}"
26 hx-trigger="input changed delay:300ms, search"
27 hx-target="#team-table"
28 hx-swap="outerHTML"
29 hx-push-url="true" />
 
 
30 </div>
31
32 {% include "organization/partials/team_table.html" %}
33 {% include "includes/_pagination.html" %}
34 {% endblock %}
35
--- templates/organization/team_list.html
+++ templates/organization/team_list.html
@@ -14,21 +14,24 @@
14 New Team
15 </a>
16 {% endif %}
17 </div>
18
19 <div class="mb-4 search-wrap max-w-md">
20 <input type="search"
21 name="search"
22 value="{{ search }}"
23 placeholder="Search teams..."
24 aria-label="Search teams"
25 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"
26 hx-get="{% url 'organization:team_list' %}"
27 hx-trigger="input changed delay:300ms, search"
28 hx-target="#team-table"
29 hx-swap="outerHTML"
30 hx-push-url="true"
31 hx-indicator="closest .search-wrap" />
32 <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
33 </div>
34
35 {% include "organization/partials/team_table.html" %}
36 {% include "includes/_pagination.html" %}
37 {% endblock %}
38
--- templates/pages/page_list.html
+++ templates/pages/page_list.html
@@ -10,21 +10,24 @@
1010
New Page
1111
</a>
1212
{% endif %}
1313
</div>
1414
15
-<div class="mb-4">
15
+<div class="mb-4 search-wrap max-w-md">
1616
<input type="search"
1717
name="search"
1818
value="{{ search }}"
1919
placeholder="Search knowledge base..."
20
- class="w-full 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
+ aria-label="Search knowledge base"
21
+ 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"
2122
hx-get="{% url 'pages:list' %}"
2223
hx-trigger="input changed delay:300ms, search"
2324
hx-target="#page-table"
2425
hx-swap="outerHTML"
25
- hx-push-url="true" />
26
+ hx-push-url="true"
27
+ hx-indicator="closest .search-wrap" />
28
+ <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
2629
</div>
2730
2831
{% include "pages/partials/page_table.html" %}
2932
{% include "includes/_pagination.html" %}
3033
{% endblock %}
3134
--- templates/pages/page_list.html
+++ templates/pages/page_list.html
@@ -10,21 +10,24 @@
10 New Page
11 </a>
12 {% endif %}
13 </div>
14
15 <div class="mb-4">
16 <input type="search"
17 name="search"
18 value="{{ search }}"
19 placeholder="Search knowledge base..."
20 class="w-full 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"
 
21 hx-get="{% url 'pages:list' %}"
22 hx-trigger="input changed delay:300ms, search"
23 hx-target="#page-table"
24 hx-swap="outerHTML"
25 hx-push-url="true" />
 
 
26 </div>
27
28 {% include "pages/partials/page_table.html" %}
29 {% include "includes/_pagination.html" %}
30 {% endblock %}
31
--- templates/pages/page_list.html
+++ templates/pages/page_list.html
@@ -10,21 +10,24 @@
10 New Page
11 </a>
12 {% endif %}
13 </div>
14
15 <div class="mb-4 search-wrap max-w-md">
16 <input type="search"
17 name="search"
18 value="{{ search }}"
19 placeholder="Search knowledge base..."
20 aria-label="Search knowledge base"
21 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"
22 hx-get="{% url 'pages:list' %}"
23 hx-trigger="input changed delay:300ms, search"
24 hx-target="#page-table"
25 hx-swap="outerHTML"
26 hx-push-url="true"
27 hx-indicator="closest .search-wrap" />
28 <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
29 </div>
30
31 {% include "pages/partials/page_table.html" %}
32 {% include "includes/_pagination.html" %}
33 {% endblock %}
34
--- templates/projects/explore.html
+++ templates/projects/explore.html
@@ -15,10 +15,11 @@
1515
<form method="get" action="{% url 'explore' %}" class="flex-1 flex gap-2">
1616
<input type="search"
1717
name="search"
1818
value="{{ search }}"
1919
placeholder="Search projects..."
20
+ aria-label="Search projects"
2021
class="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" />
2122
{% if sort != "stars" %}
2223
<input type="hidden" name="sort" value="{{ sort }}" />
2324
{% endif %}
2425
<button type="submit"
2526
--- templates/projects/explore.html
+++ templates/projects/explore.html
@@ -15,10 +15,11 @@
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="Search projects..."
 
20 class="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" />
21 {% if sort != "stars" %}
22 <input type="hidden" name="sort" value="{{ sort }}" />
23 {% endif %}
24 <button type="submit"
25
--- templates/projects/explore.html
+++ templates/projects/explore.html
@@ -15,10 +15,11 @@
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="Search projects..."
20 aria-label="Search projects"
21 class="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" />
22 {% if sort != "stars" %}
23 <input type="hidden" name="sort" value="{{ sort }}" />
24 {% endif %}
25 <button type="submit"
26
--- templates/projects/group_list.html
+++ templates/projects/group_list.html
@@ -10,21 +10,24 @@
1010
New Group
1111
</a>
1212
{% endif %}
1313
</div>
1414
15
-<div class="mb-4">
15
+<div class="mb-4 search-wrap max-w-md">
1616
<input type="search"
1717
name="search"
1818
value="{{ search }}"
1919
placeholder="Search groups..."
20
- class="w-full 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
+ aria-label="Search groups"
21
+ 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"
2122
hx-get="{% url 'projects:group_list' %}"
2223
hx-trigger="input changed delay:300ms, search"
2324
hx-target="#group-table"
2425
hx-swap="outerHTML"
25
- hx-push-url="true" />
26
+ hx-push-url="true"
27
+ hx-indicator="closest .search-wrap" />
28
+ <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
2629
</div>
2730
2831
{% include "projects/partials/group_table.html" %}
2932
{% include "includes/_pagination.html" %}
3033
{% endblock %}
3134
--- templates/projects/group_list.html
+++ templates/projects/group_list.html
@@ -10,21 +10,24 @@
10 New Group
11 </a>
12 {% endif %}
13 </div>
14
15 <div class="mb-4">
16 <input type="search"
17 name="search"
18 value="{{ search }}"
19 placeholder="Search groups..."
20 class="w-full 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"
 
21 hx-get="{% url 'projects:group_list' %}"
22 hx-trigger="input changed delay:300ms, search"
23 hx-target="#group-table"
24 hx-swap="outerHTML"
25 hx-push-url="true" />
 
 
26 </div>
27
28 {% include "projects/partials/group_table.html" %}
29 {% include "includes/_pagination.html" %}
30 {% endblock %}
31
--- templates/projects/group_list.html
+++ templates/projects/group_list.html
@@ -10,21 +10,24 @@
10 New Group
11 </a>
12 {% endif %}
13 </div>
14
15 <div class="mb-4 search-wrap max-w-md">
16 <input type="search"
17 name="search"
18 value="{{ search }}"
19 placeholder="Search groups..."
20 aria-label="Search groups"
21 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"
22 hx-get="{% url 'projects:group_list' %}"
23 hx-trigger="input changed delay:300ms, search"
24 hx-target="#group-table"
25 hx-swap="outerHTML"
26 hx-push-url="true"
27 hx-indicator="closest .search-wrap" />
28 <svg class="search-spinner animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
29 </div>
30
31 {% include "projects/partials/group_table.html" %}
32 {% include "includes/_pagination.html" %}
33 {% endblock %}
34
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -5,16 +5,16 @@
55
{% block extra_head %}
66
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
77
{% endblock %}
88
99
{% block content %}
10
-<div class="flex items-center justify-between mb-6">
11
- <div>
10
+<div class="sm:flex sm:items-center sm:justify-between mb-6">
11
+ <div class="mb-4 sm:mb-0">
1212
<h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
1313
<p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p>
1414
</div>
15
- <div class="flex gap-3">
15
+ <div class="flex flex-wrap gap-3">
1616
{% if user.is_authenticated %}
1717
{% include "projects/partials/star_button.html" %}
1818
<form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}">
1919
{% csrf_token %}
2020
{% if is_watching %}
2121
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -5,16 +5,16 @@
5 {% block extra_head %}
6 <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
7 {% endblock %}
8
9 {% block content %}
10 <div class="flex items-center justify-between mb-6">
11 <div>
12 <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
13 <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p>
14 </div>
15 <div class="flex gap-3">
16 {% if user.is_authenticated %}
17 {% include "projects/partials/star_button.html" %}
18 <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}">
19 {% csrf_token %}
20 {% if is_watching %}
21
--- templates/projects/project_detail.html
+++ templates/projects/project_detail.html
@@ -5,16 +5,16 @@
5 {% block extra_head %}
6 <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
7 {% endblock %}
8
9 {% block content %}
10 <div class="sm:flex sm:items-center sm:justify-between mb-6">
11 <div class="mb-4 sm:mb-0">
12 <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1>
13 <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p>
14 </div>
15 <div class="flex flex-wrap gap-3">
16 {% if user.is_authenticated %}
17 {% include "projects/partials/star_button.html" %}
18 <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}">
19 {% csrf_token %}
20 {% if is_watching %}
21
--- tests/test_agent_coordination.py
+++ tests/test_agent_coordination.py
@@ -265,10 +265,11 @@
265265
created_by=admin_user,
266266
)
267267
268268
response = admin_client.post(
269269
_api_url(sample_project.slug, "api/tickets/abc123def456/release"),
270
+ data=json.dumps({"agent_id": "claude-abc"}),
270271
content_type="application/json",
271272
)
272273
273274
assert response.status_code == 200
274275
data = response.json()
@@ -277,10 +278,42 @@
277278
278279
# Claim should be soft-deleted (not visible via default manager)
279280
assert TicketClaim.objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 0
280281
# But still in all_objects
281282
assert TicketClaim.all_objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 1
283
+
284
+ def test_release_wrong_agent_denied(self, admin_client, sample_project, fossil_repo_obj, admin_user):
285
+ """Only the claiming agent can release the claim."""
286
+ TicketClaim.objects.create(
287
+ repository=fossil_repo_obj,
288
+ ticket_uuid="abc123def456",
289
+ agent_id="claude-abc",
290
+ created_by=admin_user,
291
+ )
292
+
293
+ response = admin_client.post(
294
+ _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
295
+ data=json.dumps({"agent_id": "different-agent"}),
296
+ content_type="application/json",
297
+ )
298
+ assert response.status_code == 403
299
+ assert "claiming agent" in response.json()["error"].lower()
300
+
301
+ def test_release_requires_agent_id(self, admin_client, sample_project, fossil_repo_obj, admin_user):
302
+ """Release without agent_id returns 400."""
303
+ TicketClaim.objects.create(
304
+ repository=fossil_repo_obj,
305
+ ticket_uuid="abc123def456",
306
+ agent_id="claude-abc",
307
+ created_by=admin_user,
308
+ )
309
+
310
+ response = admin_client.post(
311
+ _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
312
+ content_type="application/json",
313
+ )
314
+ assert response.status_code == 400
282315
283316
def test_release_allows_reclaim(self, admin_client, sample_project, fossil_repo_obj, admin_user):
284317
"""After releasing, another agent can claim the ticket."""
285318
TicketClaim.objects.create(
286319
repository=fossil_repo_obj,
@@ -290,10 +323,11 @@
290323
)
291324
292325
# Release the claim
293326
admin_client.post(
294327
_api_url(sample_project.slug, "api/tickets/abc123def456/release"),
328
+ data=json.dumps({"agent_id": "claude-abc"}),
295329
content_type="application/json",
296330
)
297331
298332
# Now a new claim should succeed
299333
with patch("fossil.api_views.FossilReader") as mock_reader_cls:
@@ -311,18 +345,20 @@
311345
312346
def test_release_nonexistent_claim(self, admin_client, sample_project, fossil_repo_obj):
313347
"""Releasing when no claim exists returns 404."""
314348
response = admin_client.post(
315349
_api_url(sample_project.slug, "api/tickets/nonexistent/release"),
350
+ data=json.dumps({"agent_id": "claude-abc"}),
316351
content_type="application/json",
317352
)
318353
assert response.status_code == 404
319354
320355
def test_release_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
321356
"""Read-only users cannot release claims."""
322357
response = reader_client.post(
323358
_api_url(sample_project.slug, "api/tickets/abc123def456/release"),
359
+ data=json.dumps({"agent_id": "claude-abc"}),
324360
content_type="application/json",
325361
)
326362
assert response.status_code == 403
327363
328364
@@ -342,10 +378,11 @@
342378
343379
response = admin_client.post(
344380
_api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
345381
data=json.dumps(
346382
{
383
+ "agent_id": "claude-abc",
347384
"summary": "Fixed the null pointer bug",
348385
"files_changed": ["src/auth.py", "tests/test_auth.py"],
349386
}
350387
),
351388
content_type="application/json",
@@ -371,29 +408,62 @@
371408
created_by=admin_user,
372409
)
373410
374411
response = admin_client.post(
375412
_api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
376
- data=json.dumps({"summary": "more work"}),
413
+ data=json.dumps({"agent_id": "claude-abc", "summary": "more work"}),
377414
content_type="application/json",
378415
)
379416
assert response.status_code == 409
417
+
418
+ def test_submit_wrong_agent_denied(self, admin_client, sample_project, fossil_repo_obj, admin_user):
419
+ """Only the claiming agent can submit work."""
420
+ TicketClaim.objects.create(
421
+ repository=fossil_repo_obj,
422
+ ticket_uuid="abc123def456",
423
+ agent_id="claude-abc",
424
+ created_by=admin_user,
425
+ )
426
+
427
+ response = admin_client.post(
428
+ _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
429
+ data=json.dumps({"agent_id": "different-agent", "summary": "hijack"}),
430
+ content_type="application/json",
431
+ )
432
+ assert response.status_code == 403
433
+ assert "claiming agent" in response.json()["error"].lower()
434
+
435
+ def test_submit_requires_agent_id(self, admin_client, sample_project, fossil_repo_obj, admin_user):
436
+ """Submit without agent_id returns 400."""
437
+ TicketClaim.objects.create(
438
+ repository=fossil_repo_obj,
439
+ ticket_uuid="abc123def456",
440
+ agent_id="claude-abc",
441
+ created_by=admin_user,
442
+ )
443
+
444
+ response = admin_client.post(
445
+ _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
446
+ data=json.dumps({"summary": "some work"}),
447
+ content_type="application/json",
448
+ )
449
+ assert response.status_code == 400
380450
381451
def test_submit_no_claim(self, admin_client, sample_project, fossil_repo_obj):
382452
"""Submitting without an active claim returns 404."""
383453
response = admin_client.post(
384454
_api_url(sample_project.slug, "api/tickets/nonexistent/submit"),
385
- data=json.dumps({"summary": "some work"}),
455
+ data=json.dumps({"agent_id": "claude-abc", "summary": "some work"}),
386456
content_type="application/json",
387457
)
388458
assert response.status_code == 404
389459
390460
def test_submit_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
391461
"""Read-only users cannot submit work."""
392462
response = reader_client.post(
393463
_api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
394
- data=json.dumps({"summary": "some work"}),
464
+ data=json.dumps({"agent_id": "claude-abc", "summary": "some work"}),
395465
content_type="application/json",
396466
)
397467
assert response.status_code == 403
398468
399469
400470
--- tests/test_agent_coordination.py
+++ tests/test_agent_coordination.py
@@ -265,10 +265,11 @@
265 created_by=admin_user,
266 )
267
268 response = admin_client.post(
269 _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
 
270 content_type="application/json",
271 )
272
273 assert response.status_code == 200
274 data = response.json()
@@ -277,10 +278,42 @@
277
278 # Claim should be soft-deleted (not visible via default manager)
279 assert TicketClaim.objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 0
280 # But still in all_objects
281 assert TicketClaim.all_objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
283 def test_release_allows_reclaim(self, admin_client, sample_project, fossil_repo_obj, admin_user):
284 """After releasing, another agent can claim the ticket."""
285 TicketClaim.objects.create(
286 repository=fossil_repo_obj,
@@ -290,10 +323,11 @@
290 )
291
292 # Release the claim
293 admin_client.post(
294 _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
 
295 content_type="application/json",
296 )
297
298 # Now a new claim should succeed
299 with patch("fossil.api_views.FossilReader") as mock_reader_cls:
@@ -311,18 +345,20 @@
311
312 def test_release_nonexistent_claim(self, admin_client, sample_project, fossil_repo_obj):
313 """Releasing when no claim exists returns 404."""
314 response = admin_client.post(
315 _api_url(sample_project.slug, "api/tickets/nonexistent/release"),
 
316 content_type="application/json",
317 )
318 assert response.status_code == 404
319
320 def test_release_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
321 """Read-only users cannot release claims."""
322 response = reader_client.post(
323 _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
 
324 content_type="application/json",
325 )
326 assert response.status_code == 403
327
328
@@ -342,10 +378,11 @@
342
343 response = admin_client.post(
344 _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
345 data=json.dumps(
346 {
 
347 "summary": "Fixed the null pointer bug",
348 "files_changed": ["src/auth.py", "tests/test_auth.py"],
349 }
350 ),
351 content_type="application/json",
@@ -371,29 +408,62 @@
371 created_by=admin_user,
372 )
373
374 response = admin_client.post(
375 _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
376 data=json.dumps({"summary": "more work"}),
377 content_type="application/json",
378 )
379 assert response.status_code == 409
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
381 def test_submit_no_claim(self, admin_client, sample_project, fossil_repo_obj):
382 """Submitting without an active claim returns 404."""
383 response = admin_client.post(
384 _api_url(sample_project.slug, "api/tickets/nonexistent/submit"),
385 data=json.dumps({"summary": "some work"}),
386 content_type="application/json",
387 )
388 assert response.status_code == 404
389
390 def test_submit_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
391 """Read-only users cannot submit work."""
392 response = reader_client.post(
393 _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
394 data=json.dumps({"summary": "some work"}),
395 content_type="application/json",
396 )
397 assert response.status_code == 403
398
399
400
--- tests/test_agent_coordination.py
+++ tests/test_agent_coordination.py
@@ -265,10 +265,11 @@
265 created_by=admin_user,
266 )
267
268 response = admin_client.post(
269 _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
270 data=json.dumps({"agent_id": "claude-abc"}),
271 content_type="application/json",
272 )
273
274 assert response.status_code == 200
275 data = response.json()
@@ -277,10 +278,42 @@
278
279 # Claim should be soft-deleted (not visible via default manager)
280 assert TicketClaim.objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 0
281 # But still in all_objects
282 assert TicketClaim.all_objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 1
283
284 def test_release_wrong_agent_denied(self, admin_client, sample_project, fossil_repo_obj, admin_user):
285 """Only the claiming agent can release the claim."""
286 TicketClaim.objects.create(
287 repository=fossil_repo_obj,
288 ticket_uuid="abc123def456",
289 agent_id="claude-abc",
290 created_by=admin_user,
291 )
292
293 response = admin_client.post(
294 _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
295 data=json.dumps({"agent_id": "different-agent"}),
296 content_type="application/json",
297 )
298 assert response.status_code == 403
299 assert "claiming agent" in response.json()["error"].lower()
300
301 def test_release_requires_agent_id(self, admin_client, sample_project, fossil_repo_obj, admin_user):
302 """Release without agent_id returns 400."""
303 TicketClaim.objects.create(
304 repository=fossil_repo_obj,
305 ticket_uuid="abc123def456",
306 agent_id="claude-abc",
307 created_by=admin_user,
308 )
309
310 response = admin_client.post(
311 _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
312 content_type="application/json",
313 )
314 assert response.status_code == 400
315
316 def test_release_allows_reclaim(self, admin_client, sample_project, fossil_repo_obj, admin_user):
317 """After releasing, another agent can claim the ticket."""
318 TicketClaim.objects.create(
319 repository=fossil_repo_obj,
@@ -290,10 +323,11 @@
323 )
324
325 # Release the claim
326 admin_client.post(
327 _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
328 data=json.dumps({"agent_id": "claude-abc"}),
329 content_type="application/json",
330 )
331
332 # Now a new claim should succeed
333 with patch("fossil.api_views.FossilReader") as mock_reader_cls:
@@ -311,18 +345,20 @@
345
346 def test_release_nonexistent_claim(self, admin_client, sample_project, fossil_repo_obj):
347 """Releasing when no claim exists returns 404."""
348 response = admin_client.post(
349 _api_url(sample_project.slug, "api/tickets/nonexistent/release"),
350 data=json.dumps({"agent_id": "claude-abc"}),
351 content_type="application/json",
352 )
353 assert response.status_code == 404
354
355 def test_release_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
356 """Read-only users cannot release claims."""
357 response = reader_client.post(
358 _api_url(sample_project.slug, "api/tickets/abc123def456/release"),
359 data=json.dumps({"agent_id": "claude-abc"}),
360 content_type="application/json",
361 )
362 assert response.status_code == 403
363
364
@@ -342,10 +378,11 @@
378
379 response = admin_client.post(
380 _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
381 data=json.dumps(
382 {
383 "agent_id": "claude-abc",
384 "summary": "Fixed the null pointer bug",
385 "files_changed": ["src/auth.py", "tests/test_auth.py"],
386 }
387 ),
388 content_type="application/json",
@@ -371,29 +408,62 @@
408 created_by=admin_user,
409 )
410
411 response = admin_client.post(
412 _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
413 data=json.dumps({"agent_id": "claude-abc", "summary": "more work"}),
414 content_type="application/json",
415 )
416 assert response.status_code == 409
417
418 def test_submit_wrong_agent_denied(self, admin_client, sample_project, fossil_repo_obj, admin_user):
419 """Only the claiming agent can submit work."""
420 TicketClaim.objects.create(
421 repository=fossil_repo_obj,
422 ticket_uuid="abc123def456",
423 agent_id="claude-abc",
424 created_by=admin_user,
425 )
426
427 response = admin_client.post(
428 _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
429 data=json.dumps({"agent_id": "different-agent", "summary": "hijack"}),
430 content_type="application/json",
431 )
432 assert response.status_code == 403
433 assert "claiming agent" in response.json()["error"].lower()
434
435 def test_submit_requires_agent_id(self, admin_client, sample_project, fossil_repo_obj, admin_user):
436 """Submit without agent_id returns 400."""
437 TicketClaim.objects.create(
438 repository=fossil_repo_obj,
439 ticket_uuid="abc123def456",
440 agent_id="claude-abc",
441 created_by=admin_user,
442 )
443
444 response = admin_client.post(
445 _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
446 data=json.dumps({"summary": "some work"}),
447 content_type="application/json",
448 )
449 assert response.status_code == 400
450
451 def test_submit_no_claim(self, admin_client, sample_project, fossil_repo_obj):
452 """Submitting without an active claim returns 404."""
453 response = admin_client.post(
454 _api_url(sample_project.slug, "api/tickets/nonexistent/submit"),
455 data=json.dumps({"agent_id": "claude-abc", "summary": "some work"}),
456 content_type="application/json",
457 )
458 assert response.status_code == 404
459
460 def test_submit_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj):
461 """Read-only users cannot submit work."""
462 response = reader_client.post(
463 _api_url(sample_project.slug, "api/tickets/abc123def456/submit"),
464 data=json.dumps({"agent_id": "claude-abc", "summary": "some work"}),
465 content_type="application/json",
466 )
467 assert response.status_code == 403
468
469
470

Keyboard Shortcuts

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