FossilRepo
Implement ticket + wiki sync to GitHub: TicketSyncMapping/WikiSyncMapping models, GitHub API client with rate limiting, sync tasks chained from run_git_sync
Commit
0e40dc289c280226c87959aebb87b14b872f096fa0bebe753251d10f7591accd
Parent
7e1aaf6ba6205d3…
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 | ||
| 1 | 1 |
| --- 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 | ||
| 1 | 1 |
| --- 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 | ||
| 1 | 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_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 | ||
| 1 | 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__/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 | ||
| 1 | 1 |
| --- 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 | ||
| 1 | 1 |
| --- 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 | ||
| 1 | 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__/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 | ||
| 1 | 1 |
| --- 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 |
+2
-2
| --- fossil/api_auth.py | ||
| +++ fossil/api_auth.py | ||
| @@ -78,11 +78,11 @@ | ||
| 78 | 78 | scopes = {s.strip().lower() for s in token_scopes.split(",") if s.strip()} |
| 79 | 79 | |
| 80 | 80 | if "*" in scopes or "admin" in scopes: |
| 81 | 81 | return True |
| 82 | 82 | if required == "read": |
| 83 | - return bool(scopes & {"read", "write", "admin", "status:write"}) | |
| 83 | + return bool(scopes & {"read", "write", "admin"}) | |
| 84 | 84 | if required == "write": |
| 85 | 85 | return "write" in scopes |
| 86 | 86 | if required == "status:write": |
| 87 | - return bool(scopes & {"status:write", "write", "admin", "*"}) | |
| 87 | + return bool(scopes & {"status:write", "write"}) | |
| 88 | 88 | return False |
| 89 | 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", "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 |
+88
-5
| --- fossil/api_views.py | ||
| +++ fossil/api_views.py | ||
| @@ -23,11 +23,11 @@ | ||
| 23 | 23 | from django.views.decorators.http import require_GET |
| 24 | 24 | |
| 25 | 25 | from fossil.api_auth import authenticate_request |
| 26 | 26 | from fossil.models import FossilRepository |
| 27 | 27 | 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 | |
| 29 | 29 | from projects.models import Project |
| 30 | 30 | |
| 31 | 31 | logger = logging.getLogger(__name__) |
| 32 | 32 | |
| 33 | 33 | |
| @@ -866,11 +866,10 @@ | ||
| 866 | 866 | "name": workspace.name, |
| 867 | 867 | "branch": workspace.branch, |
| 868 | 868 | "status": workspace.status, |
| 869 | 869 | "agent_id": workspace.agent_id, |
| 870 | 870 | "description": workspace.description, |
| 871 | - "checkout_path": workspace.checkout_path, | |
| 872 | 871 | "created_at": _isoformat(workspace.created_at), |
| 873 | 872 | }, |
| 874 | 873 | status=201, |
| 875 | 874 | ) |
| 876 | 875 | |
| @@ -898,11 +897,10 @@ | ||
| 898 | 897 | "name": workspace.name, |
| 899 | 898 | "branch": workspace.branch, |
| 900 | 899 | "status": workspace.status, |
| 901 | 900 | "agent_id": workspace.agent_id, |
| 902 | 901 | "description": workspace.description, |
| 903 | - "checkout_path": workspace.checkout_path, | |
| 904 | 902 | "files_changed": workspace.files_changed, |
| 905 | 903 | "commits_made": workspace.commits_made, |
| 906 | 904 | "created_at": _isoformat(workspace.created_at), |
| 907 | 905 | "updated_at": _isoformat(workspace.updated_at), |
| 908 | 906 | } |
| @@ -1037,10 +1035,50 @@ | ||
| 1037 | 1035 | data = json.loads(request.body) if request.body else {} |
| 1038 | 1036 | except (json.JSONDecodeError, ValueError): |
| 1039 | 1037 | data = {} |
| 1040 | 1038 | |
| 1041 | 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 | + ) | |
| 1042 | 1080 | |
| 1043 | 1081 | from fossil.cli import FossilCLI |
| 1044 | 1082 | |
| 1045 | 1083 | cli = FossilCLI() |
| 1046 | 1084 | checkout_dir = workspace.checkout_path |
| @@ -1093,10 +1131,15 @@ | ||
| 1093 | 1131 | |
| 1094 | 1132 | workspace.status = "merged" |
| 1095 | 1133 | workspace.checkout_path = "" |
| 1096 | 1134 | workspace.save(update_fields=["status", "checkout_path", "updated_at", "version"]) |
| 1097 | 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 | + | |
| 1098 | 1141 | return JsonResponse( |
| 1099 | 1142 | { |
| 1100 | 1143 | "name": workspace.name, |
| 1101 | 1144 | "branch": workspace.branch, |
| 1102 | 1145 | "status": workspace.status, |
| @@ -1269,16 +1312,28 @@ | ||
| 1269 | 1312 | return err |
| 1270 | 1313 | |
| 1271 | 1314 | if token is None and (user is None or not can_write_project(user, project)): |
| 1272 | 1315 | return JsonResponse({"error": "Write access required"}, status=403) |
| 1273 | 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 | + | |
| 1274 | 1326 | from fossil.agent_claims import TicketClaim |
| 1275 | 1327 | |
| 1276 | 1328 | claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first() |
| 1277 | 1329 | if claim is None: |
| 1278 | 1330 | return JsonResponse({"error": "No active claim for this ticket"}, status=404) |
| 1279 | 1331 | |
| 1332 | + if claim.agent_id != agent_id: | |
| 1333 | + return JsonResponse({"error": "Only the claiming agent can release this ticket"}, status=403) | |
| 1334 | + | |
| 1280 | 1335 | claim.status = "released" |
| 1281 | 1336 | claim.released_at = timezone.now() |
| 1282 | 1337 | claim.save(update_fields=["status", "released_at", "updated_at", "version"]) |
| 1283 | 1338 | # Soft-delete to free the unique constraint slot for future claims |
| 1284 | 1339 | claim.soft_delete(user=user) |
| @@ -1322,16 +1377,23 @@ | ||
| 1322 | 1377 | try: |
| 1323 | 1378 | data = json.loads(request.body) |
| 1324 | 1379 | except (json.JSONDecodeError, ValueError): |
| 1325 | 1380 | return JsonResponse({"error": "Invalid JSON body"}, status=400) |
| 1326 | 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 | + | |
| 1327 | 1386 | from fossil.agent_claims import TicketClaim |
| 1328 | 1387 | |
| 1329 | 1388 | claim = TicketClaim.objects.filter(repository=repo, ticket_uuid=ticket_uuid).first() |
| 1330 | 1389 | if claim is None: |
| 1331 | 1390 | return JsonResponse({"error": "No active claim for this ticket"}, status=404) |
| 1332 | 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 | + | |
| 1333 | 1395 | if claim.status != "claimed": |
| 1334 | 1396 | return JsonResponse({"error": f"Claim is already {claim.status}"}, status=409) |
| 1335 | 1397 | |
| 1336 | 1398 | summary = (data.get("summary") or "").strip() |
| 1337 | 1399 | files_changed = data.get("files_changed") or [] |
| @@ -1600,21 +1662,31 @@ | ||
| 1600 | 1662 | if workspace_name: |
| 1601 | 1663 | from fossil.workspaces import AgentWorkspace |
| 1602 | 1664 | |
| 1603 | 1665 | workspace_obj = AgentWorkspace.objects.filter(repository=repo, name=workspace_name).first() |
| 1604 | 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 | + | |
| 1605 | 1677 | from fossil.code_reviews import CodeReview |
| 1606 | 1678 | |
| 1607 | 1679 | review = CodeReview.objects.create( |
| 1608 | 1680 | repository=repo, |
| 1609 | 1681 | workspace=workspace_obj, |
| 1610 | 1682 | title=title, |
| 1611 | 1683 | description=data.get("description", ""), |
| 1612 | 1684 | diff=diff, |
| 1613 | 1685 | 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, | |
| 1616 | 1688 | created_by=user, |
| 1617 | 1689 | ) |
| 1618 | 1690 | |
| 1619 | 1691 | return JsonResponse( |
| 1620 | 1692 | { |
| @@ -1823,10 +1895,21 @@ | ||
| 1823 | 1895 | if review is None: |
| 1824 | 1896 | return JsonResponse({"error": "Review not found"}, status=404) |
| 1825 | 1897 | |
| 1826 | 1898 | if review.status == "merged": |
| 1827 | 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) | |
| 1828 | 1911 | |
| 1829 | 1912 | review.status = "approved" |
| 1830 | 1913 | review.save(update_fields=["status", "updated_at", "version"]) |
| 1831 | 1914 | |
| 1832 | 1915 | return JsonResponse({"id": review.pk, "status": review.status}) |
| 1833 | 1916 | |
| 1834 | 1917 | ADDED fossil/github_api.py |
| 1835 | 1918 | 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 |
+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/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 | ||
| 1 | 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__/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 | ||
| 1 | 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/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 |
+32
| --- fossil/sync_models.py | ||
| +++ fossil/sync_models.py | ||
| @@ -92,5 +92,37 @@ | ||
| 92 | 92 | class Meta: |
| 93 | 93 | ordering = ["-started_at"] |
| 94 | 94 | |
| 95 | 95 | def __str__(self): |
| 96 | 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 | |
| 97 | 129 |
| --- 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 |
+216
| --- fossil/tasks.py | ||
| +++ fossil/tasks.py | ||
| @@ -185,10 +185,16 @@ | ||
| 185 | 185 | ] |
| 186 | 186 | ) |
| 187 | 187 | |
| 188 | 188 | if result["success"]: |
| 189 | 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) | |
| 190 | 196 | else: |
| 191 | 197 | logger.warning("Git sync failed for %s: %s", repo.filename, result["message"][:200]) |
| 192 | 198 | |
| 193 | 199 | except Exception: |
| 194 | 200 | logger.exception("Git sync error for %s", repo.filename) |
| @@ -371,5 +377,215 @@ | ||
| 371 | 377 | body=entry.comment or "", |
| 372 | 378 | url=url, |
| 373 | 379 | ) |
| 374 | 380 | except Exception: |
| 375 | 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() | |
| 376 | 592 |
| --- 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 @@ | ||
| 277 | 277 | return f'href="{base}/docs/{m.group(1)}"' |
| 278 | 278 | return match.group(0) |
| 279 | 279 | |
| 280 | 280 | html = re.sub(r'href="https?://(?:www\.)?fossil-scm\.org/home(/[^"]*)"', replace_external_fossil, html) |
| 281 | 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) | |
| 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. | |
| 291 | 285 | return html |
| 292 | 286 | |
| 293 | 287 | |
| 294 | 288 | def _get_repo_and_reader(slug, request=None, require="read"): |
| 295 | 289 | """Return (project, fossil_repo, reader) or raise 404/403. |
| 296 | 290 |
| --- 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 | ||
| 1 | 1 |
| --- 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 |
+18
-16
| --- templates/accounts/profile.html | ||
| +++ templates/accounts/profile.html | ||
| @@ -1,26 +1,28 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | 2 | {% block title %}Profile — Fossilrepo{% endblock %} |
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | 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"> | |
| 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"> | |
| 22 | 24 | <a href="{% url 'accounts:profile_edit' %}" |
| 23 | 25 | class="rounded-md bg-gray-700 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-gray-600"> |
| 24 | 26 | Edit Profile |
| 25 | 27 | </a> |
| 26 | 28 | <a href="{% url 'organization:user_password' username=user.username %}" |
| 27 | 29 |
| --- 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 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | 2 | {% block title %}SSH Keys — Fossilrepo{% endblock %} |
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | +<div class="mb-4"> | |
| 6 | + <a href="{% url 'accounts:profile' %}" class="text-sm text-brand-light hover:text-brand">← Back to Profile</a> | |
| 7 | +</div> | |
| 5 | 8 | <h1 class="text-2xl font-bold text-gray-100 mb-6">SSH Keys</h1> |
| 6 | 9 | |
| 7 | 10 | <div class="rounded-lg bg-gray-800 border border-gray-700 p-6 mb-6"> |
| 8 | 11 | <h2 class="text-lg font-semibold text-gray-200 mb-4">Add SSH Key</h2> |
| 9 | 12 | <form method="post" class="space-y-4"> |
| 10 | 13 |
| --- 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">← 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 |
+8
-2
| --- templates/base.html | ||
| +++ templates/base.html | ||
| @@ -45,10 +45,16 @@ | ||
| 45 | 45 | focus:border-brand focus:ring-brand sm:text-sm; |
| 46 | 46 | } |
| 47 | 47 | } |
| 48 | 48 | </style> |
| 49 | 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 | + } | |
| 50 | 56 | /* HTMX loading indicator: hidden by default, shown when htmx is in flight */ |
| 51 | 57 | .htmx-indicator { display: none; } |
| 52 | 58 | .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline-flex; } |
| 53 | 59 | /* Spinner next to search inputs during HTMX requests */ |
| 54 | 60 | .search-wrap { position: relative; display: inline-flex; align-items: center; } |
| @@ -165,14 +171,14 @@ | ||
| 165 | 171 | <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> |
| 166 | 172 | {% if messages %} |
| 167 | 173 | <div id="messages" class="mb-4 space-y-2"> |
| 168 | 174 | {% for message in messages %} |
| 169 | 175 | <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 %}"> | |
| 171 | 177 | <div class="flex justify-between"> |
| 172 | 178 | <p class="text-sm font-medium">{{ message }}</p> |
| 173 | - <button @click="show = false" class="ml-3 text-sm font-medium underline">×</button> | |
| 179 | + <button @click="show = false" aria-label="Dismiss" class="ml-3 text-sm font-medium underline">×</button> | |
| 174 | 180 | </div> |
| 175 | 181 | </div> |
| 176 | 182 | {% endfor %} |
| 177 | 183 | </div> |
| 178 | 184 | {% endif %} |
| 179 | 185 |
| --- 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">×</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">×</button> |
| 180 | </div> |
| 181 | </div> |
| 182 | {% endfor %} |
| 183 | </div> |
| 184 | {% endif %} |
| 185 |
| --- templates/dashboard.html | ||
| +++ templates/dashboard.html | ||
| @@ -86,10 +86,19 @@ | ||
| 86 | 86 | </div> |
| 87 | 87 | </div> |
| 88 | 88 | {% endfor %} |
| 89 | 89 | </div> |
| 90 | 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> | |
| 91 | 100 | {% endif %} |
| 92 | 101 | </div> |
| 93 | 102 | |
| 94 | 103 | <!-- Sidebar --> |
| 95 | 104 | <div class="space-y-4"> |
| 96 | 105 |
| --- 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"> | |
| 2 | 2 | <a href="{% url 'projects:detail' slug=project.slug %}" |
| 3 | 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 | 4 | Overview |
| 5 | 5 | </a> |
| 6 | 6 | <a href="{% url 'fossil:code' slug=project.slug %}" |
| 7 | 7 |
| --- 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 @@ | ||
| 47 | 47 | |
| 48 | 48 | <div> |
| 49 | 49 | <label class="block text-sm font-medium text-gray-300 mb-1">Permissions</label> |
| 50 | 50 | <input type="text" name="permissions" value="status:write" placeholder="status:write" |
| 51 | 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> | |
| 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 | 53 | </div> |
| 54 | 54 | |
| 55 | 55 | <div> |
| 56 | 56 | <label class="block text-sm font-medium text-gray-300 mb-1">Expiration</label> |
| 57 | 57 | <input type="datetime-local" name="expires_at" |
| 58 | 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">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 @@ | ||
| 9 | 9 | <div> |
| 10 | 10 | <h2 class="text-lg font-semibold text-gray-200">API Tokens</h2> |
| 11 | 11 | <p class="text-sm text-gray-500 mt-1">Tokens for CI/CD systems and automation. Used with Bearer authentication.</p> |
| 12 | 12 | </div> |
| 13 | 13 | <div class="flex items-center gap-3"> |
| 14 | + <span class="search-wrap"> | |
| 14 | 15 | <input type="search" |
| 15 | 16 | name="search" |
| 16 | 17 | value="{{ search }}" |
| 17 | 18 | placeholder="Search tokens..." |
| 19 | + aria-label="Search API tokens" | |
| 18 | 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" |
| 19 | 21 | hx-get="{% url 'fossil:api_tokens' slug=project.slug %}" |
| 20 | 22 | hx-trigger="input changed delay:300ms, search" |
| 21 | 23 | hx-target="#token-content" |
| 22 | 24 | 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> | |
| 24 | 29 | <a href="{% url 'fossil:api_token_create' slug=project.slug %}" |
| 25 | 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"> |
| 26 | 31 | Generate Token |
| 27 | 32 | </a> |
| 28 | 33 | </div> |
| 29 | 34 |
| --- 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 @@ | ||
| 9 | 9 | <div> |
| 10 | 10 | <h2 class="text-lg font-semibold text-gray-200">Branch Protection Rules</h2> |
| 11 | 11 | <p class="text-sm text-gray-500 mt-1">Protect branches from unreviewed changes and require CI checks to pass.</p> |
| 12 | 12 | </div> |
| 13 | 13 | <div class="flex items-center gap-3"> |
| 14 | + <span class="search-wrap"> | |
| 14 | 15 | <input type="search" |
| 15 | 16 | name="search" |
| 16 | 17 | value="{{ search }}" |
| 17 | 18 | placeholder="Search rules..." |
| 19 | + aria-label="Search branch protection rules" | |
| 18 | 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" |
| 19 | 21 | hx-get="{% url 'fossil:branch_protections' slug=project.slug %}" |
| 20 | 22 | hx-trigger="input changed delay:300ms, search" |
| 21 | 23 | hx-target="#protection-content" |
| 22 | 24 | 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> | |
| 24 | 29 | <a href="{% url 'fossil:branch_protection_create' slug=project.slug %}" |
| 25 | 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"> |
| 26 | 31 | Add Rule |
| 27 | 32 | </a> |
| 28 | 33 | </div> |
| 29 | 34 |
| --- 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 |
+1
-1
| --- templates/fossil/code_blame.html | ||
| +++ templates/fossil/code_blame.html | ||
| @@ -18,11 +18,11 @@ | ||
| 18 | 18 | {% block content %} |
| 19 | 19 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 20 | 20 | {% include "fossil/_project_nav.html" %} |
| 21 | 21 | |
| 22 | 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"> | |
| 23 | + <div class="px-4 py-3 border-b border-gray-700 flex flex-wrap items-center justify-between gap-2"> | |
| 24 | 24 | <div class="flex items-center gap-1 text-sm font-mono"> |
| 25 | 25 | <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a> |
| 26 | 26 | {% for crumb in file_breadcrumbs %} |
| 27 | 27 | <span class="text-gray-600">/</span> |
| 28 | 28 | {% if forloop.last %} |
| 29 | 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 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 @@ | ||
| 9 | 9 | |
| 10 | 10 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 11 | 11 | <!-- Breadcrumb + commit bar --> |
| 12 | 12 | <div class="px-4 py-3 border-b border-gray-700"> |
| 13 | 13 | <!-- Breadcrumbs --> |
| 14 | - <div class="flex items-center justify-between"> | |
| 14 | + <div class="flex flex-wrap items-center justify-between gap-2"> | |
| 15 | 15 | <div class="flex items-center gap-1 text-sm min-w-0"> |
| 16 | 16 | <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand font-medium">{{ project.name }}</a> |
| 17 | 17 | {% for crumb in breadcrumbs %} |
| 18 | 18 | <span class="text-gray-600">/</span> |
| 19 | 19 | {% if forloop.last %} |
| 20 | 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 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 |
+1
-1
| --- templates/fossil/code_file.html | ||
| +++ templates/fossil/code_file.html | ||
| @@ -43,11 +43,11 @@ | ||
| 43 | 43 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 44 | 44 | {% include "fossil/_project_nav.html" %} |
| 45 | 45 | |
| 46 | 46 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 47 | 47 | <!-- 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"> | |
| 49 | 49 | <div class="flex items-center gap-1 text-sm font-mono"> |
| 50 | 50 | <a href="{% url 'fossil:code' slug=project.slug %}" class="text-brand-light hover:text-brand">{{ project.slug }}</a> |
| 51 | 51 | {% for crumb in file_breadcrumbs %} |
| 52 | 52 | <span class="text-gray-600">/</span> |
| 53 | 53 | {% if forloop.last %} |
| 54 | 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 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 @@ | ||
| 39 | 39 | <h2 class="text-lg font-semibold text-gray-200 mb-3">Compare Checkins</h2> |
| 40 | 40 | <form method="get" class="flex items-end gap-3"> |
| 41 | 41 | <div class="flex-1"> |
| 42 | 42 | <label class="block text-xs text-gray-500 mb-1">From (older)</label> |
| 43 | 43 | <input type="text" name="from" value="{{ from_uuid }}" placeholder="Checkin hash..." |
| 44 | + aria-label="From checkin hash" | |
| 44 | 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"> |
| 45 | 46 | </div> |
| 46 | 47 | <div class="flex-1"> |
| 47 | 48 | <label class="block text-xs text-gray-500 mb-1">To (newer)</label> |
| 48 | 49 | <input type="text" name="to" value="{{ to_uuid }}" placeholder="Checkin hash..." |
| 50 | + aria-label="To checkin hash" | |
| 49 | 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"> |
| 50 | 52 | </div> |
| 51 | 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> |
| 52 | 54 | </form> |
| 53 | 55 | </div> |
| 54 | 56 |
| --- 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 @@ | ||
| 43 | 43 | <div class="mt-6"> |
| 44 | 44 | <h3 class="text-sm font-semibold text-gray-300 mb-3">Reply</h3> |
| 45 | 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 | 46 | {% csrf_token %} |
| 47 | 47 | <textarea name="body" rows="6" required placeholder="Write your reply in Markdown..." |
| 48 | + aria-label="Write a reply" | |
| 48 | 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> |
| 49 | 50 | <div class="flex justify-end"> |
| 50 | 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"> |
| 51 | 52 | Post Reply |
| 52 | 53 | </button> |
| 53 | 54 |
| --- 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 @@ | ||
| 95 | 95 | </h3> |
| 96 | 96 | |
| 97 | 97 | {% if assets %} |
| 98 | 98 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 99 | 99 | <table class="min-w-full divide-y divide-gray-700"> |
| 100 | - <thead class="bg-gray-900/50"> | |
| 100 | + <thead class="bg-gray-900"> | |
| 101 | 101 | <tr> |
| 102 | 102 | <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th> |
| 103 | 103 | <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th> |
| 104 | 104 | <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Downloads</th> |
| 105 | 105 | <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th> |
| 106 | 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/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 @@ | ||
| 7 | 7 | {% include "fossil/_project_nav.html" %} |
| 8 | 8 | |
| 9 | 9 | <form method="get" class="mb-6"> |
| 10 | 10 | <div class="flex gap-2"> |
| 11 | 11 | <input type="text" name="q" value="{{ query }}" placeholder="Search checkins, tickets, wiki..." |
| 12 | + aria-label="Search repository" | |
| 12 | 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" |
| 13 | 14 | autofocus> |
| 14 | 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> |
| 15 | 16 | </div> |
| 16 | 17 | </form> |
| 17 | 18 |
| --- 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 @@ | ||
| 7 | 7 | {% include "fossil/_project_nav.html" %} |
| 8 | 8 | |
| 9 | 9 | <div class="flex items-center justify-between mb-6"> |
| 10 | 10 | <h2 class="text-lg font-semibold text-gray-200">Technotes</h2> |
| 11 | 11 | <div class="flex items-center gap-3"> |
| 12 | + <span class="search-wrap"> | |
| 12 | 13 | <input type="search" |
| 13 | 14 | name="search" |
| 14 | 15 | value="{{ search }}" |
| 15 | 16 | placeholder="Search technotes..." |
| 17 | + aria-label="Search technotes" | |
| 16 | 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" |
| 17 | 19 | hx-get="{% url 'fossil:technotes' slug=project.slug %}" |
| 18 | 20 | hx-trigger="input changed delay:300ms, search" |
| 19 | 21 | hx-target="#technote-content" |
| 20 | 22 | 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> | |
| 22 | 27 | {% if has_write %} |
| 23 | 28 | <a href="{% url 'fossil:technote_create' slug=project.slug %}" |
| 24 | 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"> |
| 25 | 30 | New Technote |
| 26 | 31 | </a> |
| 27 | 32 |
| --- 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 @@ | ||
| 100 | 100 | <div class="mt-6"> |
| 101 | 101 | <h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Add Comment</h3> |
| 102 | 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 | 103 | {% csrf_token %} |
| 104 | 104 | <textarea name="comment" rows="4" required placeholder="Write a comment (Markdown supported)..." |
| 105 | + aria-label="Add a comment" | |
| 105 | 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> |
| 106 | 107 | <div class="mt-3 flex justify-end"> |
| 107 | 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> |
| 108 | 109 | </div> |
| 109 | 110 | </form> |
| 110 | 111 |
| --- 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 @@ | ||
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | 7 | |
| 8 | 8 | <div class="flex items-center justify-between mb-6"> |
| 9 | 9 | <h2 class="text-lg font-semibold text-gray-200">Custom Ticket Fields</h2> |
| 10 | 10 | <div class="flex items-center gap-3"> |
| 11 | + <span class="search-wrap"> | |
| 11 | 12 | <input type="search" |
| 12 | 13 | name="search" |
| 13 | 14 | value="{{ search }}" |
| 14 | 15 | placeholder="Search fields..." |
| 16 | + aria-label="Search custom fields" | |
| 15 | 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" |
| 16 | 18 | hx-get="{% url 'fossil:ticket_fields' slug=project.slug %}" |
| 17 | 19 | hx-trigger="input changed delay:300ms, search" |
| 18 | 20 | hx-target="#fields-content" |
| 19 | 21 | 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> | |
| 21 | 26 | <a href="{% url 'fossil:ticket_field_create' slug=project.slug %}" |
| 22 | 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"> |
| 23 | 28 | Add Field |
| 24 | 29 | </a> |
| 25 | 30 | </div> |
| 26 | 31 |
| --- 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 @@ | ||
| 3 | 3 | |
| 4 | 4 | {% block content %} |
| 5 | 5 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | 7 | |
| 8 | -<div class="flex items-center justify-between mb-4"> | |
| 8 | +<div class="flex flex-wrap items-center justify-between gap-3 mb-4"> | |
| 9 | 9 | <div class="flex items-center gap-2 text-xs text-gray-500"> |
| 10 | 10 | <span>Status:</span> |
| 11 | 11 | <a href="{% url 'fossil:tickets' slug=project.slug %}" |
| 12 | 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 | 13 | <a href="{% url 'fossil:tickets' slug=project.slug %}?status=Open" |
| 14 | 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 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 @@ | ||
| 6 | 6 | {% include "fossil/_project_nav.html" %} |
| 7 | 7 | |
| 8 | 8 | <div class="flex items-center justify-between mb-6"> |
| 9 | 9 | <h2 class="text-lg font-semibold text-gray-200">Ticket Reports</h2> |
| 10 | 10 | <div class="flex items-center gap-3"> |
| 11 | + <span class="search-wrap"> | |
| 11 | 12 | <input type="search" |
| 12 | 13 | name="search" |
| 13 | 14 | value="{{ search }}" |
| 14 | 15 | placeholder="Search reports..." |
| 16 | + aria-label="Search ticket reports" | |
| 15 | 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" |
| 16 | 18 | hx-get="{% url 'fossil:ticket_reports' slug=project.slug %}" |
| 17 | 19 | hx-trigger="input changed delay:300ms, search" |
| 18 | 20 | hx-target="#reports-content" |
| 19 | 21 | 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> | |
| 21 | 26 | {% if can_admin %} |
| 22 | 27 | <a href="{% url 'fossil:ticket_report_create' slug=project.slug %}" |
| 23 | 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"> |
| 24 | 29 | Create Report |
| 25 | 30 | </a> |
| 26 | 31 |
| --- 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 |
+15
-15
| --- templates/fossil/timeline.html | ||
| +++ templates/fossil/timeline.html | ||
| @@ -5,25 +5,25 @@ | ||
| 5 | 5 | {% include "fossil/_live_reload.html" %} |
| 6 | 6 | <h1 class="text-2xl font-bold text-gray-100 mb-2">{{ project.name }}</h1> |
| 7 | 7 | {% include "fossil/_project_nav.html" %} |
| 8 | 8 | |
| 9 | 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> | |
| 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 | 25 | </div> |
| 26 | 26 | |
| 27 | 27 | {% include "fossil/partials/timeline_entries.html" %} |
| 28 | 28 | |
| 29 | 29 | {% if entries|length == 50 %} |
| 30 | 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/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 @@ | ||
| 28 | 28 | |
| 29 | 29 | <div id="unversioned-content"> |
| 30 | 30 | {% if files %} |
| 31 | 31 | <div class="overflow-hidden rounded-lg bg-gray-800 shadow border border-gray-700"> |
| 32 | 32 | <table class="min-w-full divide-y divide-gray-700"> |
| 33 | - <thead class="bg-gray-900/50"> | |
| 33 | + <thead class="bg-gray-900"> | |
| 34 | 34 | <tr> |
| 35 | 35 | <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Name</th> |
| 36 | 36 | <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Size</th> |
| 37 | 37 | <th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Modified</th> |
| 38 | 38 | <th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider"></th> |
| 39 | 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/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 |
+4
-3
| --- templates/includes/nav.html | ||
| +++ templates/includes/nav.html | ||
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | {% 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"> | |
| 3 | 3 | <div class="px-4 sm:px-6 lg:px-8"> |
| 4 | 4 | <div class="flex h-14 items-center justify-between"> |
| 5 | 5 | <div class="flex items-center gap-3"> |
| 6 | 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"> | |
| 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 | 8 | <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> |
| 9 | 9 | <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> |
| 10 | 10 | </svg> |
| 11 | 11 | </button> |
| 12 | 12 | <a href="{% url 'dashboard' %}" class="flex-shrink-0"> |
| @@ -23,10 +23,11 @@ | ||
| 23 | 23 | </button> |
| 24 | 24 | <div x-show="open" @click.outside="open = false" @keydown.escape.window="open = false" x-transition |
| 25 | 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 | 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 | 27 | <input type="text" name="q" x-ref="searchInput" placeholder="Search checkins, tickets, wiki..." |
| 28 | + aria-label="Search checkins, tickets, wiki" | |
| 28 | 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"> |
| 29 | 30 | </form> |
| 30 | 31 | </div> |
| 31 | 32 | </div> |
| 32 | 33 | <!-- Theme toggle --> |
| @@ -41,11 +42,11 @@ | ||
| 41 | 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" /> |
| 42 | 43 | </svg> |
| 43 | 44 | </button> |
| 44 | 45 | <!-- User menu --> |
| 45 | 46 | <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"> | |
| 47 | 48 | {{ user.get_full_name|default:user.username }} |
| 48 | 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> |
| 49 | 50 | </button> |
| 50 | 51 | <div x-show="open" @click.outside="open = false" x-transition |
| 51 | 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"> |
| 52 | 53 |
| --- 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"> | |
| 2 | 2 | <div class="px-4 sm:px-6 lg:px-8"> |
| 3 | 3 | <div class="flex h-14 items-center justify-between"> |
| 4 | 4 | <div class="flex items-center gap-6"> |
| 5 | 5 | <a href="/" class="flex items-center gap-2"> |
| 6 | 6 | <svg class="h-6 w-6 text-brand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| 7 | 7 |
| --- 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 |
+1
-1
| --- 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 }" | |
| 2 | 2 | :class="collapsed ? 'w-14' : 'w-60'" |
| 3 | 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 | 4 | |
| 5 | 5 | <nav class="flex-1 px-2 py-3 space-y-1 overflow-y-auto"> |
| 6 | 6 | |
| 7 | 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 }" |
| 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 @@ | ||
| 14 | 14 | New Team |
| 15 | 15 | </a> |
| 16 | 16 | {% endif %} |
| 17 | 17 | </div> |
| 18 | 18 | |
| 19 | -<div class="mb-4"> | |
| 19 | +<div class="mb-4 search-wrap max-w-md"> | |
| 20 | 20 | <input type="search" |
| 21 | 21 | name="search" |
| 22 | 22 | value="{{ search }}" |
| 23 | 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" | |
| 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" | |
| 25 | 26 | hx-get="{% url 'organization:team_list' %}" |
| 26 | 27 | hx-trigger="input changed delay:300ms, search" |
| 27 | 28 | hx-target="#team-table" |
| 28 | 29 | 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> | |
| 30 | 33 | </div> |
| 31 | 34 | |
| 32 | 35 | {% include "organization/partials/team_table.html" %} |
| 33 | 36 | {% include "includes/_pagination.html" %} |
| 34 | 37 | {% endblock %} |
| 35 | 38 |
| --- 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 |
+6
-3
| --- templates/pages/page_list.html | ||
| +++ templates/pages/page_list.html | ||
| @@ -10,21 +10,24 @@ | ||
| 10 | 10 | New Page |
| 11 | 11 | </a> |
| 12 | 12 | {% endif %} |
| 13 | 13 | </div> |
| 14 | 14 | |
| 15 | -<div class="mb-4"> | |
| 15 | +<div class="mb-4 search-wrap max-w-md"> | |
| 16 | 16 | <input type="search" |
| 17 | 17 | name="search" |
| 18 | 18 | value="{{ search }}" |
| 19 | 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" | |
| 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" | |
| 21 | 22 | hx-get="{% url 'pages:list' %}" |
| 22 | 23 | hx-trigger="input changed delay:300ms, search" |
| 23 | 24 | hx-target="#page-table" |
| 24 | 25 | 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> | |
| 26 | 29 | </div> |
| 27 | 30 | |
| 28 | 31 | {% include "pages/partials/page_table.html" %} |
| 29 | 32 | {% include "includes/_pagination.html" %} |
| 30 | 33 | {% endblock %} |
| 31 | 34 |
| --- 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 @@ | ||
| 15 | 15 | <form method="get" action="{% url 'explore' %}" class="flex-1 flex gap-2"> |
| 16 | 16 | <input type="search" |
| 17 | 17 | name="search" |
| 18 | 18 | value="{{ search }}" |
| 19 | 19 | placeholder="Search projects..." |
| 20 | + aria-label="Search projects" | |
| 20 | 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" /> |
| 21 | 22 | {% if sort != "stars" %} |
| 22 | 23 | <input type="hidden" name="sort" value="{{ sort }}" /> |
| 23 | 24 | {% endif %} |
| 24 | 25 | <button type="submit" |
| 25 | 26 |
| --- 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 @@ | ||
| 10 | 10 | New Group |
| 11 | 11 | </a> |
| 12 | 12 | {% endif %} |
| 13 | 13 | </div> |
| 14 | 14 | |
| 15 | -<div class="mb-4"> | |
| 15 | +<div class="mb-4 search-wrap max-w-md"> | |
| 16 | 16 | <input type="search" |
| 17 | 17 | name="search" |
| 18 | 18 | value="{{ search }}" |
| 19 | 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" | |
| 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" | |
| 21 | 22 | hx-get="{% url 'projects:group_list' %}" |
| 22 | 23 | hx-trigger="input changed delay:300ms, search" |
| 23 | 24 | hx-target="#group-table" |
| 24 | 25 | 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> | |
| 26 | 29 | </div> |
| 27 | 30 | |
| 28 | 31 | {% include "projects/partials/group_table.html" %} |
| 29 | 32 | {% include "includes/_pagination.html" %} |
| 30 | 33 | {% endblock %} |
| 31 | 34 |
| --- 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 @@ | ||
| 5 | 5 | {% block extra_head %} |
| 6 | 6 | <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> |
| 7 | 7 | {% endblock %} |
| 8 | 8 | |
| 9 | 9 | {% 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"> | |
| 12 | 12 | <h1 class="text-2xl font-bold text-gray-100">{{ project.name }}</h1> |
| 13 | 13 | <p class="mt-1 text-sm text-gray-400">{{ project.description|default:"No description." }}</p> |
| 14 | 14 | </div> |
| 15 | - <div class="flex gap-3"> | |
| 15 | + <div class="flex flex-wrap gap-3"> | |
| 16 | 16 | {% if user.is_authenticated %} |
| 17 | 17 | {% include "projects/partials/star_button.html" %} |
| 18 | 18 | <form method="post" action="{% url 'fossil:toggle_watch' slug=project.slug %}"> |
| 19 | 19 | {% csrf_token %} |
| 20 | 20 | {% if is_watching %} |
| 21 | 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="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 |
+73
-3
| --- tests/test_agent_coordination.py | ||
| +++ tests/test_agent_coordination.py | ||
| @@ -265,10 +265,11 @@ | ||
| 265 | 265 | created_by=admin_user, |
| 266 | 266 | ) |
| 267 | 267 | |
| 268 | 268 | response = admin_client.post( |
| 269 | 269 | _api_url(sample_project.slug, "api/tickets/abc123def456/release"), |
| 270 | + data=json.dumps({"agent_id": "claude-abc"}), | |
| 270 | 271 | content_type="application/json", |
| 271 | 272 | ) |
| 272 | 273 | |
| 273 | 274 | assert response.status_code == 200 |
| 274 | 275 | data = response.json() |
| @@ -277,10 +278,42 @@ | ||
| 277 | 278 | |
| 278 | 279 | # Claim should be soft-deleted (not visible via default manager) |
| 279 | 280 | assert TicketClaim.objects.filter(repository=fossil_repo_obj, ticket_uuid="abc123def456").count() == 0 |
| 280 | 281 | # But still in all_objects |
| 281 | 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 | |
| 282 | 315 | |
| 283 | 316 | def test_release_allows_reclaim(self, admin_client, sample_project, fossil_repo_obj, admin_user): |
| 284 | 317 | """After releasing, another agent can claim the ticket.""" |
| 285 | 318 | TicketClaim.objects.create( |
| 286 | 319 | repository=fossil_repo_obj, |
| @@ -290,10 +323,11 @@ | ||
| 290 | 323 | ) |
| 291 | 324 | |
| 292 | 325 | # Release the claim |
| 293 | 326 | admin_client.post( |
| 294 | 327 | _api_url(sample_project.slug, "api/tickets/abc123def456/release"), |
| 328 | + data=json.dumps({"agent_id": "claude-abc"}), | |
| 295 | 329 | content_type="application/json", |
| 296 | 330 | ) |
| 297 | 331 | |
| 298 | 332 | # Now a new claim should succeed |
| 299 | 333 | with patch("fossil.api_views.FossilReader") as mock_reader_cls: |
| @@ -311,18 +345,20 @@ | ||
| 311 | 345 | |
| 312 | 346 | def test_release_nonexistent_claim(self, admin_client, sample_project, fossil_repo_obj): |
| 313 | 347 | """Releasing when no claim exists returns 404.""" |
| 314 | 348 | response = admin_client.post( |
| 315 | 349 | _api_url(sample_project.slug, "api/tickets/nonexistent/release"), |
| 350 | + data=json.dumps({"agent_id": "claude-abc"}), | |
| 316 | 351 | content_type="application/json", |
| 317 | 352 | ) |
| 318 | 353 | assert response.status_code == 404 |
| 319 | 354 | |
| 320 | 355 | def test_release_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj): |
| 321 | 356 | """Read-only users cannot release claims.""" |
| 322 | 357 | response = reader_client.post( |
| 323 | 358 | _api_url(sample_project.slug, "api/tickets/abc123def456/release"), |
| 359 | + data=json.dumps({"agent_id": "claude-abc"}), | |
| 324 | 360 | content_type="application/json", |
| 325 | 361 | ) |
| 326 | 362 | assert response.status_code == 403 |
| 327 | 363 | |
| 328 | 364 | |
| @@ -342,10 +378,11 @@ | ||
| 342 | 378 | |
| 343 | 379 | response = admin_client.post( |
| 344 | 380 | _api_url(sample_project.slug, "api/tickets/abc123def456/submit"), |
| 345 | 381 | data=json.dumps( |
| 346 | 382 | { |
| 383 | + "agent_id": "claude-abc", | |
| 347 | 384 | "summary": "Fixed the null pointer bug", |
| 348 | 385 | "files_changed": ["src/auth.py", "tests/test_auth.py"], |
| 349 | 386 | } |
| 350 | 387 | ), |
| 351 | 388 | content_type="application/json", |
| @@ -371,29 +408,62 @@ | ||
| 371 | 408 | created_by=admin_user, |
| 372 | 409 | ) |
| 373 | 410 | |
| 374 | 411 | response = admin_client.post( |
| 375 | 412 | _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"}), | |
| 377 | 414 | content_type="application/json", |
| 378 | 415 | ) |
| 379 | 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 | |
| 380 | 450 | |
| 381 | 451 | def test_submit_no_claim(self, admin_client, sample_project, fossil_repo_obj): |
| 382 | 452 | """Submitting without an active claim returns 404.""" |
| 383 | 453 | response = admin_client.post( |
| 384 | 454 | _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"}), | |
| 386 | 456 | content_type="application/json", |
| 387 | 457 | ) |
| 388 | 458 | assert response.status_code == 404 |
| 389 | 459 | |
| 390 | 460 | def test_submit_denied_for_reader(self, reader_client, sample_project, fossil_repo_obj): |
| 391 | 461 | """Read-only users cannot submit work.""" |
| 392 | 462 | response = reader_client.post( |
| 393 | 463 | _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"}), | |
| 395 | 465 | content_type="application/json", |
| 396 | 466 | ) |
| 397 | 467 | assert response.status_code == 403 |
| 398 | 468 | |
| 399 | 469 | |
| 400 | 470 |
| --- 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 |