pygitweb 0.1.0__py3-none-any.whl

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 (95) hide show
  1. pygitweb/__init__.py +22 -0
  2. pygitweb/__meta__.py +9 -0
  3. pygitweb/actions.py +1303 -0
  4. pygitweb/addproject_permissions_test.py +114 -0
  5. pygitweb/api/__init__.py +0 -0
  6. pygitweb/api/action.py +20 -0
  7. pygitweb/api/plugins/__init__.py +0 -0
  8. pygitweb/api/plugins/project_zip.py +71 -0
  9. pygitweb/api/project.py +67 -0
  10. pygitweb/api/subpage.py +22 -0
  11. pygitweb/auth.py +387 -0
  12. pygitweb/auth.schema.json +106 -0
  13. pygitweb/auth_config.py +243 -0
  14. pygitweb/auth_oauth.py +343 -0
  15. pygitweb/auth_test.py +390 -0
  16. pygitweb/boards_permissions_test.py +113 -0
  17. pygitweb/change_queue.py +110 -0
  18. pygitweb/change_queue_routes_test.py +146 -0
  19. pygitweb/change_queue_test.py +91 -0
  20. pygitweb/comments_permissions_test.py +140 -0
  21. pygitweb/config.py +185 -0
  22. pygitweb/conftest.py +43 -0
  23. pygitweb/dependencies.py +87 -0
  24. pygitweb/formatting.py +182 -0
  25. pygitweb/formatting_test.py +68 -0
  26. pygitweb/git_helpers.py +454 -0
  27. pygitweb/git_helpers_test.py +223 -0
  28. pygitweb/gravatar.py +16 -0
  29. pygitweb/gravatar_test.py +10 -0
  30. pygitweb/hooks_install.py +278 -0
  31. pygitweb/hooks_install_test.py +363 -0
  32. pygitweb/hooks_permissions_test.py +116 -0
  33. pygitweb/main.py +887 -0
  34. pygitweb/merge_requests.py +230 -0
  35. pygitweb/merge_requests_test.py +85 -0
  36. pygitweb/mr_permissions_test.py +148 -0
  37. pygitweb/password_hash.py +74 -0
  38. pygitweb/password_hash_test.py +20 -0
  39. pygitweb/permissions.py +209 -0
  40. pygitweb/permissions_test.py +86 -0
  41. pygitweb/plugin_loader.py +83 -0
  42. pygitweb/projects.py +213 -0
  43. pygitweb/projects_test.py +186 -0
  44. pygitweb/sessions.py +73 -0
  45. pygitweb/settings.py +412 -0
  46. pygitweb/settings.schema.json +73 -0
  47. pygitweb/settings_config.py +122 -0
  48. pygitweb/settings_config_test.py +101 -0
  49. pygitweb/settings_permissions_test.py +76 -0
  50. pygitweb/static/blob-view.js +133 -0
  51. pygitweb/static/board.js +839 -0
  52. pygitweb/static/hook-install.js +53 -0
  53. pygitweb/static/icons/cloud-down.svg +1 -0
  54. pygitweb/static/icons/cloud-exclamation.svg +1 -0
  55. pygitweb/static/icons/cloud-filled.svg +1 -0
  56. pygitweb/static/icons/cloud-storm.svg +1 -0
  57. pygitweb/static/icons/cloud-up.svg +1 -0
  58. pygitweb/static/icons/cloud.svg +1 -0
  59. pygitweb/static/logo.svg +44 -0
  60. pygitweb/static/main.css +1210 -0
  61. pygitweb/static/navbar-auth.js +82 -0
  62. pygitweb/static/readme-render.js +174 -0
  63. pygitweb/static/search-page.js +139 -0
  64. pygitweb/static/summary-ref-switcher.js +130 -0
  65. pygitweb/static/themes/solarized.css +34 -0
  66. pygitweb/tasks.py +595 -0
  67. pygitweb/tasks_permissions_test.py +137 -0
  68. pygitweb/tasks_routes_test.py +430 -0
  69. pygitweb/templates/addproject.html +174 -0
  70. pygitweb/templates/blobdiff.html +57 -0
  71. pygitweb/templates/board.html +31 -0
  72. pygitweb/templates/board_card.html +44 -0
  73. pygitweb/templates/commitdiff.html +57 -0
  74. pygitweb/templates/hooks_summary_cell.html +37 -0
  75. pygitweb/templates/login.html +37 -0
  76. pygitweb/templates/merge_request_summary_cell.html +18 -0
  77. pygitweb/templates/merge_request_tag.html +90 -0
  78. pygitweb/templates/pagination.html +44 -0
  79. pygitweb/templates/preamble.html +89 -0
  80. pygitweb/templates/project_settings.html +13 -0
  81. pygitweb/templates/pygit2_settings.html +12 -0
  82. pygitweb/templates/pygitweb_settings.html +12 -0
  83. pygitweb/templates/search.html +77 -0
  84. pygitweb/templates/settings_form_field.html +27 -0
  85. pygitweb/templates/table.html +15 -0
  86. pygitweb/templates/user_profile.html +10 -0
  87. pygitweb/templates_env.py +21 -0
  88. pygitweb/timeline_cache.py +243 -0
  89. pygitweb/validation.py +75 -0
  90. pygitweb/validation_test.py +167 -0
  91. pygitweb-0.1.0.dist-info/METADATA +165 -0
  92. pygitweb-0.1.0.dist-info/RECORD +95 -0
  93. pygitweb-0.1.0.dist-info/WHEEL +5 -0
  94. pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
  95. pygitweb-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,209 @@
1
+ """
2
+ PyGitWeb permission keys and grant checks.
3
+
4
+ ``auth.json`` field ``oauth_permissions`` maps permission keys to principal lists
5
+ (typically OAuth email addresses). The special key ``*`` lists principals granted
6
+ every permission.
7
+
8
+ Scoped keys use a project segment that supports ``fnmatch`` globs (``*``, ``?``,
9
+ ``[seq]``), e.g. ``pgw.tasks.foo/*`` or ``pgw.boards.*``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import fnmatch
15
+ import re
16
+ from enum import StrEnum
17
+ from typing import Self
18
+
19
+ from pydantic import BaseModel, ConfigDict, RootModel, model_validator
20
+
21
+ WILDCARD_PERMISSION_KEY = "*"
22
+
23
+ # Known permission families (suffix after ``pgw.``).
24
+ PERMISSION_ADD_PROJECTS = "pgw.addprojects"
25
+ PERMISSION_SETTINGS = "pgw.settings"
26
+
27
+ _SCOPED_PREFIX = re.compile(r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge)\.(.+)$")
28
+
29
+
30
+ class Permission(StrEnum):
31
+ """Logical permission checked by route dependencies."""
32
+
33
+ BOARDS = "boards"
34
+ TASKS = "tasks"
35
+ COMMENTS = "comments"
36
+ HOOKS = "hooks"
37
+ MR_CREATE = "mr.create"
38
+ MR_MERGE = "mr.merge"
39
+ ADD_PROJECTS = "addprojects"
40
+ SETTINGS = "settings"
41
+
42
+ @property
43
+ def global_key(self) -> str | None:
44
+ if self is Permission.ADD_PROJECTS:
45
+ return PERMISSION_ADD_PROJECTS
46
+ if self is Permission.SETTINGS:
47
+ return PERMISSION_SETTINGS
48
+ return None
49
+
50
+ @property
51
+ def scoped_prefix(self) -> str | None:
52
+ if self in (
53
+ Permission.BOARDS,
54
+ Permission.TASKS,
55
+ Permission.COMMENTS,
56
+ Permission.HOOKS,
57
+ Permission.MR_CREATE,
58
+ Permission.MR_MERGE,
59
+ ):
60
+ return f"pgw.{self.value}."
61
+ return None
62
+
63
+
64
+ class ParsedPermissionKey(BaseModel):
65
+ model_config = ConfigDict(frozen=True)
66
+
67
+ raw: str
68
+ permission: Permission
69
+ project_scope: str | None = None
70
+
71
+ @property
72
+ def is_global(self) -> bool:
73
+ return self.project_scope is None
74
+
75
+
76
+ def parse_permission_key(key: str) -> ParsedPermissionKey | None:
77
+ """Return a parsed key, or ``None`` if ``key`` is not a known permission name."""
78
+ raw = key.strip()
79
+ if not raw or raw == WILDCARD_PERMISSION_KEY:
80
+ return None
81
+ if raw == PERMISSION_ADD_PROJECTS:
82
+ return ParsedPermissionKey(raw=raw, permission=Permission.ADD_PROJECTS)
83
+ if raw == PERMISSION_SETTINGS:
84
+ return ParsedPermissionKey(raw=raw, permission=Permission.SETTINGS)
85
+ m = _SCOPED_PREFIX.match(raw)
86
+ if not m:
87
+ return None
88
+ kind, scope = m.group(1), m.group(2)
89
+ try:
90
+ perm = Permission(kind)
91
+ except ValueError:
92
+ return None
93
+ if not scope:
94
+ return None
95
+ return ParsedPermissionKey(raw=raw, permission=perm, project_scope=scope)
96
+
97
+
98
+ def permission_key(permission: Permission, project: str) -> str:
99
+ """Build the canonical scoped permission key for a project."""
100
+ prefix = permission.scoped_prefix
101
+ if prefix is None:
102
+ raise ValueError(f"{permission!r} is not project-scoped")
103
+ return f"{prefix}{project}"
104
+
105
+
106
+ def project_matches_scope(project: str, scope: str) -> bool:
107
+ if scope == WILDCARD_PERMISSION_KEY:
108
+ return True
109
+ return fnmatch.fnmatchcase(project, scope)
110
+
111
+
112
+ class PermissionsMap(RootModel[dict[str, list[str]]]):
113
+ """Validated ``oauth_permissions`` object from auth.json."""
114
+
115
+ root: dict[str, list[str]]
116
+
117
+ @model_validator(mode="before")
118
+ @classmethod
119
+ def _coerce_root(cls, v: object) -> object:
120
+ if isinstance(v, PermissionsMap):
121
+ return v.root
122
+ if v is None:
123
+ return {WILDCARD_PERMISSION_KEY: []}
124
+ if not isinstance(v, dict):
125
+ raise TypeError("oauth_permissions must be an object")
126
+ out: dict[str, list[str]] = {}
127
+ for key, principals in v.items():
128
+ if not isinstance(key, str):
129
+ raise TypeError("permission keys must be strings")
130
+ if key != WILDCARD_PERMISSION_KEY and parse_permission_key(key) is None:
131
+ raise ValueError(f"unknown permission key: {key!r}")
132
+ if not isinstance(principals, list):
133
+ raise TypeError(f"oauth_permissions[{key!r}] must be a list")
134
+ normalized: list[str] = []
135
+ for p in principals:
136
+ if not isinstance(p, str):
137
+ raise TypeError(f"oauth_permissions[{key!r}] entries must be strings")
138
+ s = p.strip()
139
+ if s:
140
+ normalized.append(s)
141
+ out[key] = normalized
142
+ if WILDCARD_PERMISSION_KEY not in out:
143
+ out = {**out, WILDCARD_PERMISSION_KEY: []}
144
+ return out
145
+
146
+ @classmethod
147
+ def empty(cls) -> Self:
148
+ return cls({WILDCARD_PERMISSION_KEY: []})
149
+
150
+ def principals_for_key(self, key: str) -> frozenset[str]:
151
+ return frozenset(self.root.get(key, []))
152
+
153
+ def superuser_principals(self) -> frozenset[str]:
154
+ return self.principals_for_key(WILDCARD_PERMISSION_KEY)
155
+
156
+ def matching_scoped_principals(self, permission: Permission, project: str) -> frozenset[str]:
157
+ prefix = permission.scoped_prefix
158
+ if prefix is None:
159
+ return frozenset()
160
+ acc: set[str] = set()
161
+ for key, principals in self.root.items():
162
+ if key == WILDCARD_PERMISSION_KEY:
163
+ continue
164
+ parsed = parse_permission_key(key)
165
+ if parsed is None or parsed.permission is not permission or parsed.project_scope is None:
166
+ continue
167
+ if project_matches_scope(project, parsed.project_scope):
168
+ acc.update(principals)
169
+ return frozenset(acc)
170
+
171
+ def principals_with_permission(self, permission: Permission, project: str | None = None) -> frozenset[str]:
172
+ acc = set(self.superuser_principals())
173
+ global_key = permission.global_key
174
+ if global_key is not None:
175
+ acc.update(self.principals_for_key(global_key))
176
+ return frozenset(acc)
177
+ if project is None:
178
+ return frozenset(acc)
179
+ acc.update(self.matching_scoped_principals(permission, project))
180
+ return frozenset(acc)
181
+
182
+ def has_permission(self, principal: str | None, permission: Permission, project: str | None = None) -> bool:
183
+ if not principal or not principal.strip():
184
+ return False
185
+ normalized = principal.strip()
186
+ allowed = self.principals_with_permission(permission, project)
187
+ if normalized in allowed:
188
+ return True
189
+ # Case-insensitive match for email principals.
190
+ lower = normalized.lower()
191
+ return any(p.lower() == lower for p in allowed)
192
+
193
+
194
+ def normalize_oauth_permissions(raw: object) -> dict[str, list[str]]:
195
+ return PermissionsMap.model_validate(raw).root
196
+
197
+
198
+ class PermissionPrincipal(BaseModel):
199
+ model_config = ConfigDict(frozen=True)
200
+
201
+ username: str
202
+ email: str | None = None
203
+
204
+ @property
205
+ def identity(self) -> str:
206
+ """Principal string used for grant lookup (email when present, else username)."""
207
+ if self.email and self.email.strip():
208
+ return self.email.strip()
209
+ return self.username.strip()
@@ -0,0 +1,86 @@
1
+ import pytest
2
+
3
+ from pygitweb.permissions import (
4
+ PERMISSION_ADD_PROJECTS,
5
+ PERMISSION_SETTINGS,
6
+ Permission,
7
+ PermissionsMap,
8
+ parse_permission_key,
9
+ permission_key,
10
+ project_matches_scope,
11
+ )
12
+
13
+
14
+ def test_parse_global_keys() -> None:
15
+ assert parse_permission_key(PERMISSION_ADD_PROJECTS) is not None
16
+ assert parse_permission_key(PERMISSION_SETTINGS) is not None
17
+ assert parse_permission_key("pgw.unknown") is None
18
+ assert parse_permission_key("*") is None
19
+
20
+
21
+ def test_parse_scoped_key() -> None:
22
+ parsed = parse_permission_key("pgw.tasks.my/repo")
23
+ assert parsed is not None
24
+ assert parsed.permission is Permission.TASKS
25
+ assert parsed.project_scope == "my/repo"
26
+
27
+
28
+ def test_permission_key_roundtrip() -> None:
29
+ assert permission_key(Permission.BOARDS, "foo") == "pgw.boards.foo"
30
+ assert permission_key(Permission.MR_CREATE, "foo") == "pgw.mr.create.foo"
31
+ assert permission_key(Permission.MR_MERGE, "foo") == "pgw.mr.merge.foo"
32
+ assert permission_key(Permission.HOOKS, "foo") == "pgw.hooks.foo"
33
+
34
+
35
+ def test_parse_mr_scoped_keys() -> None:
36
+ create = parse_permission_key("pgw.mr.create.demo")
37
+ assert create is not None
38
+ assert create.permission is Permission.MR_CREATE
39
+ merge = parse_permission_key("pgw.mr.merge.demo")
40
+ assert merge is not None
41
+ assert merge.permission is Permission.MR_MERGE
42
+
43
+
44
+ def test_project_glob() -> None:
45
+ assert project_matches_scope("team/app", "team/*")
46
+ assert not project_matches_scope("other/app", "team/*")
47
+ assert project_matches_scope("anything", "*")
48
+
49
+
50
+ def test_superuser_grants_all() -> None:
51
+ m = PermissionsMap.model_validate({
52
+ "*": ["admin@example.com"],
53
+ "pgw.tasks.secret": ["other@example.com"],
54
+ })
55
+ assert m.has_permission("admin@example.com", Permission.TASKS, "secret")
56
+ assert m.has_permission("admin@example.com", Permission.SETTINGS)
57
+ assert m.has_permission("other@example.com", Permission.TASKS, "secret")
58
+ assert not m.has_permission("other@example.com", Permission.SETTINGS)
59
+ assert not m.has_permission("other@example.com", Permission.TASKS, "public")
60
+
61
+
62
+ def test_scoped_grant() -> None:
63
+ m = PermissionsMap.model_validate({
64
+ "*": [],
65
+ "pgw.comments.proj-a": ["reader@example.com"],
66
+ "pgw.boards.team/*": ["lead@example.com"],
67
+ })
68
+ assert m.has_permission("reader@example.com", Permission.COMMENTS, "proj-a")
69
+ assert not m.has_permission("reader@example.com", Permission.TASKS, "proj-a")
70
+ assert m.has_permission("lead@example.com", Permission.BOARDS, "team/widget")
71
+ assert not m.has_permission("lead@example.com", Permission.BOARDS, "other/widget")
72
+
73
+
74
+ def test_email_case_insensitive() -> None:
75
+ m = PermissionsMap.model_validate({"*": [], PERMISSION_SETTINGS: ["Admin@Example.COM"]})
76
+ assert m.has_permission("admin@example.com", Permission.SETTINGS)
77
+
78
+
79
+ def test_rejects_unknown_key() -> None:
80
+ with pytest.raises(ValueError, match="unknown permission key"):
81
+ PermissionsMap.model_validate({"pgw.notreal": ["a@b.com"]})
82
+
83
+
84
+ def test_ensures_wildcard_key() -> None:
85
+ m = PermissionsMap.model_validate({PERMISSION_ADD_PROJECTS: ["u@x.com"]})
86
+ assert "*" in m.root
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ import logging
5
+ from collections.abc import Iterable
6
+
7
+ from pygitweb.api.action import Action
8
+ from pygitweb.api.subpage import Subpage
9
+ from pygitweb.config import ACTIONS as BUILTIN_ACTIONS
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ SUBPAGE_GROUP = "pygitweb.subpages"
14
+ ACTION_GROUP = "pygitweb.actions"
15
+
16
+
17
+ def _entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
18
+ return importlib.metadata.entry_points().select(group=group)
19
+
20
+
21
+ def load_subpages() -> list[Subpage]:
22
+ out: list[Subpage] = []
23
+ seen_names: set[str] = set()
24
+ for ep in _entry_points(SUBPAGE_GROUP):
25
+ try:
26
+ loaded = ep.load()
27
+ except Exception:
28
+ logger.exception("pygitweb: failed to load subpage entry point %r", ep.name)
29
+ continue
30
+ if not isinstance(loaded, type) or not issubclass(loaded, Subpage):
31
+ logger.error(
32
+ "pygitweb: entry point %r must be a Subpage subclass, got %r",
33
+ ep.name,
34
+ loaded,
35
+ )
36
+ continue
37
+ try:
38
+ instance = loaded()
39
+ except Exception:
40
+ logger.exception("pygitweb: failed to instantiate subpage %r", ep.name)
41
+ continue
42
+ key = instance.subpage_name()
43
+ if key in seen_names:
44
+ logger.warning("pygitweb: duplicate subpage %r from %r; skipping", key, ep.name)
45
+ continue
46
+ seen_names.add(key)
47
+ out.append(instance)
48
+ return out
49
+
50
+
51
+ def load_plugin_actions() -> dict[str, Action]:
52
+ out: dict[str, Action] = {}
53
+ for ep in _entry_points(ACTION_GROUP):
54
+ try:
55
+ loaded = ep.load()
56
+ except Exception:
57
+ logger.exception("pygitweb: failed to load action entry point %r", ep.name)
58
+ continue
59
+ if not isinstance(loaded, type) or not issubclass(loaded, Action):
60
+ logger.error(
61
+ "pygitweb: entry point %r must be an Action subclass, got %r",
62
+ ep.name,
63
+ loaded,
64
+ )
65
+ continue
66
+ try:
67
+ instance = loaded()
68
+ except Exception:
69
+ logger.exception("pygitweb: failed to instantiate action %r", ep.name)
70
+ continue
71
+ name = instance.action_name()
72
+ if name in BUILTIN_ACTIONS:
73
+ logger.warning(
74
+ "pygitweb: plugin action %r conflicts with built-in; skipping entry %r",
75
+ name,
76
+ ep.name,
77
+ )
78
+ continue
79
+ if name in out:
80
+ logger.warning("pygitweb: duplicate plugin action %r; skipping entry %r", name, ep.name)
81
+ continue
82
+ out[name] = instance
83
+ return out
pygitweb/projects.py ADDED
@@ -0,0 +1,213 @@
1
+ """
2
+ Project list: get_projects_list, search_projects_list, project_in_list,
3
+ get_project_owner, get_project_list_from_file, get_last_activity.
4
+ Ported from gitweb/gitweb.perl.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ import pygit2
15
+
16
+ from pygitweb.config import settings
17
+ from pygitweb.validation import check_export_ok
18
+
19
+
20
+ def project_in_list(
21
+ project: str,
22
+ get_projects_list_fn: Callable[[], list[dict[str, Any]]],
23
+ ) -> bool:
24
+ """True if project appears in project list. Port of project_in_list."""
25
+ lst = get_projects_list_fn()
26
+ return any(p.get("path") == project for p in lst)
27
+
28
+
29
+ def _find_projects_in_dir(
30
+ root: str,
31
+ prefix_len: int,
32
+ prefix_depth: int,
33
+ maxdepth: int,
34
+ export_ok: str,
35
+ export_auth_hook: Callable[[str], bool] | None,
36
+ skip_export_check: bool = False,
37
+ ) -> list[dict[str, Any]]:
38
+ result = []
39
+ for dirpath, dirnames, _ in os.walk(root, topdown=True):
40
+ rel = os.path.relpath(dirpath, root)
41
+ depth = 0 if rel == "." else rel.count(os.sep) + 1
42
+ if depth >= maxdepth:
43
+ dirnames.clear()
44
+ continue
45
+ for d in list(dirnames):
46
+ path = os.path.join(dirpath, d)
47
+ if not os.path.isdir(path):
48
+ continue
49
+ try:
50
+ if not os.access(path, os.X_OK):
51
+ continue
52
+ except OSError:
53
+ continue
54
+ repo = pygit2.discover_repository(path)
55
+ if not repo:
56
+ continue
57
+ project_path = os.path.relpath(path, settings.PROJECTROOT)
58
+ project_path = project_path.replace("\\", "/")
59
+ git_dir = os.path.join(settings.PROJECTROOT, project_path)
60
+ if not skip_export_check and not check_export_ok(git_dir, export_ok, export_auth_hook):
61
+ continue
62
+ result.append({"path": project_path})
63
+ dirnames.remove(d)
64
+ return result
65
+
66
+
67
+ def git_get_projects_list(
68
+ filter_path: str = "",
69
+ paranoid: bool = False,
70
+ projectroot: str | None = None,
71
+ projects_list: str | None = None,
72
+ project_maxdepth: int | None = None,
73
+ export_ok: str = "",
74
+ export_auth_hook: Callable[[str], bool] | None = None,
75
+ ) -> list[dict[str, Any]]:
76
+ """List projects from directory scan or file. Port of git_get_projects_list."""
77
+ projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
78
+ projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
79
+ project_maxdepth = project_maxdepth if project_maxdepth is not None else settings.PROJECT_MAXDEPTH
80
+ if os.path.isdir(projects_list):
81
+ root = projects_list.rstrip("/")
82
+ prefix_len = len(root) + 1
83
+ prefix_depth = root.count(os.sep)
84
+ if filter_path and not paranoid:
85
+ root = os.path.join(root, filter_path).rstrip("/")
86
+ result = _find_projects_in_dir(
87
+ root,
88
+ prefix_len,
89
+ prefix_depth,
90
+ project_maxdepth,
91
+ export_ok,
92
+ export_auth_hook,
93
+ skip_export_check=settings.LIST_ALL,
94
+ )
95
+ if filter_path and paranoid:
96
+ result = [p for p in result if p["path"].startswith(filter_path + "/")]
97
+ return result
98
+ if os.path.isfile(projects_list):
99
+ from urllib.parse import unquote
100
+
101
+ result = []
102
+ with open(projects_list) as f:
103
+ for line in f:
104
+ line = line.strip()
105
+ if not line:
106
+ continue
107
+ parts = line.split(None, 1)
108
+ path = unquote(parts[0]) if parts else ""
109
+ owner = unquote(parts[1]) if len(parts) > 1 else None
110
+ if not path:
111
+ continue
112
+ if filter_path and not path.startswith(filter_path + "/"):
113
+ continue
114
+ git_dir = os.path.join(projectroot, path)
115
+ if not settings.LIST_ALL and not check_export_ok(git_dir, export_ok, export_auth_hook):
116
+ continue
117
+ pr = {"path": path}
118
+ if owner:
119
+ pr["owner"] = owner
120
+ result.append(pr)
121
+ return result
122
+ return []
123
+
124
+
125
+ _gitweb_project_owner: dict[str, str] | None = None
126
+
127
+
128
+ def git_get_project_list_from_file(
129
+ projects_list: str | None = None,
130
+ projectroot: str | None = None,
131
+ ) -> dict[str, str]:
132
+ """Load project -> owner from file. Port of git_get_project_list_from_file."""
133
+ global _gitweb_project_owner
134
+ if _gitweb_project_owner is not None:
135
+ return _gitweb_project_owner
136
+ projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
137
+ _gitweb_project_owner = {}
138
+ if os.path.isfile(projects_list):
139
+ from urllib.parse import unquote
140
+
141
+ with open(projects_list) as f:
142
+ for line in f:
143
+ line = line.strip()
144
+ if not line:
145
+ continue
146
+ parts = line.split(None, 1)
147
+ path = unquote(parts[0]) if parts else ""
148
+ owner = unquote(parts[1]) if len(parts) > 1 else ""
149
+ if path:
150
+ _gitweb_project_owner[path] = owner
151
+ return _gitweb_project_owner
152
+
153
+
154
+ def git_get_project_owner(
155
+ project: str,
156
+ projectroot: str | None = None,
157
+ get_project_config: Callable[[str, str], Any] | None = None,
158
+ ) -> str | None:
159
+ """Owner from list file or config or file ownership. Port of git_get_project_owner."""
160
+ if not project:
161
+ return None
162
+ projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
163
+ owners = git_get_project_list_from_file(settings.PROJECTS_LIST, projectroot)
164
+ if project in owners and owners[project]:
165
+ return owners[project]
166
+ if get_project_config:
167
+ val = get_project_config(project, "owner")
168
+ if val:
169
+ return val[0] if isinstance(val, list) else val
170
+ return None
171
+
172
+
173
+ def git_get_last_activity(project: str, projectroot: str | None = None) -> int | None:
174
+ """Last commit timestamp for project. Port of git_get_last_activity."""
175
+ try:
176
+ repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
177
+ if not repo.head:
178
+ return None
179
+ commit = repo[repo.head.target]
180
+ if isinstance(commit, pygit2.Commit):
181
+ return commit.committer.time
182
+ except (KeyError, pygit2.GitError, OSError):
183
+ return None
184
+ return None
185
+
186
+
187
+ def search_projects_list(
188
+ projlist: list[dict[str, Any]],
189
+ tagfilter: str | None = None,
190
+ search_regexp: str | None = None,
191
+ fill_project_list_info: Callable[..., None] | None = None,
192
+ ) -> list[dict[str, Any]]:
193
+ """Filter by tag or search regex. Port of search_projects_list."""
194
+ if not tagfilter and not search_regexp:
195
+ return projlist
196
+ if fill_project_list_info:
197
+ fill_project_list_info(projlist, tagfilter=tagfilter, search_re=search_regexp)
198
+ result = []
199
+ for pr in projlist:
200
+ if tagfilter:
201
+ ctags = pr.get("ctags") or {}
202
+ if not any(k.lower() == tagfilter.lower() for k in ctags):
203
+ continue
204
+ if search_regexp:
205
+ try:
206
+ rex = re.compile(search_regexp)
207
+ except re.error:
208
+ continue
209
+ descr = (pr.get("descr_long") or "") + (pr.get("path") or "")
210
+ if not rex.search(descr):
211
+ continue
212
+ result.append(pr)
213
+ return result