taskflow-agent 0.6.0__tar.gz → 0.6.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.
- taskflow_agent-0.6.2/.gitignore +22 -0
- {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/PKG-INFO +3 -3
- {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/pyproject.toml +2 -2
- taskflow_agent-0.6.2/src/approval_policy.py +42 -0
- taskflow_agent-0.6.2/src/calendar_context.py +39 -0
- taskflow_agent-0.6.2/src/contexts.py +279 -0
- taskflow_agent-0.6.2/src/db.py +3900 -0
- taskflow_agent-0.6.2/src/development.py +115 -0
- taskflow_agent-0.6.2/src/development_queue.py +203 -0
- taskflow_agent-0.6.2/src/development_sources.py +406 -0
- taskflow_agent-0.6.2/src/dotenv_loader.py +52 -0
- taskflow_agent-0.6.2/src/ideas.py +203 -0
- taskflow_agent-0.6.2/src/repos.py +816 -0
- taskflow_agent-0.6.2/src/server.py +1857 -0
- taskflow_agent-0.6.2/src/sub_agent_handler.py +86 -0
- taskflow_agent-0.6.2/src/web.py +6486 -0
- taskflow_agent-0.6.2/static/development.css +69 -0
- taskflow_agent-0.6.2/static/development.js +407 -0
- taskflow_agent-0.6.2/static/index.html +8734 -0
- taskflow_agent-0.6.0/.gitignore +0 -13
- taskflow_agent-0.6.0/src/db.py +0 -1471
- taskflow_agent-0.6.0/src/repos.py +0 -181
- taskflow_agent-0.6.0/src/server.py +0 -843
- taskflow_agent-0.6.0/src/web.py +0 -3353
- taskflow_agent-0.6.0/static/index.html +0 -3528
- {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/LICENSE +0 -0
- {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/Makefile +0 -0
- {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/README.md +0 -0
- {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/src/__init__.py +0 -0
- {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/src/importer.py +0 -0
- {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/src/models.py +0 -0
- {taskflow_agent-0.6.0 → taskflow_agent-0.6.2}/src/workflows.py +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.db
|
|
5
|
+
.claude/
|
|
6
|
+
.excel_mcp_server.pid
|
|
7
|
+
exports/
|
|
8
|
+
logs/
|
|
9
|
+
.env
|
|
10
|
+
data/taskflow-web.pid
|
|
11
|
+
data/agent_memory.md
|
|
12
|
+
data/repos.json
|
|
13
|
+
data/repos.json.lock
|
|
14
|
+
# data/workflows/ — now tracked in git
|
|
15
|
+
data/ideas/
|
|
16
|
+
.gstack/
|
|
17
|
+
.DS_Store
|
|
18
|
+
.context/
|
|
19
|
+
dist/
|
|
20
|
+
*.db-wal
|
|
21
|
+
*.db-shm
|
|
22
|
+
data/contexts/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: taskflow-agent
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: Lightweight project and task manager with MCP tools for Claude Code
|
|
5
5
|
Project-URL: Repository, https://github.com/henrysouchien/taskflow-agent
|
|
6
6
|
Author: Henry Chien
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Topic :: Office/Business :: Scheduling
|
|
18
18
|
Requires-Python: >=3.10
|
|
19
|
-
Requires-Dist: mcp
|
|
19
|
+
Requires-Dist: mcp<2,>=1.30.0
|
|
20
20
|
Provides-Extra: web
|
|
21
21
|
Requires-Dist: fastapi; extra == 'web'
|
|
22
22
|
Requires-Dist: python-dotenv; extra == 'web'
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "taskflow-agent"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.2"
|
|
8
8
|
description = "Lightweight project and task manager with MCP tools for Claude Code"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -24,7 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Topic :: Office/Business :: Scheduling",
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
|
-
"mcp>=1.
|
|
27
|
+
"mcp>=1.30.0,<2",
|
|
28
28
|
]
|
|
29
29
|
|
|
30
30
|
[project.optional-dependencies]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Collection, Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_needs_approval(
|
|
8
|
+
*,
|
|
9
|
+
local_tool_names: Collection[str],
|
|
10
|
+
mutating_tool_names: Collection[str],
|
|
11
|
+
is_mcp_tool: Callable[[str], bool],
|
|
12
|
+
):
|
|
13
|
+
"""Build Taskflow's fail-closed tool approval boundary.
|
|
14
|
+
|
|
15
|
+
Declared local reads run without prompts. Declared local mutations and all
|
|
16
|
+
dynamically loaded MCP tools require approval. Anything not declared by
|
|
17
|
+
either boundary also requires approval so newly added tools cannot silently
|
|
18
|
+
inherit write authority.
|
|
19
|
+
"""
|
|
20
|
+
local_names = frozenset(local_tool_names)
|
|
21
|
+
mutating_names = frozenset(mutating_tool_names)
|
|
22
|
+
|
|
23
|
+
def needs_approval(
|
|
24
|
+
tool_name: str,
|
|
25
|
+
tool_input: Mapping[str, Any] | None = None,
|
|
26
|
+
qualifier: str = "",
|
|
27
|
+
) -> bool:
|
|
28
|
+
del tool_input, qualifier
|
|
29
|
+
name = str(tool_name or "").strip()
|
|
30
|
+
if name in local_names:
|
|
31
|
+
return name in mutating_names
|
|
32
|
+
try:
|
|
33
|
+
if is_mcp_tool(name):
|
|
34
|
+
return True
|
|
35
|
+
except Exception:
|
|
36
|
+
return True
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
return needs_approval
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["make_needs_approval"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Authoritative calendar context for Taskflow agent prompts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import os
|
|
7
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def current_date_context(
|
|
11
|
+
*,
|
|
12
|
+
now: datetime | None = None,
|
|
13
|
+
timezone_name: str | None = None,
|
|
14
|
+
) -> str:
|
|
15
|
+
"""Return timezone-aware date guidance for relative-date reasoning."""
|
|
16
|
+
|
|
17
|
+
configured_name = str(
|
|
18
|
+
timezone_name or os.getenv("TASKFLOW_TIMEZONE", "America/New_York")
|
|
19
|
+
).strip()
|
|
20
|
+
if not configured_name:
|
|
21
|
+
configured_name = "America/New_York"
|
|
22
|
+
try:
|
|
23
|
+
local_timezone = ZoneInfo(configured_name)
|
|
24
|
+
except ZoneInfoNotFoundError as exc:
|
|
25
|
+
raise RuntimeError(f"invalid TASKFLOW_TIMEZONE: {configured_name!r}") from exc
|
|
26
|
+
instant = now or datetime.now(timezone.utc)
|
|
27
|
+
if instant.tzinfo is None:
|
|
28
|
+
instant = instant.replace(tzinfo=timezone.utc)
|
|
29
|
+
local_now = instant.astimezone(local_timezone)
|
|
30
|
+
return (
|
|
31
|
+
"CURRENT DATE CONTEXT:\n"
|
|
32
|
+
f"Today is {local_now.strftime('%A, %B')} {local_now.day}, {local_now.year} "
|
|
33
|
+
f"({local_now.date().isoformat()}).\n"
|
|
34
|
+
f"Time zone: {configured_name}. Use this date and time zone for relative dates, "
|
|
35
|
+
"deadlines, day-of-week calculations, and phrases such as today, tomorrow, or this week."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = ["current_date_context"]
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Per-project resource discovery context helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import db
|
|
13
|
+
|
|
14
|
+
CONTEXTS_DIR = Path(__file__).resolve().parent.parent / "data" / "contexts"
|
|
15
|
+
MAX_BYTES = 8_192
|
|
16
|
+
MAX_ALIASES = 20
|
|
17
|
+
MAX_SEARCH_TERMS = 30
|
|
18
|
+
MAX_LOCATION_ITEMS = 10
|
|
19
|
+
MAX_REPOS = 10
|
|
20
|
+
MAX_ALIAS_CHARS = 100
|
|
21
|
+
MAX_SEARCH_TERM_CHARS = 100
|
|
22
|
+
MAX_LOCATION_CHARS = 200
|
|
23
|
+
MAX_NOTES_CHARS = 500
|
|
24
|
+
MAX_REPO_NAME_CHARS = 60
|
|
25
|
+
_REPO_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
26
|
+
_TOP_LEVEL_KEYS = {"aliases", "search_terms", "locations", "repos", "last_scan_at", "notes"}
|
|
27
|
+
_LOCATION_KEYS = {"gdrive_folders", "roam_tags", "notes_queries", "onedrive_folders", "local_paths"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_context(project_id: int) -> dict[str, Any] | None:
|
|
31
|
+
"""Return stored context with injected read-only project metadata."""
|
|
32
|
+
project_id = _normalize_project_id(project_id)
|
|
33
|
+
path = CONTEXTS_DIR / f"{project_id}.json"
|
|
34
|
+
try:
|
|
35
|
+
if path.stat().st_size > MAX_BYTES:
|
|
36
|
+
raise ValueError(f"Context for project {project_id} exceeds size limit")
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
42
|
+
except json.JSONDecodeError as exc:
|
|
43
|
+
raise ValueError(f"Context for project {project_id} is not valid JSON") from exc
|
|
44
|
+
if not isinstance(raw, dict):
|
|
45
|
+
raise ValueError(f"Context for project {project_id} must be a JSON object")
|
|
46
|
+
|
|
47
|
+
errors = validate_context(raw)
|
|
48
|
+
if errors:
|
|
49
|
+
raise ValueError("; ".join(errors))
|
|
50
|
+
|
|
51
|
+
stored = _normalize_context_data(raw)
|
|
52
|
+
project_name = _project_name(project_id)
|
|
53
|
+
stored["_project_id"] = project_id
|
|
54
|
+
stored["_project_name"] = project_name
|
|
55
|
+
return stored
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_context(project_id: int, data: dict[str, Any]) -> dict[str, Any]:
|
|
59
|
+
"""Replace a project's stored context atomically after validation."""
|
|
60
|
+
project_id = _normalize_project_id(project_id)
|
|
61
|
+
if not isinstance(data, dict):
|
|
62
|
+
raise ValueError("context must be an object")
|
|
63
|
+
|
|
64
|
+
conn = db.get_conn()
|
|
65
|
+
try:
|
|
66
|
+
project = db.get_project(conn, project_id)
|
|
67
|
+
finally:
|
|
68
|
+
conn.close()
|
|
69
|
+
if not project:
|
|
70
|
+
raise ValueError(f"Project {project_id} not found")
|
|
71
|
+
if project.get("archived"):
|
|
72
|
+
raise ValueError(f"Project {project_id} is archived")
|
|
73
|
+
|
|
74
|
+
errors = validate_context(data)
|
|
75
|
+
if errors:
|
|
76
|
+
raise ValueError("; ".join(errors))
|
|
77
|
+
stored = _normalize_context_data(data)
|
|
78
|
+
|
|
79
|
+
encoded = json.dumps(stored, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
|
|
80
|
+
if len(encoded.encode("utf-8")) > MAX_BYTES:
|
|
81
|
+
raise ValueError("Context exceeds size limit (8 KB)")
|
|
82
|
+
|
|
83
|
+
CONTEXTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
path = CONTEXTS_DIR / f"{project_id}.json"
|
|
85
|
+
tmp_path: Path | None = None
|
|
86
|
+
try:
|
|
87
|
+
with tempfile.NamedTemporaryFile(
|
|
88
|
+
"w",
|
|
89
|
+
encoding="utf-8",
|
|
90
|
+
dir=CONTEXTS_DIR,
|
|
91
|
+
prefix=f".{project_id}-",
|
|
92
|
+
suffix=".tmp",
|
|
93
|
+
delete=False,
|
|
94
|
+
) as tmp_file:
|
|
95
|
+
tmp_file.write(encoded)
|
|
96
|
+
tmp_path = Path(tmp_file.name)
|
|
97
|
+
tmp_path.replace(path)
|
|
98
|
+
except OSError:
|
|
99
|
+
if tmp_path is not None:
|
|
100
|
+
tmp_path.unlink(missing_ok=True)
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
return {"status": "ok", "project_id": project_id, "path": str(path), "bytes": len(encoded.encode("utf-8"))}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate_context(data: dict[str, Any]) -> list[str]:
|
|
107
|
+
"""Return validation errors for stored context data."""
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
return ["context must be an object"]
|
|
110
|
+
|
|
111
|
+
cleaned = _strip_readonly_fields(data)
|
|
112
|
+
errors: list[str] = []
|
|
113
|
+
unknown_keys = sorted(set(cleaned) - _TOP_LEVEL_KEYS)
|
|
114
|
+
if unknown_keys:
|
|
115
|
+
errors.append(f"Unknown context keys: {', '.join(unknown_keys)}")
|
|
116
|
+
|
|
117
|
+
errors.extend(_validate_string_list(cleaned.get("aliases", []), "aliases", MAX_ALIASES, MAX_ALIAS_CHARS))
|
|
118
|
+
errors.extend(
|
|
119
|
+
_validate_string_list(
|
|
120
|
+
cleaned.get("search_terms", []),
|
|
121
|
+
"search_terms",
|
|
122
|
+
MAX_SEARCH_TERMS,
|
|
123
|
+
MAX_SEARCH_TERM_CHARS,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
errors.extend(_validate_locations(cleaned.get("locations", {})))
|
|
127
|
+
errors.extend(_validate_repos(cleaned.get("repos", [])))
|
|
128
|
+
|
|
129
|
+
last_scan_at = cleaned.get("last_scan_at", "")
|
|
130
|
+
if not isinstance(last_scan_at, str):
|
|
131
|
+
errors.append("last_scan_at must be a string")
|
|
132
|
+
elif last_scan_at.strip():
|
|
133
|
+
try:
|
|
134
|
+
datetime.fromisoformat(last_scan_at.strip().replace("Z", "+00:00"))
|
|
135
|
+
except ValueError:
|
|
136
|
+
errors.append("last_scan_at must be an ISO 8601 datetime string")
|
|
137
|
+
|
|
138
|
+
notes = cleaned.get("notes", "")
|
|
139
|
+
if not isinstance(notes, str):
|
|
140
|
+
errors.append("notes must be a string")
|
|
141
|
+
elif len(notes.strip()) > MAX_NOTES_CHARS:
|
|
142
|
+
errors.append(f"notes must be at most {MAX_NOTES_CHARS} characters")
|
|
143
|
+
|
|
144
|
+
return errors
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def list_contexts() -> list[dict[str, Any]]:
|
|
148
|
+
"""Return summaries for valid context files and remove orphans."""
|
|
149
|
+
if not CONTEXTS_DIR.is_dir():
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
conn = db.get_conn()
|
|
153
|
+
try:
|
|
154
|
+
items: list[dict[str, Any]] = []
|
|
155
|
+
for path in sorted(CONTEXTS_DIR.glob("*.json")):
|
|
156
|
+
try:
|
|
157
|
+
project_id = int(path.stem)
|
|
158
|
+
except ValueError:
|
|
159
|
+
continue
|
|
160
|
+
project = db.get_project(conn, project_id)
|
|
161
|
+
if not project:
|
|
162
|
+
path.unlink(missing_ok=True)
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
if path.stat().st_size > MAX_BYTES:
|
|
166
|
+
continue
|
|
167
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
168
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
169
|
+
continue
|
|
170
|
+
if not isinstance(raw, dict):
|
|
171
|
+
continue
|
|
172
|
+
items.append(
|
|
173
|
+
{
|
|
174
|
+
"project_id": project_id,
|
|
175
|
+
"project_name": str(project.get("name", "")),
|
|
176
|
+
"alias_count": len(raw.get("aliases", [])) if isinstance(raw.get("aliases"), list) else 0,
|
|
177
|
+
"search_term_count": len(raw.get("search_terms", [])) if isinstance(raw.get("search_terms"), list) else 0,
|
|
178
|
+
"repo_count": len(raw.get("repos", [])) if isinstance(raw.get("repos"), list) else 0,
|
|
179
|
+
"last_scan_at": str(raw.get("last_scan_at", "")),
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
return items
|
|
183
|
+
finally:
|
|
184
|
+
conn.close()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def delete_context(project_id: int) -> bool:
|
|
188
|
+
"""Delete a project's context file if present."""
|
|
189
|
+
project_id = _normalize_project_id(project_id)
|
|
190
|
+
path = CONTEXTS_DIR / f"{project_id}.json"
|
|
191
|
+
existed = path.exists()
|
|
192
|
+
path.unlink(missing_ok=True)
|
|
193
|
+
return existed
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _strip_readonly_fields(data: dict[str, Any]) -> dict[str, Any]:
|
|
197
|
+
return {str(key): value for key, value in data.items() if not str(key).startswith("_")}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _normalize_context_data(data: dict[str, Any]) -> dict[str, Any]:
|
|
201
|
+
cleaned = _strip_readonly_fields(data)
|
|
202
|
+
locations = cleaned.get("locations", {})
|
|
203
|
+
normalized_locations = {
|
|
204
|
+
key: [str(item).strip() for item in locations.get(key, []) if str(item).strip()]
|
|
205
|
+
for key in sorted(_LOCATION_KEYS)
|
|
206
|
+
if isinstance(locations, dict) and key in locations
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
"aliases": [str(item).strip() for item in cleaned.get("aliases", []) if str(item).strip()],
|
|
210
|
+
"search_terms": [str(item).strip() for item in cleaned.get("search_terms", []) if str(item).strip()],
|
|
211
|
+
"locations": normalized_locations,
|
|
212
|
+
"repos": [str(item).strip() for item in cleaned.get("repos", []) if str(item).strip()],
|
|
213
|
+
"last_scan_at": str(cleaned.get("last_scan_at", "")).strip(),
|
|
214
|
+
"notes": str(cleaned.get("notes", "")).strip(),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _validate_string_list(value: Any, field: str, max_items: int, max_chars: int) -> list[str]:
|
|
219
|
+
if value is None:
|
|
220
|
+
return []
|
|
221
|
+
if not isinstance(value, list):
|
|
222
|
+
return [f"{field} must be a list"]
|
|
223
|
+
errors: list[str] = []
|
|
224
|
+
if len(value) > max_items:
|
|
225
|
+
errors.append(f"{field} must have at most {max_items} items")
|
|
226
|
+
for index, item in enumerate(value, start=1):
|
|
227
|
+
if not isinstance(item, str):
|
|
228
|
+
errors.append(f"{field}[{index}] must be a string")
|
|
229
|
+
continue
|
|
230
|
+
if len(item.strip()) > max_chars:
|
|
231
|
+
errors.append(f"{field}[{index}] must be at most {max_chars} characters")
|
|
232
|
+
return errors
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _validate_locations(value: Any) -> list[str]:
|
|
236
|
+
if value is None:
|
|
237
|
+
return []
|
|
238
|
+
if not isinstance(value, dict):
|
|
239
|
+
return ["locations must be an object"]
|
|
240
|
+
errors: list[str] = []
|
|
241
|
+
unknown_keys = sorted(set(value) - _LOCATION_KEYS)
|
|
242
|
+
if unknown_keys:
|
|
243
|
+
errors.append(f"Unknown location keys: {', '.join(unknown_keys)}")
|
|
244
|
+
for key in sorted(_LOCATION_KEYS):
|
|
245
|
+
errors.extend(_validate_string_list(value.get(key, []), f"locations.{key}", MAX_LOCATION_ITEMS, MAX_LOCATION_CHARS))
|
|
246
|
+
return errors
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _validate_repos(value: Any) -> list[str]:
|
|
250
|
+
if value is None:
|
|
251
|
+
return []
|
|
252
|
+
errors = _validate_string_list(value, "repos", MAX_REPOS, MAX_REPO_NAME_CHARS)
|
|
253
|
+
if not isinstance(value, list):
|
|
254
|
+
return errors
|
|
255
|
+
for index, item in enumerate(value, start=1):
|
|
256
|
+
if isinstance(item, str) and item.strip() and not _REPO_NAME_RE.match(item.strip()):
|
|
257
|
+
errors.append(f"repos[{index}] has invalid repo name")
|
|
258
|
+
return errors
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _normalize_project_id(project_id: int) -> int:
|
|
262
|
+
if isinstance(project_id, bool):
|
|
263
|
+
raise ValueError("project_id must be an integer")
|
|
264
|
+
try:
|
|
265
|
+
value = int(project_id)
|
|
266
|
+
except (TypeError, ValueError) as exc:
|
|
267
|
+
raise ValueError("project_id must be an integer") from exc
|
|
268
|
+
if value <= 0:
|
|
269
|
+
raise ValueError("project_id must be a positive integer")
|
|
270
|
+
return value
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _project_name(project_id: int) -> str:
|
|
274
|
+
conn = db.get_conn()
|
|
275
|
+
try:
|
|
276
|
+
project = db.get_project(conn, project_id)
|
|
277
|
+
finally:
|
|
278
|
+
conn.close()
|
|
279
|
+
return str(project.get("name", "")) if project else ""
|