pygitweb 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {pygitweb-0.1.0 → pygitweb-0.2.0}/PKG-INFO +2 -28
  2. {pygitweb-0.1.0 → pygitweb-0.2.0}/README.md +0 -26
  3. {pygitweb-0.1.0 → pygitweb-0.2.0}/__meta__.py +1 -1
  4. {pygitweb-0.1.0 → pygitweb-0.2.0}/actions.py +2 -1
  5. {pygitweb-0.1.0 → pygitweb-0.2.0}/addproject_permissions_test.py +2 -3
  6. {pygitweb-0.1.0 → pygitweb-0.2.0}/auth.py +69 -0
  7. {pygitweb-0.1.0 → pygitweb-0.2.0}/auth.schema.json +1 -1
  8. {pygitweb-0.1.0 → pygitweb-0.2.0}/auth_config.py +1 -1
  9. {pygitweb-0.1.0 → pygitweb-0.2.0}/auth_test.py +11 -11
  10. pygitweb-0.2.0/board_refs.py +154 -0
  11. pygitweb-0.2.0/board_refs_test.py +106 -0
  12. {pygitweb-0.1.0 → pygitweb-0.2.0}/boards_permissions_test.py +33 -3
  13. {pygitweb-0.1.0 → pygitweb-0.2.0}/change_queue_routes_test.py +22 -0
  14. {pygitweb-0.1.0 → pygitweb-0.2.0}/comments_permissions_test.py +97 -5
  15. {pygitweb-0.1.0 → pygitweb-0.2.0}/dependencies.py +81 -2
  16. {pygitweb-0.1.0 → pygitweb-0.2.0}/git_helpers.py +59 -0
  17. {pygitweb-0.1.0 → pygitweb-0.2.0}/git_helpers_test.py +44 -0
  18. {pygitweb-0.1.0 → pygitweb-0.2.0}/hooks_install.py +46 -1
  19. {pygitweb-0.1.0 → pygitweb-0.2.0}/hooks_install_test.py +20 -0
  20. {pygitweb-0.1.0 → pygitweb-0.2.0}/hooks_permissions_test.py +3 -4
  21. {pygitweb-0.1.0 → pygitweb-0.2.0}/main.py +63 -65
  22. {pygitweb-0.1.0 → pygitweb-0.2.0}/merge_requests.py +15 -9
  23. {pygitweb-0.1.0 → pygitweb-0.2.0}/merge_requests_test.py +1 -1
  24. {pygitweb-0.1.0 → pygitweb-0.2.0}/mr_permissions_test.py +84 -5
  25. {pygitweb-0.1.0 → pygitweb-0.2.0}/permissions.py +29 -3
  26. {pygitweb-0.1.0 → pygitweb-0.2.0}/permissions_test.py +54 -0
  27. pygitweb-0.2.0/project_list_routes_test.py +56 -0
  28. {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/PKG-INFO +2 -28
  29. {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/SOURCES.txt +16 -0
  30. {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/requires.txt +1 -1
  31. {pygitweb-0.1.0 → pygitweb-0.2.0}/pyproject.toml +2 -2
  32. {pygitweb-0.1.0 → pygitweb-0.2.0}/settings.py +17 -4
  33. {pygitweb-0.1.0 → pygitweb-0.2.0}/settings_permissions_test.py +24 -3
  34. pygitweb-0.2.0/shutdown.py +61 -0
  35. pygitweb-0.2.0/smart_http.py +219 -0
  36. pygitweb-0.2.0/smart_http_test.py +232 -0
  37. pygitweb-0.2.0/static/board-refs.js +302 -0
  38. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/board.js +90 -2
  39. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/main.css +73 -1
  40. {pygitweb-0.1.0 → pygitweb-0.2.0}/tasks.py +54 -21
  41. {pygitweb-0.1.0 → pygitweb-0.2.0}/tasks_permissions_test.py +2 -3
  42. {pygitweb-0.1.0 → pygitweb-0.2.0}/tasks_routes_test.py +1 -0
  43. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/board.html +1 -0
  44. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/board_card.html +2 -1
  45. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/merge_request_tag.html +4 -0
  46. pygitweb-0.2.0/templates/pygitweb_settings.html +21 -0
  47. pygitweb-0.2.0/templates/shutdown_ack.html +6 -0
  48. pygitweb-0.1.0/templates/pygitweb_settings.html +0 -12
  49. {pygitweb-0.1.0 → pygitweb-0.2.0}/LICENSE +0 -0
  50. {pygitweb-0.1.0 → pygitweb-0.2.0}/__init__.py +0 -0
  51. {pygitweb-0.1.0 → pygitweb-0.2.0}/api/__init__.py +0 -0
  52. {pygitweb-0.1.0 → pygitweb-0.2.0}/api/action.py +0 -0
  53. {pygitweb-0.1.0 → pygitweb-0.2.0}/api/plugins/__init__.py +0 -0
  54. {pygitweb-0.1.0 → pygitweb-0.2.0}/api/plugins/project_zip.py +0 -0
  55. {pygitweb-0.1.0 → pygitweb-0.2.0}/api/project.py +0 -0
  56. {pygitweb-0.1.0 → pygitweb-0.2.0}/api/subpage.py +0 -0
  57. {pygitweb-0.1.0 → pygitweb-0.2.0}/auth_oauth.py +0 -0
  58. {pygitweb-0.1.0 → pygitweb-0.2.0}/change_queue.py +0 -0
  59. {pygitweb-0.1.0 → pygitweb-0.2.0}/change_queue_test.py +0 -0
  60. {pygitweb-0.1.0 → pygitweb-0.2.0}/config.py +0 -0
  61. {pygitweb-0.1.0 → pygitweb-0.2.0}/conftest.py +0 -0
  62. {pygitweb-0.1.0 → pygitweb-0.2.0}/formatting.py +0 -0
  63. {pygitweb-0.1.0 → pygitweb-0.2.0}/formatting_test.py +0 -0
  64. {pygitweb-0.1.0 → pygitweb-0.2.0}/gravatar.py +0 -0
  65. {pygitweb-0.1.0 → pygitweb-0.2.0}/gravatar_test.py +0 -0
  66. {pygitweb-0.1.0 → pygitweb-0.2.0}/password_hash.py +0 -0
  67. {pygitweb-0.1.0 → pygitweb-0.2.0}/password_hash_test.py +0 -0
  68. {pygitweb-0.1.0 → pygitweb-0.2.0}/plugin_loader.py +0 -0
  69. {pygitweb-0.1.0 → pygitweb-0.2.0}/projects.py +0 -0
  70. {pygitweb-0.1.0 → pygitweb-0.2.0}/projects_test.py +0 -0
  71. {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/dependency_links.txt +0 -0
  72. {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/top_level.txt +0 -0
  73. {pygitweb-0.1.0 → pygitweb-0.2.0}/sessions.py +0 -0
  74. {pygitweb-0.1.0 → pygitweb-0.2.0}/settings.schema.json +0 -0
  75. {pygitweb-0.1.0 → pygitweb-0.2.0}/settings_config.py +0 -0
  76. {pygitweb-0.1.0 → pygitweb-0.2.0}/settings_config_test.py +0 -0
  77. {pygitweb-0.1.0 → pygitweb-0.2.0}/setup.cfg +0 -0
  78. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/blob-view.js +0 -0
  79. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/hook-install.js +0 -0
  80. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-down.svg +0 -0
  81. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-exclamation.svg +0 -0
  82. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-filled.svg +0 -0
  83. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-storm.svg +0 -0
  84. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-up.svg +0 -0
  85. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud.svg +0 -0
  86. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/logo.svg +0 -0
  87. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/navbar-auth.js +0 -0
  88. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/readme-render.js +0 -0
  89. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/search-page.js +0 -0
  90. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/summary-ref-switcher.js +0 -0
  91. {pygitweb-0.1.0 → pygitweb-0.2.0}/static/themes/solarized.css +0 -0
  92. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/addproject.html +0 -0
  93. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/blobdiff.html +0 -0
  94. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/commitdiff.html +0 -0
  95. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/hooks_summary_cell.html +0 -0
  96. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/login.html +0 -0
  97. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/merge_request_summary_cell.html +0 -0
  98. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/pagination.html +0 -0
  99. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/preamble.html +0 -0
  100. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/project_settings.html +0 -0
  101. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/pygit2_settings.html +0 -0
  102. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/search.html +0 -0
  103. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/settings_form_field.html +0 -0
  104. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/table.html +0 -0
  105. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/user_profile.html +0 -0
  106. {pygitweb-0.1.0 → pygitweb-0.2.0}/templates_env.py +0 -0
  107. {pygitweb-0.1.0 → pygitweb-0.2.0}/timeline_cache.py +0 -0
  108. {pygitweb-0.1.0 → pygitweb-0.2.0}/validation.py +0 -0
  109. {pygitweb-0.1.0 → pygitweb-0.2.0}/validation_test.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygitweb
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Gitweb reimplementation with FastAPI and Pygit2
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
- Requires-Dist: pygittools>=0.1.0
9
+ Requires-Dist: pygittools>=0.2.0
10
10
  Requires-Dist: fastapi[standard-no-fastapi-cloud-cli]>=0.104.0
11
11
  Requires-Dist: uvicorn[standard]>=0.24.0
12
12
  Requires-Dist: python-multipart>=0.0.6
@@ -22,32 +22,6 @@ Dynamic: license-file
22
22
 
23
23
  Gitweb reimplementation using **Python**, **FastAPI**, and **Pygit2**, ported from `git/gitweb/gitweb.perl`.
24
24
 
25
- ## Setup
26
-
27
- ```bash
28
- uv sync --package pygitweb
29
- ```
30
-
31
- Or install everything in the workspace (all members + dev tools):
32
-
33
- ```bash
34
- uv sync --all-packages --group dev
35
- ```
36
-
37
- ## Run
38
-
39
- From the repository root (after `uv sync`), with the workspace `.venv` activated:
40
-
41
- ```bash
42
- uvicorn pygitweb.main:app --reload --host 0.0.0.0 --port 8000
43
- ```
44
-
45
- Or:
46
-
47
- ```bash
48
- python -m pygitweb.main
49
- ```
50
-
51
25
  ## Config
52
26
 
53
27
  Settings load from `~/.pygitweb/settings.json` (or `PYGITWEB_SETTINGS_CONFIG`), `PYGITWEB_*`
@@ -2,32 +2,6 @@
2
2
 
3
3
  Gitweb reimplementation using **Python**, **FastAPI**, and **Pygit2**, ported from `git/gitweb/gitweb.perl`.
4
4
 
5
- ## Setup
6
-
7
- ```bash
8
- uv sync --package pygitweb
9
- ```
10
-
11
- Or install everything in the workspace (all members + dev tools):
12
-
13
- ```bash
14
- uv sync --all-packages --group dev
15
- ```
16
-
17
- ## Run
18
-
19
- From the repository root (after `uv sync`), with the workspace `.venv` activated:
20
-
21
- ```bash
22
- uvicorn pygitweb.main:app --reload --host 0.0.0.0 --port 8000
23
- ```
24
-
25
- Or:
26
-
27
- ```bash
28
- python -m pygitweb.main
29
- ```
30
-
31
5
  ## Config
32
6
 
33
7
  Settings load from `~/.pygitweb/settings.json` (or `PYGITWEB_SETTINGS_CONFIG`), `PYGITWEB_*`
@@ -2,7 +2,7 @@
2
2
  Metadata for PyGitWeb - this is the canonical source of all information below.
3
3
  """
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
6
6
  __author__ = "Will Bowers"
7
7
  __license__ = "MIT"
8
8
  __description__ = "FastAPI + Pygit2 Repo Browser"
@@ -1057,7 +1057,7 @@ def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLR
1057
1057
  )
1058
1058
 
1059
1059
 
1060
- def git_tag(project: str, h: str | None) -> HTMLResponse:
1060
+ def git_tag(project: str, h: str | None, notice: str | None = None) -> HTMLResponse:
1061
1061
  """Single tag view. Port of git_tag."""
1062
1062
  if not h:
1063
1063
  raise HTTPException(status_code=400, detail="Tag ref or hash (h) required")
@@ -1101,6 +1101,7 @@ def git_tag(project: str, h: str | None) -> HTMLResponse:
1101
1101
  tag_oid=tag_oid,
1102
1102
  tagger=tagger,
1103
1103
  tagger_date=_format_date(tagger_epoch, tagger_tz),
1104
+ notice=notice,
1104
1105
  )
1105
1106
  target_short = target_oid[:7] if target_oid else ""
1106
1107
  if target_type == "commit":
@@ -6,13 +6,12 @@ from fastapi.testclient import TestClient
6
6
 
7
7
  from pygitweb.auth_config import AuthConfig, LocalUser
8
8
  from pygitweb.main import app
9
- from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
9
+ from pygitweb.permissions import PERMISSION_ADD_PROJECTS, permissions_with_defaults
10
10
 
11
11
  _ADDPROJECTS_AUTH = AuthConfig(
12
12
  auth_mode="local",
13
13
  local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
14
- oauth_permissions=PermissionsMap.model_validate({
15
- "*": [],
14
+ oauth_permissions=permissions_with_defaults({
16
15
  PERMISSION_ADD_PROJECTS: ["admin"],
17
16
  }),
18
17
  )
@@ -33,6 +33,7 @@ from pygitweb.config import settings
33
33
  from pygitweb.gravatar import gravatar_url
34
34
  from pygitweb.password_hash import verify_password
35
35
  from pygitweb.permissions import Permission, PermissionPrincipal
36
+ from pygitweb.projects import git_get_project_owner
36
37
  from pygitweb.sessions import create_session, get_session, revoke_session
37
38
  from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
38
39
 
@@ -78,6 +79,72 @@ def has_permission(
78
79
  return auth_config.oauth_permissions.has_permission(principal.identity, permission, project)
79
80
 
80
81
 
82
+ def _owner_matches(project: str, token: str | None) -> bool:
83
+ if not settings.AUTH or not token:
84
+ return False
85
+ principal = principal_from_session(token)
86
+ if principal is None:
87
+ return False
88
+ owner = git_get_project_owner(project)
89
+ if not owner or not owner.strip():
90
+ return False
91
+ owner_norm = owner.strip()
92
+ identity = principal.identity
93
+ if hmac.compare_digest(owner_norm, identity):
94
+ return True
95
+ return owner_norm.lower() == identity.lower()
96
+
97
+
98
+ def can_read_project(project: str, token: str | None) -> bool:
99
+ if not settings.AUTH:
100
+ return True
101
+ if _owner_matches(project, token):
102
+ return True
103
+ return has_permission(Permission.READ, project, token=token)
104
+
105
+
106
+ def can_write_project(project: str, token: str | None) -> bool:
107
+ if not settings.AUTH:
108
+ return True
109
+ if _owner_matches(project, token):
110
+ return True
111
+ return has_permission(Permission.WRITE, project, token=token)
112
+
113
+
114
+ def ensure_read_project(project: str, token: str | None) -> None:
115
+ if can_read_project(project, token):
116
+ return
117
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project")
118
+
119
+
120
+ def ensure_write_project(project: str, token: str | None) -> None:
121
+ if not settings.AUTH:
122
+ return
123
+ if not is_auth_configured(auth_config):
124
+ raise HTTPException(
125
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
126
+ detail="Authentication enabled but auth.json is not configured",
127
+ )
128
+ if not token:
129
+ raise HTTPException(
130
+ status_code=status.HTTP_401_UNAUTHORIZED,
131
+ detail="Not authenticated",
132
+ headers={"WWW-Authenticate": "Bearer"},
133
+ )
134
+ if decode_access_token(token) is None:
135
+ raise HTTPException(
136
+ status_code=status.HTTP_401_UNAUTHORIZED,
137
+ detail="Not authenticated",
138
+ headers={"WWW-Authenticate": "Bearer"},
139
+ )
140
+ if can_write_project(project, token):
141
+ return
142
+ raise HTTPException(
143
+ status_code=status.HTTP_403_FORBIDDEN,
144
+ detail="Insufficient permissions",
145
+ )
146
+
147
+
81
148
  def ensure_permission(
82
149
  permission: Permission,
83
150
  project: str | None = None,
@@ -85,6 +152,8 @@ def ensure_permission(
85
152
  token: str | None,
86
153
  ) -> None:
87
154
  ensure_active_user_if_auth_enabled(token)
155
+ if project is not None:
156
+ ensure_read_project(project, token)
88
157
  if has_permission(permission, project, token=token):
89
158
  return
90
159
  raise HTTPException(
@@ -60,7 +60,7 @@
60
60
  "additionalProperties": {
61
61
  "type": "array",
62
62
  "items": { "type": "string" },
63
- "description": "Scoped keys: pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project path)"
63
+ "description": "Scoped keys: pgw.read.{scope}, pgw.write.{scope}, pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project path). Principal * matches any authenticated user."
64
64
  }
65
65
  },
66
66
  "local_users": {
@@ -134,7 +134,7 @@ def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
134
134
  oauth_redirect_uri="",
135
135
  oauth_username_from="email",
136
136
  oauth_allowed_emails=[],
137
- oauth_permissions=PermissionsMap.empty(),
137
+ oauth_permissions=PermissionsMap.default_access(),
138
138
  local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
139
139
  )
140
140
 
@@ -319,17 +319,17 @@ def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: A
319
319
  params={"code": "auth-code", "state": "idp-state"},
320
320
  follow_redirects=False,
321
321
  )
322
- assert r.status_code == 303
323
- rec = get_session(r.cookies["pygitweb_access_token"])
324
- assert rec is not None
325
- assert rec.username == "user@example.com"
326
- assert rec.subject == "oauth-sub-1"
327
- assert rec.email == "user@example.com"
328
- assert rec.auth_method == "oauth"
329
- assert client.get("/users/me").json()["username"] == "user@example.com"
330
- status = client.get("/auth/status").json()
331
- assert status["username"] == "user@example.com"
332
- assert status["gravatar_url"] == gravatar_url("user@example.com")
322
+ assert r.status_code == 303
323
+ rec = get_session(r.cookies["pygitweb_access_token"])
324
+ assert rec is not None
325
+ assert rec.username == "user@example.com"
326
+ assert rec.subject == "oauth-sub-1"
327
+ assert rec.email == "user@example.com"
328
+ assert rec.auth_method == "oauth"
329
+ assert client.get("/users/me").json()["username"] == "user@example.com"
330
+ status = client.get("/auth/status").json()
331
+ assert status["username"] == "user@example.com"
332
+ assert status["gravatar_url"] == gravatar_url("user@example.com")
333
333
  clear_client_cookies(client)
334
334
 
335
335
 
@@ -0,0 +1,154 @@
1
+ """Client-side reference manifest for board task @ / # lookups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import UTC, datetime, timedelta, timezone
7
+ from typing import TypedDict
8
+ from urllib.parse import quote
9
+
10
+ from pygit2 import Oid, Repository, Tag
11
+
12
+ from pygittools.merge import MR_REF_PREFIX, get_merge_request_by_oid
13
+ from pygitweb.config import settings
14
+ from pygitweb.git_helpers import get_commit_history
15
+ from pygitweb.tasks import TASK_REF_PREFIX, get_board_tasks_grouped
16
+
17
+ BOARD_REF_MANIFEST_COMMIT_LIMIT = 100
18
+
19
+
20
+ class BoardRefManifestEntry(TypedDict):
21
+ name: str
22
+ insert: str
23
+ url: str
24
+ tags: str
25
+ snippet: str
26
+ created: str
27
+
28
+
29
+ class BoardRefManifest(TypedDict):
30
+ commits: list[BoardRefManifestEntry]
31
+ refs: list[BoardRefManifestEntry]
32
+
33
+
34
+ def _format_epoch(epoch: int | None, tz_offset: int | None = None) -> str:
35
+ if epoch is None:
36
+ return ""
37
+ try:
38
+ tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else UTC
39
+ return datetime.fromtimestamp(epoch, tz=tz).strftime("%Y-%m-%d %H:%M:%S")
40
+ except (ValueError, OSError):
41
+ return ""
42
+
43
+
44
+ def _project_url(project: str) -> str:
45
+ return f"/project/{quote(project, safe='/')}"
46
+
47
+
48
+ def _board_task_url(project: str, board_name: str, task_ref: str) -> str:
49
+ base = f"{_project_url(project)}/board/"
50
+ qs = f"b={quote(board_name, safe='')}&task={quote(task_ref, safe='')}"
51
+ return f"{base}?{qs}" if board_name != "Tasks" else f"{base}?task={quote(task_ref, safe='')}"
52
+
53
+
54
+ def _task_display_id(ref: str) -> str:
55
+ slug = ref.removeprefix(TASK_REF_PREFIX)
56
+ if slug.startswith("task_"):
57
+ return slug.removeprefix("task_")
58
+ return slug
59
+
60
+
61
+ def _manifest_commits(project: str) -> list[BoardRefManifestEntry]:
62
+ project_url = _project_url(project)
63
+ entries: list[BoardRefManifestEntry] = []
64
+ for commit in get_commit_history(project, ref=None, max_count=BOARD_REF_MANIFEST_COMMIT_LIMIT):
65
+ oid = str(commit.get("oid") or "")
66
+ if not oid:
67
+ continue
68
+ short = oid[:7]
69
+ subject = str(commit.get("subject") or "")
70
+ author = str(commit.get("author") or "")
71
+ created = _format_epoch(commit.get("author_epoch"), commit.get("author_tz"))
72
+ search_tags = " ".join(part.lower() for part in (oid, short, subject, author) if part)
73
+ entries.append({
74
+ "name": f"{short} {subject}".strip(),
75
+ "insert": short,
76
+ "url": f"{project_url}?a=commit&h={quote(oid, safe='')}",
77
+ "tags": search_tags,
78
+ "snippet": author,
79
+ "created": created,
80
+ })
81
+ return entries
82
+
83
+
84
+ def _manifest_board_tasks(project: str, board_name: str) -> list[BoardRefManifestEntry]:
85
+ entries: list[BoardRefManifestEntry] = []
86
+ seen_refs: set[str] = set()
87
+ try:
88
+ columns = get_board_tasks_grouped(project, board_name)
89
+ except KeyError:
90
+ return entries
91
+ for column in columns:
92
+ for task in column["tasks"]:
93
+ task_ref = str(task.get("ref") or "")
94
+ if not task_ref or task_ref in seen_refs:
95
+ continue
96
+ seen_refs.add(task_ref)
97
+ task_id = str(task.get("id") or _task_display_id(task_ref))
98
+ title = str(task.get("title") or "Untitled")
99
+ slug = task_ref.removeprefix("refs/tags/")
100
+ created = str(task.get("updated_at") or task.get("created_at") or "")
101
+ search_tags = " ".join(
102
+ part.lower() for part in (task_id, slug, task_ref, title, str(task.get("status") or "")) if part
103
+ )
104
+ entries.append({
105
+ "name": title,
106
+ "insert": task_id,
107
+ "url": _board_task_url(project, board_name, task_ref),
108
+ "tags": search_tags,
109
+ "snippet": f"task {task_id}",
110
+ "created": created,
111
+ })
112
+ return entries
113
+
114
+
115
+ def _manifest_merge_requests(project: str) -> list[BoardRefManifestEntry]:
116
+ project_url = _project_url(project)
117
+ repo = Repository(os.path.join(settings.PROJECTROOT, project))
118
+ entries: list[BoardRefManifestEntry] = []
119
+ for ref_name in repo.references:
120
+ if not ref_name.startswith(MR_REF_PREFIX):
121
+ continue
122
+ try:
123
+ oid = repo.references[ref_name].resolve().target
124
+ obj = repo[oid]
125
+ if not isinstance(obj, Tag):
126
+ continue
127
+ mr = get_merge_request_by_oid(repo, Oid(hex=str(oid)))
128
+ except (ValueError, KeyError, TypeError):
129
+ continue
130
+ slug = ref_name.removeprefix(MR_REF_PREFIX)
131
+ title = mr.title or slug
132
+ created = mr.updated_at.isoformat() if mr.updated_at else ""
133
+ search_tags = " ".join(
134
+ part.lower() for part in (slug, ref_name, title, mr.description or "", mr.ours or "") if part
135
+ )
136
+ entries.append({
137
+ "name": title,
138
+ "insert": slug,
139
+ "url": f"{project_url}?a=tag&h={quote(ref_name, safe='')}",
140
+ "tags": search_tags,
141
+ "snippet": f"merge request {slug}",
142
+ "created": created,
143
+ })
144
+ return entries
145
+
146
+
147
+ def board_ref_manifest(project: str, board_name: str) -> BoardRefManifest:
148
+ refs = _manifest_board_tasks(project, board_name)
149
+ refs.extend(_manifest_merge_requests(project))
150
+ refs.sort(key=lambda entry: (entry["created"], entry["name"]), reverse=True)
151
+ return {
152
+ "commits": _manifest_commits(project),
153
+ "refs": refs,
154
+ }
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+ from fastapi.testclient import TestClient
8
+ from pygit2 import Oid, Signature, init_repository
9
+
10
+ from pygittools.merge import MR_REF_PREFIX, MergeRequest
11
+ from pygittools.tasks import BOARD_REF_PREFIX, EMPTY_TREE_OID_HEX, TASK_REF_PREFIX, Board, Task
12
+ from pygitweb.board_refs import board_ref_manifest
13
+ from pygitweb.config import settings
14
+ from pygitweb.main import app
15
+
16
+ EMPTY_TREE_OID = Oid(hex=EMPTY_TREE_OID_HEX)
17
+ SIG = Signature("tester", "tester@example.com")
18
+
19
+
20
+ @pytest.fixture
21
+ def manifest_env(tmp_path: Path) -> dict[str, str | TestClient]:
22
+ root = tmp_path / "manifest"
23
+ repo_dir = root / "demo"
24
+ repo_dir.mkdir(parents=True)
25
+ repo = init_repository(str(repo_dir), bare=False)
26
+
27
+ index = repo.index
28
+ (repo_dir / "README").write_text("hello\n", encoding="utf-8")
29
+ index.add("README")
30
+ index.write()
31
+ tree = index.write_tree()
32
+ first = repo.create_commit("refs/heads/main", SIG, SIG, "Initial commit", tree, [])
33
+ second_tree = index.write_tree()
34
+ second = repo.create_commit("refs/heads/main", SIG, SIG, "Second commit", second_tree, [first])
35
+ repo.set_head(second)
36
+
37
+ board_ref = f"{BOARD_REF_PREFIX}Tasks"
38
+ board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main")
39
+ task_ref = f"{TASK_REF_PREFIX}task_1782144988054"
40
+ task = Task(
41
+ EMPTY_TREE_OID,
42
+ task_ref,
43
+ tagger="",
44
+ title="Ref lookup task",
45
+ description="See @8054",
46
+ status=Task.Status.TODO,
47
+ )
48
+ task_oid = task.write(repo)
49
+ board.tasks = [str(task_oid)]
50
+ board.update_message()
51
+ board.write(repo)
52
+
53
+ mr_ref = f"{MR_REF_PREFIX}aaaabbbbccccdddd"
54
+ mr = MergeRequest(
55
+ target=second,
56
+ opener=SIG,
57
+ ours="refs/heads/main",
58
+ title="Test MR",
59
+ description="Merge me",
60
+ name=mr_ref,
61
+ )
62
+ mr.write(repo)
63
+
64
+ with (
65
+ patch.object(settings, "PROJECTROOT", str(root)),
66
+ patch.object(settings, "PROJECTS_LIST", str(root)),
67
+ patch.object(settings, "STRICT_EXPORT", False),
68
+ patch.object(settings, "EXPORT_OK", ""),
69
+ patch.object(settings, "AUTH", False),
70
+ ):
71
+ yield {"client": TestClient(app), "project": "demo", "task_ref": task_ref, "mr_ref": mr_ref}
72
+
73
+
74
+ def test_board_ref_manifest_includes_commits_tasks_and_merge_requests(
75
+ manifest_env: dict[str, str | TestClient],
76
+ ) -> None:
77
+ manifest = board_ref_manifest(str(manifest_env["project"]), "Tasks")
78
+
79
+ assert len(manifest["commits"]) == 2
80
+ assert "Second commit" in manifest["commits"][0]["name"]
81
+ assert manifest["commits"][0]["insert"] == manifest["commits"][0]["name"].split()[0]
82
+
83
+ task_entries = [entry for entry in manifest["refs"] if "1782144988054" in entry["tags"]]
84
+ assert len(task_entries) == 1
85
+ assert task_entries[0]["insert"] == "1782144988054"
86
+ assert task_entries[0]["name"] == "Ref lookup task"
87
+ assert "task=refs" in task_entries[0]["url"]
88
+ assert "task_1782144988054" in task_entries[0]["url"]
89
+
90
+ mr_entries = [entry for entry in manifest["refs"] if entry["insert"] == "aaaabbbbccccdddd"]
91
+ assert len(mr_entries) == 1
92
+ assert mr_entries[0]["name"] == "Test MR"
93
+ assert "a=tag" in mr_entries[0]["url"]
94
+
95
+
96
+ def test_board_ref_manifest_route(manifest_env: dict[str, str | TestClient]) -> None:
97
+ client = manifest_env["client"]
98
+ assert isinstance(client, TestClient)
99
+ resp = client.get(
100
+ f"/project/{manifest_env['project']}/board-ref-manifest",
101
+ params={"b": "Tasks"},
102
+ )
103
+ assert resp.status_code == 200
104
+ body = resp.json()
105
+ assert len(body["commits"]) == 2
106
+ assert any(entry["insert"] == "1782144988054" for entry in body["refs"])
@@ -12,14 +12,14 @@ from fastapi.testclient import TestClient
12
12
  from pygitweb.auth_config import AuthConfig, LocalUser
13
13
  from pygitweb.config import settings
14
14
  from pygitweb.conftest import client_as_user
15
- from pygitweb.permissions import Permission, PermissionsMap, permission_key
15
+ from pygitweb.main import app
16
+ from pygitweb.permissions import Permission, permission_key, permissions_with_defaults
16
17
  from pygitweb.tasks import board_router
17
18
 
18
19
  _BOARDS_AUTH = AuthConfig(
19
20
  auth_mode="local",
20
21
  local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
21
- oauth_permissions=PermissionsMap.model_validate({
22
- "*": [],
22
+ oauth_permissions=permissions_with_defaults({
23
23
  permission_key(Permission.BOARDS, "demo"): ["admin"],
24
24
  }),
25
25
  )
@@ -102,6 +102,36 @@ def test_board_create_forbidden_without_grant(board_env: dict[str, str | TestCli
102
102
  assert r.status_code == 403
103
103
 
104
104
 
105
+ def test_board_create_get_forbidden_without_grant(board_env: dict[str, str | TestClient]) -> None:
106
+ with (
107
+ patch.object(settings, "AUTH", True),
108
+ patch("pygitweb.main.auth_config", _BOARDS_AUTH),
109
+ ):
110
+ client = TestClient(app)
111
+ with client_as_user(client, "viewer"):
112
+ r = client.get(
113
+ "/board/create",
114
+ params={"project": board_env["project"]},
115
+ follow_redirects=False,
116
+ )
117
+ assert r.status_code == 403
118
+
119
+
120
+ def test_board_create_get_allowed_with_grant(board_env: dict[str, str | TestClient]) -> None:
121
+ with (
122
+ patch.object(settings, "AUTH", True),
123
+ patch("pygitweb.main.auth_config", _BOARDS_AUTH),
124
+ ):
125
+ client = TestClient(app)
126
+ with client_as_user(client, "admin"):
127
+ r = client.get(
128
+ "/board/create",
129
+ params={"project": board_env["project"]},
130
+ follow_redirects=False,
131
+ )
132
+ assert r.status_code == 303
133
+
134
+
105
135
  def test_board_delete_forbidden_without_grant(board_env: dict[str, str | TestClient]) -> None:
106
136
  client = board_env["client"]
107
137
  assert isinstance(client, TestClient)
@@ -12,6 +12,7 @@ from httpx import ASGITransport
12
12
 
13
13
  from pygitweb.change_queue import CHANGE_QUEUE
14
14
  from pygitweb.config import settings
15
+ from pygitweb.dependencies import is_loopback_host
15
16
  from pygitweb.main import app
16
17
 
17
18
 
@@ -144,3 +145,24 @@ class TestUpdatesLongPoll:
144
145
  return resp.status_code
145
146
 
146
147
  assert asyncio.run(scenario()) == 404
148
+
149
+ def test_notify_rejects_non_loopback_client(self, updates_env: dict[str, str]) -> None:
150
+ del updates_env
151
+
152
+ async def scenario() -> int:
153
+ transport = ASGITransport(app=app, client=("203.0.113.1", 999))
154
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
155
+ resp = await client.post("/_internal/notify", params={"project": "alpha"})
156
+ return resp.status_code
157
+
158
+ assert asyncio.run(scenario()) == 403
159
+
160
+
161
+ class TestLoopbackHost:
162
+ def test_is_loopback_host(self) -> None:
163
+ assert is_loopback_host("127.0.0.1")
164
+ assert is_loopback_host("::1")
165
+ assert is_loopback_host("localhost")
166
+ assert not is_loopback_host("203.0.113.1")
167
+ assert not is_loopback_host(None)
168
+ assert not is_loopback_host("")