pygitweb 0.2.1__tar.gz → 0.2.2__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.
- {pygitweb-0.2.1/pygitweb.egg-info → pygitweb-0.2.2}/PKG-INFO +1 -1
- {pygitweb-0.2.1 → pygitweb-0.2.2}/actions.py +8 -3
- {pygitweb-0.2.1 → pygitweb-0.2.2}/auth.py +32 -4
- {pygitweb-0.2.1 → pygitweb-0.2.2}/auth.schema.json +5 -1
- {pygitweb-0.2.1 → pygitweb-0.2.2}/auth_config.py +7 -4
- {pygitweb-0.2.1 → pygitweb-0.2.2}/auth_oauth.py +1 -2
- {pygitweb-0.2.1 → pygitweb-0.2.2}/auth_test.py +113 -6
- {pygitweb-0.2.1 → pygitweb-0.2.2}/hooks_install.py +1 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/main.py +2 -1
- {pygitweb-0.2.1 → pygitweb-0.2.2}/permissions.py +52 -1
- {pygitweb-0.2.1 → pygitweb-0.2.2}/permissions_test.py +61 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2/pygitweb.egg-info}/PKG-INFO +1 -1
- {pygitweb-0.2.1 → pygitweb-0.2.2}/pyproject.toml +1 -1
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/board.js +13 -1
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/main.css +6 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/tasks.py +6 -1
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/board_card.html +40 -7
- {pygitweb-0.2.1 → pygitweb-0.2.2}/LICENSE +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/README.md +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/__init__.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/__meta__.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/addproject_permissions_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/api/__init__.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/api/action.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/api/plugins/__init__.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/api/plugins/project_zip.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/api/project.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/api/subpage.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/board_refs.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/board_refs_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/boards_permissions_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/change_queue.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/change_queue_routes_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/change_queue_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/comments_permissions_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/config.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/conftest.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/dependencies.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/formatting.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/formatting_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/git_helpers.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/git_helpers_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/gravatar.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/gravatar_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/hooks_install_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/hooks_permissions_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/merge_requests.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/merge_requests_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/mr_permissions_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/password_hash.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/password_hash_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/plugin_loader.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/project_list_routes_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/projects.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/projects_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/pygitweb.egg-info/SOURCES.txt +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/pygitweb.egg-info/dependency_links.txt +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/pygitweb.egg-info/requires.txt +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/pygitweb.egg-info/top_level.txt +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/sessions.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/settings.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/settings.schema.json +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/settings_config.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/settings_config_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/settings_permissions_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/setup.cfg +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/shutdown.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/smart_http.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/smart_http_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/blob-view.js +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/board-refs.js +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/hook-install.js +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-down.svg +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-exclamation.svg +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-filled.svg +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-storm.svg +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-up.svg +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud.svg +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/logo.svg +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/navbar-auth.js +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/readme-render.js +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/search-page.js +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/summary-ref-switcher.js +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/static/themes/solarized.css +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/tasks_permissions_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/tasks_routes_test.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/addproject.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/blobdiff.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/board.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/commitdiff.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/hooks_summary_cell.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/login.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/merge_request_summary_cell.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/merge_request_tag.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/pagination.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/preamble.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/project_settings.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/pygit2_settings.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/pygitweb_settings.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/search.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/settings_form_field.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/shutdown_ack.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/table.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/user_profile.html +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/templates_env.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/timeline_cache.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/validation.py +0 -0
- {pygitweb-0.2.1 → pygitweb-0.2.2}/validation_test.py +0 -0
|
@@ -20,6 +20,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Res
|
|
|
20
20
|
from python_ripgrep import PySortMode, PySortModeKind
|
|
21
21
|
from python_ripgrep import search as rg_search
|
|
22
22
|
|
|
23
|
+
from pygitweb.auth import can_read_branch
|
|
23
24
|
from pygitweb.config import BLOB_LANG, settings
|
|
24
25
|
from pygitweb.formatting import age_string, sanitize, to_utf8
|
|
25
26
|
from pygitweb.git_helpers import (
|
|
@@ -972,13 +973,17 @@ def git_history(
|
|
|
972
973
|
)
|
|
973
974
|
|
|
974
975
|
|
|
975
|
-
def git_heads(project: str) -> HTMLResponse:
|
|
976
|
+
def git_heads(project: str, token: str | None) -> HTMLResponse:
|
|
976
977
|
"""Heads (branches) list page. Port of git_heads."""
|
|
977
978
|
heads_list = git_get_heads_list(project)
|
|
978
|
-
if
|
|
979
|
+
if settings.AUTH:
|
|
980
|
+
visible_heads = [(name, ref, oid) for name, ref, oid in heads_list if can_read_branch(project, ref, token)]
|
|
981
|
+
else:
|
|
982
|
+
visible_heads = heads_list
|
|
983
|
+
if not visible_heads:
|
|
979
984
|
raise HTTPException(status_code=404, detail="No heads found")
|
|
980
985
|
rows = []
|
|
981
|
-
for name, _ref, oid in
|
|
986
|
+
for name, _ref, oid in visible_heads:
|
|
982
987
|
oid_short = oid[:7] if oid else ""
|
|
983
988
|
commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
|
|
984
989
|
tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
|
|
@@ -32,7 +32,7 @@ from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, oauth_router
|
|
|
32
32
|
from pygitweb.config import settings
|
|
33
33
|
from pygitweb.gravatar import gravatar_url
|
|
34
34
|
from pygitweb.password_hash import verify_password
|
|
35
|
-
from pygitweb.permissions import Permission, PermissionPrincipal
|
|
35
|
+
from pygitweb.permissions import Permission, PermissionPrincipal, has_branch_read_permission
|
|
36
36
|
from pygitweb.projects import git_get_project_owner
|
|
37
37
|
from pygitweb.sessions import create_session, get_session, revoke_session
|
|
38
38
|
from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
|
|
@@ -65,6 +65,23 @@ def principal_from_session(token: str | None) -> PermissionPrincipal | None:
|
|
|
65
65
|
return PermissionPrincipal(username=rec.username, email=rec.email)
|
|
66
66
|
|
|
67
67
|
|
|
68
|
+
def _anonymous_principal_for_permissions() -> PermissionPrincipal | None:
|
|
69
|
+
for entry in auth_config.local_users:
|
|
70
|
+
if getattr(entry, "assume_anonymous", False):
|
|
71
|
+
return PermissionPrincipal(username=entry.user, email=entry.email)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _principal_identity_for_permissions(token: str | None) -> str | None:
|
|
76
|
+
principal = principal_from_session(token)
|
|
77
|
+
if principal is not None:
|
|
78
|
+
return principal.identity
|
|
79
|
+
anonymous = _anonymous_principal_for_permissions()
|
|
80
|
+
if anonymous is not None:
|
|
81
|
+
return anonymous.identity
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
68
85
|
def has_permission(
|
|
69
86
|
permission: Permission,
|
|
70
87
|
project: str | None = None,
|
|
@@ -73,10 +90,10 @@ def has_permission(
|
|
|
73
90
|
) -> bool:
|
|
74
91
|
if not settings.AUTH:
|
|
75
92
|
return True
|
|
76
|
-
|
|
77
|
-
if
|
|
93
|
+
identity = _principal_identity_for_permissions(token)
|
|
94
|
+
if identity is None:
|
|
78
95
|
return False
|
|
79
|
-
return auth_config.oauth_permissions.has_permission(
|
|
96
|
+
return auth_config.oauth_permissions.has_permission(identity, permission, project)
|
|
80
97
|
|
|
81
98
|
|
|
82
99
|
def _owner_matches(project: str, token: str | None) -> bool:
|
|
@@ -111,6 +128,17 @@ def can_write_project(project: str, token: str | None) -> bool:
|
|
|
111
128
|
return has_permission(Permission.WRITE, project, token=token)
|
|
112
129
|
|
|
113
130
|
|
|
131
|
+
def can_read_branch(project: str, ref_name: str, token: str | None) -> bool:
|
|
132
|
+
if not settings.AUTH:
|
|
133
|
+
return True
|
|
134
|
+
if _owner_matches(project, token):
|
|
135
|
+
return True
|
|
136
|
+
identity = _principal_identity_for_permissions(token)
|
|
137
|
+
if identity is None:
|
|
138
|
+
return False
|
|
139
|
+
return has_branch_read_permission(auth_config.oauth_permissions, identity, project, ref_name)
|
|
140
|
+
|
|
141
|
+
|
|
114
142
|
def ensure_read_project(project: str, token: str | None) -> None:
|
|
115
143
|
if can_read_project(project, token):
|
|
116
144
|
return
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"additionalProperties": {
|
|
61
61
|
"type": "array",
|
|
62
62
|
"items": { "type": "string" },
|
|
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."
|
|
63
|
+
"description": "Scoped keys: pgw.read.{scope}, pgw.write.{scope}, pgw.read_branch.{scope}, pgw.write_branch.{scope}, pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project or project/branch path). Principal * matches any authenticated user."
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"local_users": {
|
|
@@ -94,6 +94,10 @@
|
|
|
94
94
|
"type": "string",
|
|
95
95
|
"format": "email",
|
|
96
96
|
"description": "Optional e-mail for Gravatar and permission grants by principal e-mail."
|
|
97
|
+
},
|
|
98
|
+
"assume_anonymous": {
|
|
99
|
+
"type": "boolean",
|
|
100
|
+
"description": "If true, unauthenticated requests assume this user for read permissions (e.g. public viewer role)."
|
|
97
101
|
}
|
|
98
102
|
}
|
|
99
103
|
}
|
|
@@ -12,14 +12,16 @@ import secrets
|
|
|
12
12
|
import stat
|
|
13
13
|
import sys
|
|
14
14
|
from pathlib import Path
|
|
15
|
-
from typing import Literal
|
|
15
|
+
from typing import TYPE_CHECKING, Literal
|
|
16
16
|
|
|
17
17
|
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
|
18
18
|
|
|
19
|
-
from pygitweb.config import Settings
|
|
20
19
|
from pygitweb.password_hash import hash_password
|
|
21
20
|
from pygitweb.permissions import PermissionsMap
|
|
22
21
|
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pygitweb.config import Settings
|
|
24
|
+
|
|
23
25
|
DEFAULT_AUTH_CONFIG_PATH = Path.home() / ".pygitweb" / "auth.json"
|
|
24
26
|
_AUTH_CONFIG_MODE = 0o600
|
|
25
27
|
|
|
@@ -70,6 +72,7 @@ class LocalUser(BaseModel):
|
|
|
70
72
|
password: str | None = Field(default=None, alias="pass", serialization_alias="pass")
|
|
71
73
|
pass_hash: str = ""
|
|
72
74
|
email: str | None = None
|
|
75
|
+
assume_anonymous: bool = False
|
|
73
76
|
|
|
74
77
|
@field_validator("email", mode="before")
|
|
75
78
|
@classmethod
|
|
@@ -91,7 +94,7 @@ class AuthConfig(BaseModel):
|
|
|
91
94
|
oauth_client_id: str = ""
|
|
92
95
|
oauth_client_secret: str = ""
|
|
93
96
|
oauth_redirect_uri: str = ""
|
|
94
|
-
oauth_username_from: OAuthUsernameFrom = "
|
|
97
|
+
oauth_username_from: OAuthUsernameFrom = "preffered_username"
|
|
95
98
|
oauth_allowed_emails: list[str] = Field(default_factory=list)
|
|
96
99
|
oauth_permissions: PermissionsMap = Field(default_factory=PermissionsMap.empty)
|
|
97
100
|
local_users: list[LocalUser] = Field(default_factory=list)
|
|
@@ -132,7 +135,7 @@ def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
|
|
|
132
135
|
oauth_client_id="",
|
|
133
136
|
oauth_client_secret="",
|
|
134
137
|
oauth_redirect_uri="",
|
|
135
|
-
oauth_username_from="
|
|
138
|
+
oauth_username_from="preffered_username",
|
|
136
139
|
oauth_allowed_emails=[],
|
|
137
140
|
oauth_permissions=PermissionsMap.default_access(),
|
|
138
141
|
local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
|
|
@@ -323,10 +323,9 @@ async def oauth_callback(
|
|
|
323
323
|
if not is_oauth_email_allowed(auth_config, email):
|
|
324
324
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email is not allowed to sign in")
|
|
325
325
|
username = oauth_username_from_profile(auth_config, profile)
|
|
326
|
-
subject = _profile_subject(profile)
|
|
327
326
|
sid = create_session(
|
|
328
327
|
username,
|
|
329
|
-
subject=
|
|
328
|
+
subject=username,
|
|
330
329
|
email=email,
|
|
331
330
|
auth_method="oauth",
|
|
332
331
|
)
|
|
@@ -5,10 +5,13 @@ import sys
|
|
|
5
5
|
from collections.abc import Generator
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from unittest.mock import AsyncMock, patch
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
8
9
|
|
|
10
|
+
import httpx
|
|
9
11
|
import pytest
|
|
10
|
-
from fastapi import HTTPException
|
|
12
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
11
13
|
from fastapi.testclient import TestClient
|
|
14
|
+
from httpx import ASGITransport
|
|
12
15
|
|
|
13
16
|
from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
|
|
14
17
|
from pygitweb.auth_config import AuthConfig, LocalUser, init_auth_config, write_auth_config
|
|
@@ -29,6 +32,8 @@ from pygitweb.password_hash import hash_password, verify_password
|
|
|
29
32
|
from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
|
|
30
33
|
from pygitweb.sessions import get_session
|
|
31
34
|
|
|
35
|
+
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
|
36
|
+
|
|
32
37
|
|
|
33
38
|
class _DummyResponse:
|
|
34
39
|
def __init__(self, payload: object, is_success: bool = True) -> None:
|
|
@@ -48,6 +53,47 @@ _LOCAL_ADMIN = AuthConfig(
|
|
|
48
53
|
)
|
|
49
54
|
|
|
50
55
|
|
|
56
|
+
def _build_test_idp_app(
|
|
57
|
+
email: str = "user@example.com",
|
|
58
|
+
preferred_username: str = "oauthuser",
|
|
59
|
+
) -> FastAPI:
|
|
60
|
+
idp = FastAPI()
|
|
61
|
+
|
|
62
|
+
@idp.post("/token")
|
|
63
|
+
async def google_token(request: Request) -> dict[str, str]:
|
|
64
|
+
form = await request.form()
|
|
65
|
+
grant_type = str(form.get("grant_type") or "")
|
|
66
|
+
code = str(form.get("code") or "")
|
|
67
|
+
if grant_type != "authorization_code" or code != "auth-code":
|
|
68
|
+
return {"error": "invalid_grant"}
|
|
69
|
+
return {"access_token": "idp-access-token"}
|
|
70
|
+
|
|
71
|
+
@idp.post("/login/oauth/access_token")
|
|
72
|
+
async def github_token(request: Request) -> dict[str, str]:
|
|
73
|
+
form = await request.form()
|
|
74
|
+
code = str(form.get("code") or "")
|
|
75
|
+
if code != "auth-code":
|
|
76
|
+
return {"error": "bad_code"}
|
|
77
|
+
return {"access_token": "idp-access-token"}
|
|
78
|
+
|
|
79
|
+
@idp.get("/v1/userinfo")
|
|
80
|
+
async def google_userinfo() -> dict[str, str]:
|
|
81
|
+
return {"sub": "oauth-sub-1", "email": email, "preferred_username": preferred_username}
|
|
82
|
+
|
|
83
|
+
@idp.get("/user")
|
|
84
|
+
async def github_user() -> dict[str, object]:
|
|
85
|
+
return {"id": "123", "login": preferred_username, "email": None}
|
|
86
|
+
|
|
87
|
+
@idp.get("/user/emails")
|
|
88
|
+
async def github_emails() -> list[dict[str, object]]:
|
|
89
|
+
return [
|
|
90
|
+
{"email": email, "primary": True, "verified": True},
|
|
91
|
+
{"email": "other@example.com", "primary": False, "verified": True},
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
return idp
|
|
95
|
+
|
|
96
|
+
|
|
51
97
|
@pytest.fixture
|
|
52
98
|
def client() -> Generator[TestClient, None, None]:
|
|
53
99
|
with TestClient(app) as c:
|
|
@@ -294,6 +340,32 @@ def test_oauth_allowed_emails_exact_match() -> None:
|
|
|
294
340
|
assert not is_oauth_email_allowed(cfg, "other@example.com")
|
|
295
341
|
|
|
296
342
|
|
|
343
|
+
def test_anonymous_user_assumes_local_public_role_for_read_permissions() -> None:
|
|
344
|
+
from pygitweb.auth import can_read_project
|
|
345
|
+
|
|
346
|
+
cfg = AuthConfig(
|
|
347
|
+
auth_mode="local",
|
|
348
|
+
local_users=[
|
|
349
|
+
LocalUser(
|
|
350
|
+
user="public",
|
|
351
|
+
password="secret",
|
|
352
|
+
email="public@example.com",
|
|
353
|
+
assume_anonymous=True,
|
|
354
|
+
),
|
|
355
|
+
],
|
|
356
|
+
oauth_permissions=PermissionsMap.model_validate({
|
|
357
|
+
"*": [],
|
|
358
|
+
"pgw.read.secret": ["public@example.com"],
|
|
359
|
+
}),
|
|
360
|
+
)
|
|
361
|
+
with (
|
|
362
|
+
patch.object(settings, "AUTH", True),
|
|
363
|
+
patch("pygitweb.auth.auth_config", cfg),
|
|
364
|
+
):
|
|
365
|
+
assert can_read_project("secret", None)
|
|
366
|
+
assert not can_read_project("other", None)
|
|
367
|
+
|
|
368
|
+
|
|
297
369
|
_OAUTH_CFG = AuthConfig(
|
|
298
370
|
auth_mode="oauth",
|
|
299
371
|
oauth_provider="google",
|
|
@@ -345,15 +417,50 @@ def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: A
|
|
|
345
417
|
assert r.status_code == 303
|
|
346
418
|
rec = get_session(r.cookies["pygitweb_access_token"])
|
|
347
419
|
assert rec is not None
|
|
348
|
-
assert rec.username == "
|
|
349
|
-
assert rec.subject == "
|
|
420
|
+
assert rec.username == "oauthuser"
|
|
421
|
+
assert rec.subject == "oauthuser"
|
|
350
422
|
assert rec.email == "user@example.com"
|
|
351
423
|
assert rec.auth_method == "oauth"
|
|
352
|
-
assert client.get("/users/me").json()["username"] == "
|
|
424
|
+
assert client.get("/users/me").json()["username"] == "oauthuser"
|
|
353
425
|
status = client.get("/auth/status").json()
|
|
354
|
-
assert status["username"] == "
|
|
426
|
+
assert status["username"] == "oauthuser"
|
|
355
427
|
assert status["gravatar_url"] == gravatar_url("user@example.com")
|
|
356
|
-
|
|
428
|
+
clear_client_cookies(client)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def test_oauth_flow_with_asgi_idp(client: TestClient, oauth_auth_config: AuthConfig) -> None:
|
|
432
|
+
idp_app = _build_test_idp_app()
|
|
433
|
+
|
|
434
|
+
async def runner() -> None:
|
|
435
|
+
transport = ASGITransport(app=idp_app)
|
|
436
|
+
|
|
437
|
+
def _idp_client_factory(*_: object, **__: object) -> httpx.AsyncClient:
|
|
438
|
+
return _REAL_ASYNC_CLIENT(transport=transport, base_url="https://idp.test")
|
|
439
|
+
|
|
440
|
+
with (
|
|
441
|
+
patch.object(settings, "AUTH", True),
|
|
442
|
+
patch("pygitweb.auth_oauth.httpx.AsyncClient", side_effect=_idp_client_factory),
|
|
443
|
+
):
|
|
444
|
+
start = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
|
|
445
|
+
assert start.status_code == 303
|
|
446
|
+
location = start.headers["location"]
|
|
447
|
+
state_values = parse_qs(urlparse(location).query).get("state", [])
|
|
448
|
+
assert state_values
|
|
449
|
+
state = state_values[0]
|
|
450
|
+
callback = client.get(
|
|
451
|
+
"/auth/oauth/callback",
|
|
452
|
+
params={"code": "auth-code", "state": state},
|
|
453
|
+
follow_redirects=False,
|
|
454
|
+
)
|
|
455
|
+
assert callback.status_code == 303
|
|
456
|
+
rec = get_session(callback.cookies[ACCESS_TOKEN_COOKIE_NAME])
|
|
457
|
+
assert rec is not None
|
|
458
|
+
assert rec.username == "oauthuser"
|
|
459
|
+
assert rec.subject == "oauthuser"
|
|
460
|
+
assert rec.email == "user@example.com"
|
|
461
|
+
assert rec.auth_method == "oauth"
|
|
462
|
+
|
|
463
|
+
asyncio.run(runner())
|
|
357
464
|
|
|
358
465
|
|
|
359
466
|
def test_auth_status_no_gravatar_for_local_login(client: TestClient, local_auth_config: AuthConfig) -> None:
|
|
@@ -34,6 +34,7 @@ _SAMPLE_LABELS: dict[str, str] = {
|
|
|
34
34
|
"pre-commit.ruff": "Ruff Pre-commit",
|
|
35
35
|
"commit-msg.pattern": "Commit Message Pattern",
|
|
36
36
|
"pre-receive.protected-pattern": "Protected Branch Commit Messages",
|
|
37
|
+
"pre-receive.branch-permissions": "Branch Write Permissions",
|
|
37
38
|
"post-commit.push-remotes": "Post-commit Push Remotes",
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -751,6 +751,7 @@ async def internal_notify(
|
|
|
751
751
|
async def dispatch(
|
|
752
752
|
request: Request,
|
|
753
753
|
project: ValidatedReadableProject,
|
|
754
|
+
token: Annotated[str | None, Depends(access_token_from_request)],
|
|
754
755
|
a: Annotated[str | None, Query(alias="a")] = None,
|
|
755
756
|
h: Annotated[str | None, Query(alias="h")] = None,
|
|
756
757
|
hb: Annotated[str | None, Query(alias="hb")] = None,
|
|
@@ -845,7 +846,7 @@ async def dispatch(
|
|
|
845
846
|
p, pc = parse_pagination(page, pagecount)
|
|
846
847
|
return git_history(project, hash_param, file_name, request, p, pc)
|
|
847
848
|
if action == "heads":
|
|
848
|
-
return git_heads(project)
|
|
849
|
+
return git_heads(project, token)
|
|
849
850
|
if action == "tags":
|
|
850
851
|
p, pc = parse_pagination(page, pagecount)
|
|
851
852
|
return git_tags(project, request, p, pc)
|
|
@@ -27,7 +27,11 @@ WILDCARD_PRINCIPAL = "*"
|
|
|
27
27
|
PERMISSION_ADD_PROJECTS = "pgw.addprojects"
|
|
28
28
|
PERMISSION_SETTINGS = "pgw.settings"
|
|
29
29
|
|
|
30
|
-
_SCOPED_PREFIX = re.compile(
|
|
30
|
+
_SCOPED_PREFIX = re.compile(
|
|
31
|
+
r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge|read|write|read_branch|write_branch)\.(.+)$",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_BRANCH_REF_PREFIX = "refs/heads/"
|
|
31
35
|
|
|
32
36
|
|
|
33
37
|
class Permission(StrEnum):
|
|
@@ -35,6 +39,8 @@ class Permission(StrEnum):
|
|
|
35
39
|
|
|
36
40
|
READ = "read"
|
|
37
41
|
WRITE = "write"
|
|
42
|
+
READ_BRANCH = "read_branch"
|
|
43
|
+
WRITE_BRANCH = "write_branch"
|
|
38
44
|
BOARDS = "boards"
|
|
39
45
|
TASKS = "tasks"
|
|
40
46
|
COMMENTS = "comments"
|
|
@@ -57,6 +63,8 @@ class Permission(StrEnum):
|
|
|
57
63
|
if self in (
|
|
58
64
|
Permission.READ,
|
|
59
65
|
Permission.WRITE,
|
|
66
|
+
Permission.READ_BRANCH,
|
|
67
|
+
Permission.WRITE_BRANCH,
|
|
60
68
|
Permission.BOARDS,
|
|
61
69
|
Permission.TASKS,
|
|
62
70
|
Permission.COMMENTS,
|
|
@@ -116,6 +124,49 @@ def project_matches_scope(project: str, scope: str) -> bool:
|
|
|
116
124
|
return fnmatch.fnmatchcase(project, scope)
|
|
117
125
|
|
|
118
126
|
|
|
127
|
+
def short_branch_name_from_ref(ref_name: str) -> str | None:
|
|
128
|
+
if not ref_name.startswith(_BRANCH_REF_PREFIX):
|
|
129
|
+
return None
|
|
130
|
+
return ref_name.removeprefix(_BRANCH_REF_PREFIX)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def branch_permission_subject(project: str, ref_name: str) -> str | None:
|
|
134
|
+
short_name = short_branch_name_from_ref(ref_name)
|
|
135
|
+
if short_name is None:
|
|
136
|
+
return None
|
|
137
|
+
return f"{project}/{short_name}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def has_branch_read_permission(
|
|
141
|
+
permissions: PermissionsMap,
|
|
142
|
+
principal: str | None,
|
|
143
|
+
project: str,
|
|
144
|
+
ref_name: str,
|
|
145
|
+
) -> bool:
|
|
146
|
+
subject = branch_permission_subject(project, ref_name)
|
|
147
|
+
if subject is None:
|
|
148
|
+
return True
|
|
149
|
+
branch_specific = permissions.matching_scoped_principals(Permission.READ_BRANCH, subject)
|
|
150
|
+
if branch_specific:
|
|
151
|
+
return permissions.has_permission(principal, Permission.READ_BRANCH, subject)
|
|
152
|
+
return permissions.has_permission(principal, Permission.READ, project)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def has_branch_write_permission(
|
|
156
|
+
permissions: PermissionsMap,
|
|
157
|
+
principal: str | None,
|
|
158
|
+
project: str,
|
|
159
|
+
ref_name: str,
|
|
160
|
+
) -> bool:
|
|
161
|
+
subject = branch_permission_subject(project, ref_name)
|
|
162
|
+
if subject is None:
|
|
163
|
+
return True
|
|
164
|
+
branch_specific = permissions.matching_scoped_principals(Permission.WRITE_BRANCH, subject)
|
|
165
|
+
if branch_specific:
|
|
166
|
+
return permissions.has_permission(principal, Permission.WRITE_BRANCH, subject)
|
|
167
|
+
return permissions.has_permission(principal, Permission.WRITE, project)
|
|
168
|
+
|
|
169
|
+
|
|
119
170
|
class PermissionsMap(RootModel[dict[str, list[str]]]):
|
|
120
171
|
"""Validated ``oauth_permissions`` object from auth.json."""
|
|
121
172
|
|
|
@@ -5,9 +5,13 @@ from pygitweb.permissions import (
|
|
|
5
5
|
PERMISSION_SETTINGS,
|
|
6
6
|
Permission,
|
|
7
7
|
PermissionsMap,
|
|
8
|
+
branch_permission_subject,
|
|
9
|
+
has_branch_read_permission,
|
|
10
|
+
has_branch_write_permission,
|
|
8
11
|
parse_permission_key,
|
|
9
12
|
permission_key,
|
|
10
13
|
project_matches_scope,
|
|
14
|
+
short_branch_name_from_ref,
|
|
11
15
|
)
|
|
12
16
|
|
|
13
17
|
|
|
@@ -54,12 +58,31 @@ def test_parse_mr_scoped_keys() -> None:
|
|
|
54
58
|
assert merge.permission is Permission.MR_MERGE
|
|
55
59
|
|
|
56
60
|
|
|
61
|
+
def test_parse_branch_scoped_keys() -> None:
|
|
62
|
+
read = parse_permission_key("pgw.read_branch.team/app-main")
|
|
63
|
+
assert read is not None
|
|
64
|
+
assert read.permission is Permission.READ_BRANCH
|
|
65
|
+
assert read.project_scope == "team/app-main"
|
|
66
|
+
write = parse_permission_key("pgw.write_branch.dev/alice.*")
|
|
67
|
+
assert write is not None
|
|
68
|
+
assert write.permission is Permission.WRITE_BRANCH
|
|
69
|
+
assert write.project_scope == "dev/alice.*"
|
|
70
|
+
|
|
71
|
+
|
|
57
72
|
def test_project_glob() -> None:
|
|
58
73
|
assert project_matches_scope("team/app", "team/*")
|
|
59
74
|
assert not project_matches_scope("other/app", "team/*")
|
|
60
75
|
assert project_matches_scope("anything", "*")
|
|
61
76
|
|
|
62
77
|
|
|
78
|
+
def test_branch_permission_subject() -> None:
|
|
79
|
+
assert short_branch_name_from_ref("refs/heads/main") == "main"
|
|
80
|
+
assert short_branch_name_from_ref("refs/heads/dev/alice.feature") == "dev/alice.feature"
|
|
81
|
+
assert short_branch_name_from_ref("refs/tags/v1.0.0") is None
|
|
82
|
+
assert branch_permission_subject("demo", "refs/heads/main") == "demo/main"
|
|
83
|
+
assert branch_permission_subject("demo", "refs/tags/v1.0.0") is None
|
|
84
|
+
|
|
85
|
+
|
|
63
86
|
def test_superuser_grants_all() -> None:
|
|
64
87
|
m = PermissionsMap.model_validate({
|
|
65
88
|
"*": ["admin@example.com"],
|
|
@@ -138,3 +161,41 @@ def test_owner_grants_read_and_write() -> None:
|
|
|
138
161
|
assert can_read_project("secret", token)
|
|
139
162
|
assert can_write_project("secret", token)
|
|
140
163
|
assert not can_read_project("secret", None)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_has_branch_write_permission_falls_back_to_project_scope() -> None:
|
|
167
|
+
m = PermissionsMap.model_validate({
|
|
168
|
+
"*": [],
|
|
169
|
+
"pgw.write.demo": ["writer@example.com"],
|
|
170
|
+
})
|
|
171
|
+
assert has_branch_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
|
|
172
|
+
assert not has_branch_write_permission(m, "other@example.com", "demo", "refs/heads/main")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_has_branch_write_permission_enforces_branch_specific_rules() -> None:
|
|
176
|
+
m = PermissionsMap.model_validate({
|
|
177
|
+
"*": [],
|
|
178
|
+
"pgw.write.demo": ["writer@example.com"],
|
|
179
|
+
"pgw.write_branch.demo/main": ["branch-writer@example.com"],
|
|
180
|
+
})
|
|
181
|
+
assert has_branch_write_permission(m, "branch-writer@example.com", "demo", "refs/heads/main")
|
|
182
|
+
assert not has_branch_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_has_branch_read_permission_falls_back_to_project_scope() -> None:
|
|
186
|
+
m = PermissionsMap.model_validate({
|
|
187
|
+
"*": [],
|
|
188
|
+
"pgw.read.demo": ["reader@example.com"],
|
|
189
|
+
})
|
|
190
|
+
assert has_branch_read_permission(m, "reader@example.com", "demo", "refs/heads/main")
|
|
191
|
+
assert not has_branch_read_permission(m, "other@example.com", "demo", "refs/heads/main")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def test_has_branch_read_permission_enforces_branch_specific_rules() -> None:
|
|
195
|
+
m = PermissionsMap.model_validate({
|
|
196
|
+
"*": [],
|
|
197
|
+
"pgw.read.demo": ["reader@example.com"],
|
|
198
|
+
"pgw.read_branch.demo/main": ["branch-reader@example.com"],
|
|
199
|
+
})
|
|
200
|
+
assert has_branch_read_permission(m, "branch-reader@example.com", "demo", "refs/heads/main")
|
|
201
|
+
assert not has_branch_read_permission(m, "reader@example.com", "demo", "refs/heads/main")
|
|
@@ -225,11 +225,13 @@
|
|
|
225
225
|
var statusEl = details.querySelector('.board-card-status');
|
|
226
226
|
var priorityEl = details.querySelector('.board-card-priority');
|
|
227
227
|
var assigneeEl = details.querySelector('.board-card-assignee');
|
|
228
|
+
var tagsEl = details.querySelector('.board-card-tags');
|
|
228
229
|
if (dueDate) dueDate.textContent = task.due_date || 'None';
|
|
229
230
|
if (updatedAt) updatedAt.textContent = task.updated_at || 'None';
|
|
230
231
|
if (statusEl) statusEl.textContent = status;
|
|
231
232
|
if (priorityEl) priorityEl.textContent = task.priority || 'LOW';
|
|
232
233
|
if (assigneeEl) assigneeEl.textContent = task.assignee || 'None';
|
|
234
|
+
if (tagsEl) tagsEl.textContent = task.tags || '';
|
|
233
235
|
updateCardCommentCount(card, task.comments_count || 0);
|
|
234
236
|
}
|
|
235
237
|
return clone;
|
|
@@ -617,6 +619,7 @@
|
|
|
617
619
|
var dueEl = popoutCard.querySelector('.board-card-due-date');
|
|
618
620
|
var statusEl = popoutCard.querySelector('.board-card-status');
|
|
619
621
|
var priorityEl = popoutCard.querySelector('.board-card-priority');
|
|
622
|
+
var tagsEl = popoutCard.querySelector('.board-card-tags');
|
|
620
623
|
|
|
621
624
|
function pushTaskUpdate() {
|
|
622
625
|
var taskRef = popoutCard.dataset.taskRef;
|
|
@@ -629,16 +632,19 @@
|
|
|
629
632
|
var sEl = popoutCard.querySelector('.board-card-status');
|
|
630
633
|
var pEl = popoutCard.querySelector('.board-card-priority');
|
|
631
634
|
var aEl = popoutCard.querySelector('.board-card-assignee');
|
|
635
|
+
var tagsEl_ = popoutCard.querySelector('.board-card-tags');
|
|
632
636
|
var dueVal = (dueEl_ && dueEl_.textContent) ? dueEl_.textContent.trim() : '';
|
|
633
637
|
var statusDisplay = (sEl && sEl.textContent) ? sEl.textContent.trim() : 'TODO';
|
|
634
638
|
var priorityDisplay = (pEl && pEl.textContent) ? pEl.textContent.trim() : 'LOW';
|
|
639
|
+
var tagsVal = (tagsEl_ && tagsEl_.textContent) ? tagsEl_.textContent.trim() : '';
|
|
635
640
|
var body = {
|
|
636
641
|
title: (tEl && tEl.textContent) ? tEl.textContent.trim() || 'Untitled' : 'Untitled',
|
|
637
642
|
description: getMarkdownFromElement(dEl),
|
|
638
643
|
status: statusDisplay ? statusDisplay.replace(/\s+/g, '_').toUpperCase() : 'TODO',
|
|
639
644
|
priority: priorityDisplay ? priorityDisplay.replace(/\s+/g, '_').toUpperCase() : 'LOW',
|
|
640
645
|
assignee: (aEl && aEl.textContent && aEl.textContent.trim() !== 'None') ? aEl.textContent.trim() : null,
|
|
641
|
-
due_date: (dueVal && dueVal !== 'None') ? dueVal : null
|
|
646
|
+
due_date: (dueVal && dueVal !== 'None') ? dueVal : null,
|
|
647
|
+
tags: tagsVal || ''
|
|
642
648
|
};
|
|
643
649
|
fetch('/tasks/update?' + new URLSearchParams({ project: ctx.project, board: ctx.board, task: taskRef }).toString(), {
|
|
644
650
|
method: 'POST',
|
|
@@ -783,6 +789,12 @@
|
|
|
783
789
|
sel.addEventListener('change', commitPriority);
|
|
784
790
|
});
|
|
785
791
|
}
|
|
792
|
+
|
|
793
|
+
if (tagsEl) {
|
|
794
|
+
tagsEl.addEventListener('blur', function () {
|
|
795
|
+
pushTaskUpdate();
|
|
796
|
+
});
|
|
797
|
+
}
|
|
786
798
|
}
|
|
787
799
|
|
|
788
800
|
function showCardPopout(card) {
|
|
@@ -1126,6 +1126,12 @@ pre {
|
|
|
1126
1126
|
text-transform: uppercase;
|
|
1127
1127
|
}
|
|
1128
1128
|
|
|
1129
|
+
.board-card-tags[contenteditable="true"]:empty::before {
|
|
1130
|
+
content: "(click to add tags)";
|
|
1131
|
+
color: var(--pgw-text-muted);
|
|
1132
|
+
opacity: 0.8;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1129
1135
|
.board-card-comment-body {
|
|
1130
1136
|
min-width: 0;
|
|
1131
1137
|
flex: 1;
|
|
@@ -220,6 +220,7 @@ def _task_to_json(t: Task) -> dict[str, Any]:
|
|
|
220
220
|
"priority": t.priority.value if t.priority else None,
|
|
221
221
|
"assignee": t.assignee,
|
|
222
222
|
"due_date": t.due_date.isoformat() if t.due_date else None,
|
|
223
|
+
"tags": getattr(t, "tags", "") or "",
|
|
223
224
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
|
224
225
|
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
|
225
226
|
"comments_count": len(getattr(t, "comments", [])),
|
|
@@ -305,6 +306,7 @@ def task_create(
|
|
|
305
306
|
priority: str = Query("LOW", description="Task priority"),
|
|
306
307
|
assignee: str = Query("", description="Assignee"),
|
|
307
308
|
due_date: str = Query("", description="Due date ISO"),
|
|
309
|
+
tags: str = Query("", description="Task tags (KEY=VALUE list)"),
|
|
308
310
|
) -> JSONResponse:
|
|
309
311
|
"""Create a new task on a board."""
|
|
310
312
|
repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
|
|
@@ -335,6 +337,7 @@ def task_create(
|
|
|
335
337
|
priority=priority_enum,
|
|
336
338
|
assignee=assignee or None,
|
|
337
339
|
due_date=due,
|
|
340
|
+
tags=tags or "",
|
|
338
341
|
)
|
|
339
342
|
oid = task.write(repo)
|
|
340
343
|
b.tasks = getattr(b, "tasks", []) or []
|
|
@@ -355,7 +358,7 @@ async def task_update(
|
|
|
355
358
|
board: str = Query(..., description="Board name"),
|
|
356
359
|
task_ref: str = Query(..., alias="task", description="Task ref (e.g. refs/tags/tasks/task_123)"),
|
|
357
360
|
) -> JSONResponse:
|
|
358
|
-
"""Update a task (body: optional title, description, status, priority, assignee, due_date)."""
|
|
361
|
+
"""Update a task (body: optional title, description, status, priority, assignee, due_date, tags)."""
|
|
359
362
|
try:
|
|
360
363
|
body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
|
|
361
364
|
except Exception:
|
|
@@ -385,6 +388,8 @@ async def task_update(
|
|
|
385
388
|
)
|
|
386
389
|
except ValueError:
|
|
387
390
|
pass
|
|
391
|
+
if "tags" in body:
|
|
392
|
+
t.tags = str(body.get("tags") or "")
|
|
388
393
|
old_oid = str(repo.references[ref].resolve().target)
|
|
389
394
|
t.update_message()
|
|
390
395
|
new_oid = t.write(repo)
|
|
@@ -24,13 +24,46 @@
|
|
|
24
24
|
{% endif %}
|
|
25
25
|
</div>
|
|
26
26
|
<div class="board-card-details d-none mt-2 pt-2 border-top small">
|
|
27
|
-
<div class="mb-2"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<div class="mb-2"
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
<div class="mb-2">
|
|
28
|
+
<strong class="text-muted">Ref</strong>
|
|
29
|
+
<div class="board-card-task-ref font-monospace text-muted mt-1 text-break">{% if task and task.ref %}{{ task.ref }}{% else %}{% endif %}</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="mb-2">
|
|
32
|
+
<strong class="text-muted">Description</strong>
|
|
33
|
+
<div class="board-card-description-full board-card-editable board-card-markdown mt-1">{% if task and task.description %}{{ task.description }}{% else %}{% endif %}</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="mb-2">
|
|
36
|
+
<strong class="text-muted">Due date</strong>
|
|
37
|
+
<div class="board-card-due-date board-card-editable text-muted mt-1">{% if task and task.due_date %}{{ task.due_date }}{% else %}None{% endif %}</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="mb-2">
|
|
40
|
+
<strong class="text-muted">Last modified</strong>
|
|
41
|
+
<div class="board-card-updated-at text-muted mt-1">{% if task and task.updated_at %}{{ task.updated_at }}{% else %}None{% endif %}</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="mb-2">
|
|
44
|
+
<strong class="text-muted">Status</strong>
|
|
45
|
+
<div class="board-card-status board-card-editable text-muted mt-1">{% if task %}{{ task.status or 'TODO' }}{% else %}TODO{% endif %}</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="mb-2">
|
|
48
|
+
<strong class="text-muted">Priority</strong>
|
|
49
|
+
<div class="board-card-priority board-card-editable text-muted mt-1">{% if task and task.priority %}{{ task.priority }}{% else %}LOW{% endif %}</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="mb-2">
|
|
52
|
+
<strong class="text-muted">Assignee</strong>
|
|
53
|
+
<div class="board-card-assignee text-muted mt-1">{% if task and task.assignee %}{{ task.assignee }}{% else %}None{% endif %}</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="mb-2">
|
|
56
|
+
<details class="board-card-tags-details" open>
|
|
57
|
+
<summary class="text-muted">Tags</summary>
|
|
58
|
+
<div
|
|
59
|
+
class="board-card-tags font-monospace text-muted mt-1 small"
|
|
60
|
+
contenteditable="true"
|
|
61
|
+
spellcheck="false"
|
|
62
|
+
>
|
|
63
|
+
{% if task and task.tags %}{{ task.tags }}{% endif %}
|
|
64
|
+
</div>
|
|
65
|
+
</details>
|
|
66
|
+
</div>
|
|
34
67
|
<div class="mb-0 board-card-comments-section">
|
|
35
68
|
<strong class="text-muted">Comments</strong>
|
|
36
69
|
<div class="board-card-comments-list mt-2"></div>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|