Compare commits
10 Commits
5fbfe3a8d4
...
1cb56be010
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cb56be010 | ||
|
|
e20977ef49 | ||
|
|
98faabad67 | ||
|
|
fd13770427 | ||
|
|
5dc96dd43c | ||
|
|
151a5e6175 | ||
|
|
75b1559354 | ||
|
|
f1dae5a811 | ||
|
|
4a6855d70b | ||
|
|
abbcf0cc1f |
@ -75,3 +75,19 @@
|
|||||||
- R-6 (design #10): `base_jql` и `board_id` удаляются через механизм устаревших ключей, без новой версии схемы.
|
- R-6 (design #10): `base_jql` и `board_id` удаляются через механизм устаревших ключей, без новой версии схемы.
|
||||||
Это тот же путь, что у `status_labels`, и при каждом старте ключи, вставленные старым процессом, убираются снова.
|
Это тот же путь, что у `status_labels`, и при каждом старте ключи, вставленные старым процессом, убираются снова.
|
||||||
Цена ошибки: нет.
|
Цена ошибки: нет.
|
||||||
|
- R-7 (design #7, 26.10.2026): `files.size` для каталога на месте файла возвращает `None` (проверка `S_ISREG`), а не размер
|
||||||
|
каталога. План предписывал только `except OSError`, но `Path.stat()` на каталоге не бросает исключение, и claim отдал бы
|
||||||
|
агенту размер каталога. Решение согласовано с `files.exists` (`is_file`). Цена ошибки: нет, поведение совпадает с целью
|
||||||
|
design #7 («файла нет»); `read` для каталога пишет предупреждение, `size` молчит (T4.1).
|
||||||
|
- R-8 (design #1, 26.10.2026; финальное ревью I-1): необязательный параметр `ensure_fields` (поля `FIELD_PARAMS`: `assignee`,
|
||||||
|
`epic_key`, `priority`, даты, оценки), заданный строкой из одних пробелов, отвергается с 422 «<параметр> — из одних
|
||||||
|
пробелов; для очистки — "" или null». Иначе обрезка превращала бы его в `""`, а агент читает `""` как «очистить поле в
|
||||||
|
Jira». Для `before`/`after`/`move_open_to` пробелы по-прежнему означают «не задано». Цена ошибки: ввод из одних пробелов
|
||||||
|
отвергается хабом, а не Jira; очистка полей работает как раньше через `""` или `null`.
|
||||||
|
- R-9 (design #7, уточняет R-7; финальное ревью I-3): `files.size` открывает файл и берёт размер через `fstat`, `_intact`
|
||||||
|
ловит `OSError`. Причина: `Path.stat()` не требует права на чтение, и `chmod 0` давал настоящий размер при `read` → `None`;
|
||||||
|
повторная загрузка того же файла давала 500. Теперь `size` для нечитаемого файла и каталога — `None` с предупреждением,
|
||||||
|
`put` перезаписывает нечитаемый файл. Цена ошибки: лишнее открытие файла при claim.
|
||||||
|
- R-10 (design #10; финальное ревью M-3, T6.2): на базе v7 с ключами v3 `migrate_v7` удаляет `base_jql` и `board_id` и
|
||||||
|
поднимает `settings_version`; неразобранный `base_jql` теряется вместе с ключом (значение остаётся в предупреждении
|
||||||
|
`migrate_v3` и в `backups/schema-v3`). Цена ошибки: потеря неразобранного значения, которое нигде не читается.
|
||||||
|
|||||||
@ -5,7 +5,9 @@
|
|||||||
каноническом виде. Нормализация SHALL выполняться сразу после проверки типов значений, до остальных проверок:
|
каноническом виде. Нормализация SHALL выполняться сразу после проверки типов значений, до остальных проверок:
|
||||||
обязательный параметр, ставший пустым после обрезки, даёт 422 «нет <параметр>»; правила типов, временные ключи
|
обязательный параметр, ставший пустым после обрезки, даёт 422 «нет <параметр>»; правила типов, временные ключи
|
||||||
`new:<n>` и проверки `ensure_attachment` работают по нормализованным значениям; элемент списка, ставший пустым, даёт
|
`new:<n>` и проверки `ensure_attachment` работают по нормализованным значениям; элемент списка, ставший пустым, даёт
|
||||||
422 «<параметр> — без пустых элементов». Строковые параметры обрезаются по краям, кроме текстовых `summary`,
|
422 «<параметр> — без пустых элементов». Необязательное поле `ensure_fields` из одних пробелов (`priority`, `duedate`,
|
||||||
|
`planned_start`, `planned_end`, `epic_key`, `assignee`) даёт 422 «<параметр> — из одних пробелов; для очистки — "" или
|
||||||
|
null»: после обрезки оно стало бы пустой строкой, которую агент читает как «очистить поле в Jira» (R-8). Строковые параметры обрезаются по краям, кроме текстовых `summary`,
|
||||||
`description`, `body` и `goal`, которые хранятся как заданы. Элементы списков `labels`, `labels_add`, `labels_remove`
|
`description`, `body` и `goal`, которые хранятся как заданы. Элементы списков `labels`, `labels_add`, `labels_remove`
|
||||||
и `keys`, а также `target` у `ensure_status`, если это список, обрезаются поэлементно. Числа, логические значения и
|
и `keys`, а также `target` у `ensure_status`, если это список, обрезаются поэлементно. Числа, логические значения и
|
||||||
`null` не меняются. `filename` у `ensure_attachment` SHALL дополнительно приводиться к нормальной форме Unicode NFC.
|
`null` не меняются. `filename` у `ensure_attachment` SHALL дополнительно приводиться к нормальной форме Unicode NFC.
|
||||||
@ -28,6 +30,10 @@
|
|||||||
- **WHEN** `POST /batches` с `ensure_status {key: " ", target: "Готово"}`
|
- **WHEN** `POST /batches` с `ensure_status {key: " ", target: "Готово"}`
|
||||||
- **THEN** 422 «команда 1 (ensure_status): нет key; ожидаются key, target»
|
- **THEN** 422 «команда 1 (ensure_status): нет key; ожидаются key, target»
|
||||||
|
|
||||||
|
#### Scenario: Поле из пробелов не очищает Jira
|
||||||
|
- **WHEN** `POST /batches` с `ensure_fields {key: "A-1", assignee: " "}`
|
||||||
|
- **THEN** 422 «команда 1 (ensure_fields): assignee — из одних пробелов; для очистки — "" или null», пакет не создан
|
||||||
|
|
||||||
#### Scenario: Имя вложения в NFD
|
#### Scenario: Имя вложения в NFD
|
||||||
- **WHEN** `ensure_attachment` с `filename` «отчёт.pdf» в NFD («е» + U+0308)
|
- **WHEN** `ensure_attachment` с `filename` «отчёт.pdf» в NFD («е» + U+0308)
|
||||||
- **THEN** сохранён `filename` «отчёт.pdf» в NFC; повторная постановка того же файла с именем в NFC даёт ту же строку
|
- **THEN** сохранён `filename` «отчёт.pdf» в NFC; повторная постановка того же файла с именем в NFC даёт ту же строку
|
||||||
|
|||||||
@ -2,9 +2,11 @@
|
|||||||
команда модуля (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения:
|
команда модуля (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения:
|
||||||
каталог передаётся параметром (Hub.files_dir, решение 10)."""
|
каталог передаётся параметром (Hub.files_dir, решение 10)."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat as stat_mod
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -12,6 +14,8 @@ from pathlib import Path
|
|||||||
from . import registry
|
from . import registry
|
||||||
from .paths import secure_dir
|
from .paths import secure_dir
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1)
|
MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1)
|
||||||
KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2)
|
KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2)
|
||||||
SHA_RE = re.compile(r"[0-9a-f]{64}")
|
SHA_RE = re.compile(r"[0-9a-f]{64}")
|
||||||
@ -41,29 +45,40 @@ def exists(root, sha) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def size(root, sha) -> int | None:
|
def size(root, sha) -> int | None:
|
||||||
"""Размер файла в байтах; None — файла нет или sha негодный."""
|
"""Размер файла в байтах; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный."""
|
||||||
if not is_sha(sha):
|
if not is_sha(sha):
|
||||||
return None
|
return None
|
||||||
|
target = path(root, sha)
|
||||||
try:
|
try:
|
||||||
return path(root, sha).stat().st_size
|
with open(target, "rb") as f: # fstat у дескриптора: Path.stat() не требует права на чтение (R-9)
|
||||||
|
st = os.fstat(f.fileno())
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("файл хранилища %s не читается: %s", target, e)
|
||||||
|
return None
|
||||||
|
return st.st_size if stat_mod.S_ISREG(st.st_mode) else None
|
||||||
|
|
||||||
|
|
||||||
def read(root, sha) -> bytes | None:
|
def read(root, sha) -> bytes | None:
|
||||||
|
"""Содержимое файла; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный."""
|
||||||
if not is_sha(sha):
|
if not is_sha(sha):
|
||||||
return None
|
return None
|
||||||
|
target = path(root, sha)
|
||||||
try:
|
try:
|
||||||
return path(root, sha).read_bytes()
|
return target.read_bytes()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return None
|
return None
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("файл хранилища %s не читается: %s", target, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _intact(target, sha) -> bool:
|
def _intact(target, sha) -> bool:
|
||||||
"""Копия на диске совпадает по sha256: испорченную того же размера повторная загрузка переписывает (M-2 ревью)."""
|
"""Копия на диске совпадает по sha256: испорченную того же размера повторная загрузка переписывает (M-2 ревью)."""
|
||||||
try:
|
try:
|
||||||
return hashlib.sha256(target.read_bytes()).hexdigest() == sha
|
return hashlib.sha256(target.read_bytes()).hexdigest() == sha
|
||||||
except FileNotFoundError:
|
except OSError: # нет файла, нечитаем или каталог — не целая копия, put перепишет
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,12 @@ def int_group(m, i=1) -> int:
|
|||||||
return int(m.group(i))
|
return int(m.group(i))
|
||||||
|
|
||||||
|
|
||||||
|
def is_digits(value) -> bool:
|
||||||
|
"""Непустая строка только из ASCII-цифр 0-9. str.isdigit() принимает «²» и арабо-индийские цифры, на которых int()
|
||||||
|
падает (#86 F.1); идентификаторы из цифр проверять только этим."""
|
||||||
|
return isinstance(value, str) and value.isascii() and value.isdigit()
|
||||||
|
|
||||||
|
|
||||||
def int_param(q, name, default) -> int:
|
def int_param(q, name, default) -> int:
|
||||||
try:
|
try:
|
||||||
return int(q.get(name, default))
|
return int(q.get(name, default))
|
||||||
|
|||||||
@ -7,7 +7,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def home() -> Path:
|
def home() -> Path:
|
||||||
return Path(os.environ.get("SDLC_HUB_HOME", str(Path.home() / ".local" / "share" / "sdlc-hub")))
|
return Path(os.environ.get("SDLC_HUB_HOME") or str(Path.home() / ".local" / "share" / "sdlc-hub"))
|
||||||
|
|
||||||
|
|
||||||
def legacy_home() -> Path:
|
def legacy_home() -> Path:
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from datetime import timedelta
|
|||||||
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
|
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
|
||||||
from . import db, registry, settings
|
from . import db, registry, settings
|
||||||
from .errors import CommandError
|
from .errors import CommandError
|
||||||
|
from .http import is_digits
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -427,7 +428,8 @@ def agent_view(conn, agent: str) -> dict:
|
|||||||
d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES)
|
d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES)
|
||||||
m = registry.module(agent)
|
m = registry.module(agent)
|
||||||
min_version = int(settings.get(conn, m.agent_min_version_key) or 0) if m and m.agent_min_version_key else 0
|
min_version = int(settings.get(conn, m.agent_min_version_key) or 0) if m and m.agent_min_version_key else 0
|
||||||
d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None
|
known = is_digits(str(d["agent_version"])) if d["agent_version"] else False # «²», «10-dev» — версия неизвестна
|
||||||
|
d["warning"] = "обновите агента" if known and int(d["agent_version"]) < min_version else None
|
||||||
d["rate_limited_24h"] = conn.execute(
|
d["rate_limited_24h"] = conn.execute(
|
||||||
f"SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ? AND type IN ({_in(types)})",
|
f"SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ? AND type IN ({_in(types)})",
|
||||||
((now - timedelta(days=1)).isoformat(), *types)).fetchone()["n"]
|
((now - timedelta(days=1)).isoformat(), *types)).fetchone()["n"]
|
||||||
|
|||||||
@ -99,6 +99,7 @@ def publish(release_dir, source) -> dict:
|
|||||||
raise PublishError("файл не в UTF-8") from e
|
raise PublishError("файл не в UTF-8") from e
|
||||||
version = parse_version(text)
|
version = parse_version(text)
|
||||||
rd = Path(release_dir)
|
rd = Path(release_dir)
|
||||||
|
_recover(rd)
|
||||||
prev = current(rd)
|
prev = current(rd)
|
||||||
if prev and version <= prev["version"]:
|
if prev and version <= prev["version"]:
|
||||||
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
|
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
|
||||||
@ -123,6 +124,41 @@ def publish(release_dir, source) -> dict:
|
|||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str | None:
|
||||||
|
try:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _recover(rd: Path) -> None:
|
||||||
|
"""Привести release/ к опубликованной версии после прерванной публикации (design hub-backend-fixes, решение 9).
|
||||||
|
Сверка с manifest.json по sha256: agent.py.prev совпадает, а agent.py нет или не совпадает — .prev возвращается на
|
||||||
|
место; agent.py совпадает — лишний .prev удаляется; иначе .prev не трогается, только предупреждение. Сбой
|
||||||
|
(OSError) — предупреждение в лог, публикация продолжается."""
|
||||||
|
agent, prev_file = rd / "agent.py", rd / "agent.py.prev"
|
||||||
|
try:
|
||||||
|
if not prev_file.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
m = json.loads((rd / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
m = None
|
||||||
|
sha = m.get("sha256") if _manifest_problem(m) is None else None
|
||||||
|
if sha is None:
|
||||||
|
log.warning("release: найден %s, но манифеста нет или он негоден — не трогаем", prev_file)
|
||||||
|
elif _sha256(agent) == sha:
|
||||||
|
prev_file.unlink()
|
||||||
|
log.warning("release: лишний %s удалён: agent.py сходится с манифестом", prev_file)
|
||||||
|
elif _sha256(prev_file) == sha:
|
||||||
|
os.replace(prev_file, agent)
|
||||||
|
log.warning("release: прерванная публикация: %s возвращён на место agent.py", prev_file)
|
||||||
|
else:
|
||||||
|
log.warning("release: найден %s, не сходится с манифестом — не трогаем", prev_file)
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("release: восстановление после прерванной публикации не удалось: %s", e)
|
||||||
|
|
||||||
|
|
||||||
def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
|
def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
|
||||||
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
|
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
|
||||||
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.
|
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.
|
||||||
|
|||||||
@ -21,9 +21,11 @@ _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
|
|||||||
|
|
||||||
|
|
||||||
def h_agent_state(hub, conn, m, q, body):
|
def h_agent_state(hub, conn, m, q, body):
|
||||||
issues = body.get("issues") or []
|
issues = body.get("issues")
|
||||||
|
issues = [] if issues is None else issues # {} "" 0 false — не список: check_issues отвергнет
|
||||||
boards = body.get("boards")
|
boards = body.get("boards")
|
||||||
try:
|
try:
|
||||||
|
mirror.check_issues(issues)
|
||||||
mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
|
mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
|
||||||
mirror.check_attachments(issues)
|
mirror.check_attachments(issues)
|
||||||
mirror.check_agile_fields(issues)
|
mirror.check_agile_fields(issues)
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from ...core import files
|
from ...core import files
|
||||||
|
from ...core.http import is_digits
|
||||||
|
|
||||||
# lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки;
|
# lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки;
|
||||||
# после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя
|
# после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя
|
||||||
@ -40,7 +41,7 @@ def ref_names(text: str | None) -> list[str]:
|
|||||||
|
|
||||||
def _newest_key(item: dict):
|
def _newest_key(item: dict):
|
||||||
aid = str(item["attachment_id"])
|
aid = str(item["attachment_id"])
|
||||||
return item.get("created_at") or "", int(aid) if aid.isdigit() else -1, aid
|
return item.get("created_at") or "", int(aid) if is_digits(aid) else -1, aid
|
||||||
|
|
||||||
|
|
||||||
def resolve(names: list[str], items: list[dict]) -> list[str]:
|
def resolve(names: list[str], items: list[dict]) -> list[str]:
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
|
проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место)
|
from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место)
|
||||||
@ -56,6 +57,7 @@ WRITE_TYPES = [t for t in SPEC if t not in ("refresh", "fetch_attachment")]
|
|||||||
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
|
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
|
||||||
BOOL_PARAMS = {"replace"}
|
BOOL_PARAMS = {"replace"}
|
||||||
NUMBER_PARAMS = {"story_points"}
|
NUMBER_PARAMS = {"story_points"}
|
||||||
|
TEXT_PARAMS = {"summary", "description", "body", "goal"} # содержательный текст: пробелы и отступы значимы
|
||||||
SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch)
|
SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch)
|
||||||
DATE_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$")
|
DATE_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$")
|
||||||
|
|
||||||
@ -105,6 +107,33 @@ def _type_error(ctype, name, value) -> str | None:
|
|||||||
return None if isinstance(value, str) else f"{name} — строка"
|
return None if isinstance(value, str) else f"{name} — строка"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(ctype, params: dict) -> dict:
|
||||||
|
"""Новый словарь параметров: строки и элементы списков обрезаны (кроме TEXT_PARAMS), filename — NFC (#129).
|
||||||
|
Нестроковые значения остаются как есть; вход не меняется."""
|
||||||
|
def trim(v):
|
||||||
|
return v.strip() if isinstance(v, str) else v
|
||||||
|
|
||||||
|
out = {}
|
||||||
|
for name, value in params.items():
|
||||||
|
if name in TEXT_PARAMS:
|
||||||
|
out[name] = value
|
||||||
|
elif name == "filename" and isinstance(value, str):
|
||||||
|
out[name] = unicodedata.normalize("NFC", value.strip())
|
||||||
|
elif isinstance(value, list):
|
||||||
|
out[name] = [trim(v) for v in value]
|
||||||
|
else:
|
||||||
|
out[name] = trim(value)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _blank_item_error(ctype, params) -> str | None:
|
||||||
|
for name, value in params.items():
|
||||||
|
is_list = name in LIST_PARAMS or (ctype == "ensure_status" and name == "target")
|
||||||
|
if is_list and isinstance(value, list) and any(v == "" for v in value):
|
||||||
|
return f"{name} — без пустых элементов"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _date(value) -> date | None:
|
def _date(value) -> date | None:
|
||||||
"""Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None."""
|
"""Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None."""
|
||||||
if not isinstance(value, str) or not DATE_RE.match(value):
|
if not isinstance(value, str) or not DATE_RE.match(value):
|
||||||
@ -205,6 +234,18 @@ def validate(commands) -> list[dict]:
|
|||||||
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
|
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
|
||||||
if bad:
|
if bad:
|
||||||
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
|
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
|
||||||
|
if ctype == "ensure_fields": # « » стало бы «» = «очистить поле»; очистка — только явная (R-8)
|
||||||
|
for name in FIELD_PARAMS:
|
||||||
|
v = params.get(name)
|
||||||
|
if name not in TEXT_PARAMS and isinstance(v, str) and v and not v.strip():
|
||||||
|
raise CommandError(f'команда {i} ({ctype}): {name} — из одних пробелов; для очистки — "" или null')
|
||||||
|
params = normalize(ctype, params)
|
||||||
|
missing = [f for f in spec["required"] if params.get(f) in (None, "", [])]
|
||||||
|
if missing:
|
||||||
|
raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}; {_expected(spec)}")
|
||||||
|
blank = _blank_item_error(ctype, params)
|
||||||
|
if blank:
|
||||||
|
raise CommandError(f"команда {i} ({ctype}): {blank}")
|
||||||
rule = _rule_error(ctype, params)
|
rule = _rule_error(ctype, params)
|
||||||
if rule:
|
if rule:
|
||||||
raise CommandError(f"команда {i} ({ctype}): {rule}")
|
raise CommandError(f"команда {i} ({ctype}): {rule}")
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""Скачивание вложения из Jira по запросу (fetch_attachment): постановка в очередь и ошибки постановки."""
|
"""Скачивание вложения из Jira по запросу (fetch_attachment): постановка в очередь и ошибки постановки."""
|
||||||
from ...core import db, queue
|
from ...core import db, queue
|
||||||
from ...core.errors import CommandError, HttpMapped
|
from ...core.errors import CommandError, HttpMapped
|
||||||
|
from ...core.http import is_digits
|
||||||
from . import attachments, commands
|
from . import attachments, commands
|
||||||
|
|
||||||
FETCH_AGENT_VERSION = 14 # первая версия агента с fetch_attachment
|
FETCH_AGENT_VERSION = 14 # первая версия агента с fetch_attachment
|
||||||
@ -31,7 +32,7 @@ def request_fetch(conn, files_dir, attachment_id: str, author: str | None) -> di
|
|||||||
if info["state"] in ("ready", "fetching"):
|
if info["state"] in ("ready", "fetching"):
|
||||||
return info
|
return info
|
||||||
v = queue.agent_version(conn, "jira")
|
v = queue.agent_version(conn, "jira")
|
||||||
if v is not None and str(v).isdigit() and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем
|
if v is not None and is_digits(str(v)) and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем
|
||||||
raise AgentTooOld(f"агент v{v} не умеет скачивать вложения — нужен v{FETCH_AGENT_VERSION}")
|
raise AgentTooOld(f"агент v{v} не умеет скачивать вложения — нужен v{FETCH_AGENT_VERSION}")
|
||||||
cmds = commands.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}])
|
cmds = commands.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}])
|
||||||
hooks.check_setup(conn, cmds, files_dir)
|
hooks.check_setup(conn, cmds, files_dir)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from . import projects
|
from . import projects
|
||||||
from ...core import db, settings
|
from ...core import db, settings
|
||||||
|
from ...core.http import is_digits
|
||||||
|
|
||||||
SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5)
|
SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5)
|
||||||
|
|
||||||
@ -14,7 +15,7 @@ def jira_utc(value) -> str | None:
|
|||||||
if not isinstance(value, str) or not value:
|
if not isinstance(value, str) or not value:
|
||||||
return None
|
return None
|
||||||
s = value[:-1] + "+00:00" if value.endswith("Z") else value
|
s = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||||
if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM
|
if len(s) > 5 and s[-5] in "+-" and is_digits(s[-4:]): # ±HHMM → ±HH:MM
|
||||||
s = s[:-2] + ":" + s[-2:]
|
s = s[:-2] + ":" + s[-2:]
|
||||||
try:
|
try:
|
||||||
d = datetime.fromisoformat(s)
|
d = datetime.fromisoformat(s)
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from ...core import files, projects, queue, settings
|
from ...core import files, projects, queue, settings
|
||||||
|
from ...core.http import is_digits
|
||||||
from ...core.errors import CommandError
|
from ...core.errors import CommandError
|
||||||
from ...core.modules import CommandHooks
|
from ...core.modules import CommandHooks
|
||||||
from . import attachments, commands, history, mirror, refresh
|
from . import attachments, commands, history, mirror, refresh
|
||||||
@ -40,7 +41,7 @@ def check_setup(conn, cmds, files_dir) -> None:
|
|||||||
# кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2)
|
# кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2)
|
||||||
raise CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)")
|
raise CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)")
|
||||||
if c["type"] == "fetch_attachment":
|
if c["type"] == "fetch_attachment":
|
||||||
if not p["attachment_id"].isdigit():
|
if not is_digits(p["attachment_id"]):
|
||||||
raise CommandError(f"команда {i}: attachment_id — цифры id вложения из issue")
|
raise CommandError(f"команда {i}: attachment_id — цифры id вложения из issue")
|
||||||
row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?",
|
row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?",
|
||||||
(p["attachment_id"], p["key"])).fetchone()
|
(p["attachment_id"], p["key"])).fetchone()
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import stat
|
import stat
|
||||||
import time
|
import time
|
||||||
|
import unicodedata
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ from mcp.server.fastmcp import FastMCP
|
|||||||
from ...core import mcp as core
|
from ...core import mcp as core
|
||||||
from ...core.client import HubError
|
from ...core.client import HubError
|
||||||
from ...core.files import MAX_SIZE
|
from ...core.files import MAX_SIZE
|
||||||
|
from ...core.http import is_digits
|
||||||
from .attachments import fmt_size
|
from .attachments import fmt_size
|
||||||
from .commands import describe, is_temp
|
from .commands import describe, is_temp
|
||||||
|
|
||||||
@ -45,7 +47,7 @@ def fmt_day(value) -> str:
|
|||||||
text = value.strip()
|
text = value.strip()
|
||||||
if text.endswith(("Z", "z")):
|
if text.endswith(("Z", "z")):
|
||||||
text = text[:-1] + "+00:00"
|
text = text[:-1] + "+00:00"
|
||||||
if len(text) > 5 and text[-5] in "+-" and text[-4:].isdigit():
|
if len(text) > 5 and text[-5] in "+-" and is_digits(text[-4:]):
|
||||||
text = text[:-2] + ":" + text[-2:]
|
text = text[:-2] + ":" + text[-2:]
|
||||||
try:
|
try:
|
||||||
at = datetime.fromisoformat(text)
|
at = datetime.fromisoformat(text)
|
||||||
@ -217,7 +219,7 @@ DATE_LABELS = {"duedate": "срок", "planned_start": "плановое нач
|
|||||||
|
|
||||||
def _day(value, text) -> str:
|
def _day(value, text) -> str:
|
||||||
"""Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—»."""
|
"""Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—»."""
|
||||||
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit():
|
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and is_digits(value[:4]):
|
||||||
return f"{value[8:10]}.{value[5:7]}"
|
return f"{value[8:10]}.{value[5:7]}"
|
||||||
return core.value(text)
|
return core.value(text)
|
||||||
|
|
||||||
@ -345,10 +347,12 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
|
|||||||
d = await asyncio.to_thread(client.get, f"/issues/{key}")
|
d = await asyncio.to_thread(client.get, f"/issues/{key}")
|
||||||
atts = d.get("attachments") or []
|
atts = d.get("attachments") or []
|
||||||
ref = attachment.strip().lstrip("#")
|
ref = attachment.strip().lstrip("#")
|
||||||
found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else []
|
found = [a for a in atts if str(a["attachment_id"]) == ref] if is_digits(ref) else []
|
||||||
prefix = ""
|
prefix = ""
|
||||||
if not found:
|
if not found:
|
||||||
found = [a for a in atts if a["filename"] == attachment.strip()] # хаб отдаёт вложения новыми сверху
|
want = unicodedata.normalize("NFC", attachment.strip())
|
||||||
|
# хаб отдаёт вложения новыми сверху
|
||||||
|
found = [a for a in atts if unicodedata.normalize("NFC", a["filename"]) == want]
|
||||||
if len(found) > 1:
|
if len(found) > 1:
|
||||||
prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n"
|
prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n"
|
||||||
if not found:
|
if not found:
|
||||||
@ -478,7 +482,8 @@ def _attach_files(client, commands):
|
|||||||
params = {k: v for k, v in cmd["params"].items() if k != "path"}
|
params = {k: v for k, v in cmd["params"].items() if k != "path"}
|
||||||
up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")})
|
up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")})
|
||||||
params["file"] = up["file"]
|
params["file"] = up["file"]
|
||||||
params["filename"] = params.get("filename") or Path(cmd["params"]["path"]).expanduser().name
|
params["filename"] = params.get("filename") or unicodedata.normalize(
|
||||||
|
"NFC", Path(cmd["params"]["path"]).expanduser().name)
|
||||||
out.append({**cmd, "params": params})
|
out.append({**cmd, "params": params})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import math
|
|||||||
|
|
||||||
from . import attachments, history, projects
|
from . import attachments, history, projects
|
||||||
from ...core import db
|
from ...core import db
|
||||||
|
from ...core.http import is_digits
|
||||||
|
|
||||||
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
|
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
|
||||||
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
|
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
|
||||||
@ -31,6 +32,16 @@ def check_comments(issues) -> None:
|
|||||||
if c.get(f) is not None and not isinstance(c[f], str):
|
if c.get(f) is not None and not isinstance(c[f], str):
|
||||||
raise ValueError(f"{where}.{f} — строка или null")
|
raise ValueError(f"{where}.{f} — строка или null")
|
||||||
|
|
||||||
|
|
||||||
|
def check_issues(issues) -> None:
|
||||||
|
"""Форма списка задач состояния: список объектов с непустым строковым key. Нарушение — ValueError."""
|
||||||
|
if not isinstance(issues, list):
|
||||||
|
raise ValueError("issues — список задач")
|
||||||
|
for n, it in enumerate(issues):
|
||||||
|
if not isinstance(it, dict) or not isinstance(it.get("key"), str) or not it["key"]:
|
||||||
|
raise ValueError(f"issues[{n}] — объект задачи с key")
|
||||||
|
|
||||||
|
|
||||||
def check_attachments(issues) -> None:
|
def check_attachments(issues) -> None:
|
||||||
"""Форма поля attachments у задач состояния (агент v14+); поле необязательно. Нарушение — ValueError с ключом и полем."""
|
"""Форма поля attachments у задач состояния (агент v14+); поле необязательно. Нарушение — ValueError с ключом и полем."""
|
||||||
for it in issues:
|
for it in issues:
|
||||||
@ -43,7 +54,7 @@ def check_attachments(issues) -> None:
|
|||||||
where = f"{key}: attachments[{n}]"
|
where = f"{key}: attachments[{n}]"
|
||||||
if not isinstance(a, dict):
|
if not isinstance(a, dict):
|
||||||
raise ValueError(f"{where} — объект вложения")
|
raise ValueError(f"{where} — объект вложения")
|
||||||
if not isinstance(a.get("id"), str) or not a["id"].isdigit():
|
if not is_digits(a.get("id")):
|
||||||
raise ValueError(f"{where}.id — строка из цифр")
|
raise ValueError(f"{where}.id — строка из цифр")
|
||||||
if not isinstance(a.get("filename"), str) or not a["filename"]:
|
if not isinstance(a.get("filename"), str) or not a["filename"]:
|
||||||
raise ValueError(f"{where}.filename — непустая строка")
|
raise ValueError(f"{where}.filename — непустая строка")
|
||||||
|
|||||||
@ -24,9 +24,9 @@ DEFAULTS = {
|
|||||||
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
|
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ключи прежних версий (fact-status-removal): их удаляет миграция v8 и каждый ensure_defaults — процесс со старым кодом
|
# Ключи прежних версий (fact-status-removal; base_jql и board_id — v3, #129): их удаляет миграция v8 и каждый ensure_defaults — процесс со старым кодом
|
||||||
# (MCP-сессия до reconnect) вставил бы их снова своими умолчаниями
|
# (MCP-сессия до reconnect) вставил бы их снова своими умолчаниями
|
||||||
OBSOLETE = ("status_labels", "transitions_map")
|
OBSOLETE = ("status_labels", "transitions_map", "base_jql", "board_id")
|
||||||
|
|
||||||
# Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро
|
# Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро
|
||||||
AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link",
|
AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link",
|
||||||
|
|||||||
@ -94,6 +94,8 @@ def h_search(hub, conn, m, q, body):
|
|||||||
|
|
||||||
|
|
||||||
def h_sync(hub, conn, m, q, body):
|
def h_sync(hub, conn, m, q, body):
|
||||||
|
if body.get("project") is not None and not isinstance(body["project"], str):
|
||||||
|
raise ApiError(422, "bad_request", "project — строка")
|
||||||
q = dict(q, project=body.get("project"))
|
q = dict(q, project=body.get("project"))
|
||||||
author = body.get("author") or "api"
|
author = body.get("author") or "api"
|
||||||
if not isinstance(author, str):
|
if not isinstance(author, str):
|
||||||
|
|||||||
@ -807,6 +807,19 @@ def test_attachment_flow_over_api(hub):
|
|||||||
assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}
|
assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_with_directory_instead_of_file_has_size_none(hub):
|
||||||
|
"""Файл вложения заменён каталогом: claim отвечает 200, size — None (#129, #49 M-3)."""
|
||||||
|
c, a = hub["client"], hub["agent"]
|
||||||
|
sha = c.post("/files", {"content": _b64(b"abc")})["file"]
|
||||||
|
c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": {
|
||||||
|
"key": "A-1", "file": sha, "filename": "a.xlsx"}}]})
|
||||||
|
target = hub["hub"].files_dir / sha
|
||||||
|
target.unlink()
|
||||||
|
target.mkdir()
|
||||||
|
cmd = a.post("/agent/claim", {})["command"]
|
||||||
|
assert cmd["params"]["size"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_issues_hide_closed(hub):
|
def test_issues_hide_closed(hub):
|
||||||
c = hub["client"]
|
c = hub["client"]
|
||||||
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []},
|
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []},
|
||||||
@ -1188,6 +1201,28 @@ def test_batches_post_fetch_too_large_413(hub):
|
|||||||
assert hub["client"].get("/batches")["batches"] == []
|
assert hub["client"].get("/batches")["batches"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["²", "١٢", 7])
|
||||||
|
def test_batches_post_fetch_attachment_id_not_ascii_digits_422(hub, bad):
|
||||||
|
"""#86 F.1: attachment_id из не-ASCII цифр или числом — 422, не 500; пакет не создан."""
|
||||||
|
_seed_attachment(hub)
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
hub["client"].post("/batches", {"title": "x", "commands": [
|
||||||
|
{"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": bad}}]})
|
||||||
|
assert e.value.status == 422
|
||||||
|
want = "attachment_id — строка" if bad == 7 else "attachment_id — цифры id вложения из issue"
|
||||||
|
assert want in e.value.detail
|
||||||
|
assert hub["client"].get("/batches")["batches"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_id", ["²", 7])
|
||||||
|
def test_agent_state_attachment_id_ascii_only_422(hub, bad_id):
|
||||||
|
"""#86 F.1: id вложения в состоянии агента — только строка из ASCII-цифр (ascii digit)."""
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
hub["agent"].post("/agent/state", {"issues": [{"key": "Y-1", "summary": "Игрек", "labels": [],
|
||||||
|
"attachments": [{"id": bad_id, "filename": "a"}]}], "links": []})
|
||||||
|
assert e.value.status == 422 and e.value.error == "bad_state"
|
||||||
|
|
||||||
|
|
||||||
def test_command_retry_fetch_too_large_413(hub):
|
def test_command_retry_fetch_too_large_413(hub):
|
||||||
"""M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large."""
|
"""M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large."""
|
||||||
_seed_attachment(hub)
|
_seed_attachment(hub)
|
||||||
@ -1270,3 +1305,47 @@ def test_agent_state_empty_batch_with_boards(hub):
|
|||||||
assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1
|
assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1
|
||||||
finally:
|
finally:
|
||||||
c.close()
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rank_before_trimmed_on_claim(hub):
|
||||||
|
hub["agent"].post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []},
|
||||||
|
{"key": "X-2", "summary": "Два", "labels": []}], "links": []})
|
||||||
|
hub["client"].post("/batches", {"title": "r", "commands": [
|
||||||
|
{"type": "ensure_rank", "params": {"key": "X-1", "before": " X-2 "}}]})
|
||||||
|
cmd = hub["agent"].post("/agent/claim", {})["command"]
|
||||||
|
assert cmd["type"] == "ensure_rank" and cmd["params"]["before"] == "X-2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("issues, detail", [
|
||||||
|
(["X-1"], "issues[0] — объект задачи с key"),
|
||||||
|
([{"summary": "без ключа"}], "issues[0] — объект задачи с key"),
|
||||||
|
([{"key": 5}], "issues[0] — объект задачи с key"),
|
||||||
|
([{"key": ""}], "issues[0] — объект задачи с key"),
|
||||||
|
({"key": "X-1"}, "issues — список задач"),
|
||||||
|
("X-1", "issues — список задач"),
|
||||||
|
])
|
||||||
|
def test_agent_state_bad_issue_shape_422(hub, issues, detail):
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
hub["agent"].post("/agent/state", {"issues": issues, "links": []})
|
||||||
|
assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [{}, "", 0, False])
|
||||||
|
def test_agent_state_falsy_non_list_issues_422(hub, bad):
|
||||||
|
"""M-2: пустые не-списки не маскируются под «нет задач»."""
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
hub["agent"].post("/agent/state", {"issues": bad, "links": []})
|
||||||
|
assert e.value.status == 422 and "issues — список задач" in e.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", [{"links": []}, {"issues": None, "links": []}])
|
||||||
|
def test_agent_state_missing_or_null_issues_ok(hub, body):
|
||||||
|
hub["agent"].post("/agent/state", body)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version", ["²", "10-dev"])
|
||||||
|
def test_agent_endpoints_survive_non_digit_agent_version(hub, version):
|
||||||
|
"""I-2: GET /agent и /agents отвечают 200 при нецифровой версии агента."""
|
||||||
|
hub["agent"].post("/agent/claim", {"agent_version": version, "settings_version": 1})
|
||||||
|
assert hub["client"].get("/agent")["agent_version"] == version
|
||||||
|
assert any(a["agent_version"] == version for a in hub["client"].get("/agents")["agents"])
|
||||||
|
|||||||
@ -93,3 +93,9 @@ def test_state_failed_and_cancelled(conn, tmp_path):
|
|||||||
|
|
||||||
def test_get_missing(conn, tmp_path):
|
def test_get_missing(conn, tmp_path):
|
||||||
assert attachments.get(conn, tmp_path, "999") is None
|
assert attachments.get(conn, tmp_path, "999") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_newest_key_non_ascii_digit_goes_last_ascii():
|
||||||
|
"""#86 F.1: id «²» не бросает в int() и уходит в хвост, как нецифровой (ascii digit)."""
|
||||||
|
assert attachments._newest_key({"attachment_id": "²", "created_at": "t"}) == ("t", -1, "²")
|
||||||
|
assert attachments._newest_key({"attachment_id": "12", "created_at": "t"}) == ("t", 12, "12")
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@ -284,7 +285,7 @@ def test_new_types_validate_ok():
|
|||||||
({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"),
|
({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"),
|
||||||
({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"),
|
({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"),
|
||||||
({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"),
|
({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"),
|
||||||
({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "sprint — непустое имя"),
|
({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "нет sprint; ожидаются sprint"),
|
||||||
({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"),
|
({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"),
|
||||||
({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"),
|
({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"),
|
||||||
(state(state="open"), "state — active или closed"),
|
(state(state="open"), "state — active или closed"),
|
||||||
@ -299,7 +300,7 @@ def test_new_types_validate_ok():
|
|||||||
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5
|
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5
|
||||||
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"),
|
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"),
|
||||||
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError
|
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError
|
||||||
({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "sprint — непустое имя"),
|
({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "нет sprint; ожидаются sprint, state"),
|
||||||
(state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"),
|
(state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"),
|
||||||
(state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"),
|
(state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"),
|
||||||
(state(state="active", move_open_to="x"), "move_open_to — только при state = closed"),
|
(state(state="active", move_open_to="x"), "move_open_to — только при state = closed"),
|
||||||
@ -330,3 +331,77 @@ def test_story_points_confirmation(conn): # R-16
|
|||||||
assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True
|
assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True
|
||||||
assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False
|
assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False
|
||||||
assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True
|
assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_trims_identifiers_keeps_text():
|
||||||
|
out = commands.validate([{"type": "ensure_comment", "params": {"key": " A-1", "marker": "Итог ", "body": " отступ\n"}}])
|
||||||
|
assert out[0]["params"] == {"key": "A-1", "marker": "Итог", "body": " отступ\n"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_rank_before():
|
||||||
|
out = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": " A-2 "}}])
|
||||||
|
assert out[0]["params"] == {"key": "A-1", "before": "A-2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_temp_key_with_spaces():
|
||||||
|
out = commands.validate([
|
||||||
|
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Т"}},
|
||||||
|
{"type": "ensure_fields", "params": {"key": "A-5", "epic_key": " new:1 "}},
|
||||||
|
{"type": "refresh", "params": {"keys": [" new:1"]}}])
|
||||||
|
assert out[1]["params"]["epic_key"] == "new:1" and out[2]["params"]["keys"] == ["new:1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_blank_required_is_missing():
|
||||||
|
with pytest.raises(CommandError, match=r"^команда 1 \(ensure_status\): нет key; ожидаются key, target"):
|
||||||
|
commands.validate([{"type": "ensure_status", "params": {"key": " ", "target": "Готово"}}])
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_blank_list_item():
|
||||||
|
with pytest.raises(CommandError, match="labels_add — без пустых элементов"):
|
||||||
|
commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["x", " "]}}])
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_filename_nfc():
|
||||||
|
nfd = unicodedata.normalize("NFD", "отчёт.pdf")
|
||||||
|
assert nfd != unicodedata.normalize("NFC", "отчёт.pdf")
|
||||||
|
out = commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": f" {nfd} "}}])
|
||||||
|
assert out[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_does_not_mutate_input():
|
||||||
|
params = {"key": " A-1 ", "target": [" В работе ", "Готово "]}
|
||||||
|
out = commands.validate([{"type": "ensure_status", "params": params}])
|
||||||
|
assert params["key"] == " A-1 " and out[0]["params"]["target"] == ["В работе", "Готово"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_summary_description_goal_untouched():
|
||||||
|
out = commands.validate([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task",
|
||||||
|
"summary": " Т ", "description": " d\n"}}])
|
||||||
|
assert out[0]["params"]["summary"] == " Т " and out[0]["params"]["description"] == " d\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_digits_ascii_only():
|
||||||
|
from sdlc_hub.core.http import is_digits
|
||||||
|
assert is_digits("123") is True
|
||||||
|
for bad in ("", "²", "١٢", "12a", 7, None):
|
||||||
|
assert is_digits(bad) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field", ["assignee", "epic_key", "priority", "duedate", "planned_start"])
|
||||||
|
def test_ensure_fields_whitespace_only_rejected(field):
|
||||||
|
"""I-1 (R-8): « » после обрезки стало бы «» («очистить поле») — отвергается."""
|
||||||
|
with pytest.raises(CommandError) as e:
|
||||||
|
commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", field: " "}}])
|
||||||
|
assert f'{field} — из одних пробелов; для очистки — "" или null' in str(e.value)
|
||||||
|
assert "команда 1 (ensure_fields)" in str(e.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["", None])
|
||||||
|
def test_ensure_fields_explicit_clear_accepted(value):
|
||||||
|
cmds = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "assignee": value}}])
|
||||||
|
assert cmds[0]["params"]["assignee"] == value
|
||||||
|
|
||||||
|
|
||||||
|
def test_rank_before_after_whitespace_still_means_not_given():
|
||||||
|
cmds = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": "A-2", "after": " "}}])
|
||||||
|
assert cmds[0]["params"]["after"] == ""
|
||||||
|
|||||||
@ -299,8 +299,7 @@ def test_migrates_v3_creates_awpca(tmp_path):
|
|||||||
assert {k: v for k, v in api.project_rows(conn)[0].items() if not k.startswith(("wiki_", "jira_"))} | {
|
assert {k: v for k, v in api.project_rows(conn)[0].items() if not k.startswith(("wiki_", "jira_"))} | {
|
||||||
"created_at": None, "issues_count": None} == {
|
"created_at": None, "issues_count": None} == {
|
||||||
"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None}
|
"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None}
|
||||||
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте
|
assert settings.get(conn, "base_jql") is None and settings.get(conn, "board_id") is None # ключи v3 убраны (#129)
|
||||||
assert settings.get(conn, "board_id") == 4216
|
|
||||||
assert conn.execute("SELECT query FROM sync_run").fetchone()["query"] == new
|
assert conn.execute("SELECT query FROM sync_run").fetchone()["query"] == new
|
||||||
jqls = {r["status"]: json.loads(r["params"]).get("jql") for r in conn.execute("SELECT status, params FROM command WHERE type = 'refresh'")}
|
jqls = {r["status"]: json.loads(r["params"]).get("jql") for r in conn.execute("SELECT status, params FROM command WHERE type = 'refresh'")}
|
||||||
assert jqls["done"] == "project = AURORA AND labels = AWPCA" # завершённые не трогаем
|
assert jqls["done"] == "project = AURORA AND labels = AWPCA" # завершённые не трогаем
|
||||||
|
|||||||
@ -189,3 +189,36 @@ def test_sweep_downloads(tmp_path):
|
|||||||
assert files.sweep_downloads(root) == ["31337"]
|
assert files.sweep_downloads(root) == ["31337"]
|
||||||
assert not old.exists() and fresh.exists() and (root / "loose.txt").exists()
|
assert not old.exists() and fresh.exists() and (root / "loose.txt").exists()
|
||||||
assert files.sweep_downloads(tmp_path / "нет") == []
|
assert files.sweep_downloads(tmp_path / "нет") == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.geteuid() == 0, reason="root читает любой файл")
|
||||||
|
def test_unreadable_file_is_none_with_warning_and_put_rewrites(root, caplog):
|
||||||
|
"""I-3 (R-9): настоящий chmod 0 — size и read дают None с предупреждением, put пишет файл заново."""
|
||||||
|
sha = files.put(root, b"abc")[0]
|
||||||
|
target = root / sha
|
||||||
|
os.chmod(target, 0)
|
||||||
|
try:
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert files.size(root, sha) is None
|
||||||
|
assert files.read(root, sha) is None
|
||||||
|
assert str(target) in caplog.text and "не читается" in caplog.text
|
||||||
|
assert files.put(root, b"abc") == (sha, 3)
|
||||||
|
assert target.read_bytes() == b"abc"
|
||||||
|
finally:
|
||||||
|
os.chmod(target, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("func", [files.size, files.read])
|
||||||
|
def test_directory_instead_of_file_is_none(root, caplog, func):
|
||||||
|
(root / ABC).mkdir(parents=True)
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert func(root, ABC) is None
|
||||||
|
assert str(root / ABC) in caplog.text and "не читается" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("func", [files.size, files.read])
|
||||||
|
def test_missing_file_is_none_without_log(root, caplog, func):
|
||||||
|
root.mkdir()
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
assert func(root, ABC) is None
|
||||||
|
assert caplog.text == ""
|
||||||
|
|||||||
@ -34,3 +34,10 @@ def test_env_home(monkeypatch, tmp_path):
|
|||||||
from sdlc_hub.core import paths
|
from sdlc_hub.core import paths
|
||||||
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
|
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
|
||||||
assert paths.home() == tmp_path / "x"
|
assert paths.home() == tmp_path / "x"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_env_home_is_default(monkeypatch, tmp_path):
|
||||||
|
from sdlc_hub.core import paths
|
||||||
|
monkeypatch.setenv("SDLC_HUB_HOME", "")
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
assert paths.home() == tmp_path / ".local" / "share" / "sdlc-hub"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import unicodedata
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@ -952,3 +953,23 @@ def test_batch_create_description_lists_sprint_types():
|
|||||||
doc = jmcp.batch_create.__doc__
|
doc = jmcp.batch_create.__doc__
|
||||||
for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"):
|
for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"):
|
||||||
assert word in doc
|
assert word in doc
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_create_default_filename_nfc(live, tmp_path):
|
||||||
|
"""M-4 #49: имя по умолчанию из пути (NFD с macOS) приводится к NFC."""
|
||||||
|
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []})
|
||||||
|
nfd = unicodedata.normalize("NFD", "отчёт.pdf")
|
||||||
|
f = tmp_path / nfd
|
||||||
|
f.write_bytes(b"pdf")
|
||||||
|
out = jmcp.batch_create("файл", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}])
|
||||||
|
assert _batch_commands(out)[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stored, asked", [("NFD", "NFC"), ("NFC", "NFD")])
|
||||||
|
def test_attachment_name_match_normalized(downloads, monkeypatch, stored, asked):
|
||||||
|
name = "отчёт.pdf"
|
||||||
|
att = _att("31337", unicodedata.normalize(stored, name), 3, state="ready")
|
||||||
|
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc")
|
||||||
|
_use(monkeypatch, hub)
|
||||||
|
out = asyncio.run(jmcp.attachment(ATT_KEY, unicodedata.normalize(asked, name)))
|
||||||
|
assert not out.startswith("у ") and ("POST", "/attachments/31337/fetch", {"author": "claude"}) in hub.calls
|
||||||
|
|||||||
@ -369,6 +369,7 @@ def test_attachment_moves_to_other_key(conn):
|
|||||||
@pytest.mark.parametrize("bad, field", [
|
@pytest.mark.parametrize("bad, field", [
|
||||||
("x", "attachments"), (["x"], "attachments[0]"),
|
("x", "attachments"), (["x"], "attachments[0]"),
|
||||||
([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": "1a", "filename": "a"}], "attachments[0].id"),
|
([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": "1a", "filename": "a"}], "attachments[0].id"),
|
||||||
|
([{"id": "²", "filename": "a"}], "attachments[0].id"), ([{"id": "١٢", "filename": "a"}], "attachments[0].id"),
|
||||||
([{"id": "-5", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"),
|
([{"id": "-5", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"),
|
||||||
([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"),
|
([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"),
|
||||||
([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"),
|
([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"),
|
||||||
|
|||||||
@ -601,7 +601,7 @@ def test_attachment_without_file_rejected(conn, store):
|
|||||||
@pytest.mark.parametrize("params, msg", [
|
@pytest.mark.parametrize("params, msg", [
|
||||||
({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
|
({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
|
||||||
({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
|
({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
|
||||||
({"filename": " "}, "команда 1: filename пустой"),
|
({"filename": " "}, "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename"),
|
||||||
({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
|
({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
|
||||||
({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
|
({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
|
||||||
({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
|
({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
|
||||||
@ -1287,6 +1287,12 @@ def test_request_fetch_agent_too_old(fconn, tmp_path):
|
|||||||
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
|
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_fetch_agent_version_not_ascii_digits_ascii(fconn, tmp_path):
|
||||||
|
"""#86 F.1: версия агента «²» — неизвестная, не блокирует и не бросает ValueError (ascii digit)."""
|
||||||
|
fconn.execute("UPDATE agent_status SET agent_version = '²' WHERE agent = 'jira'")
|
||||||
|
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
|
||||||
|
|
||||||
|
|
||||||
def test_request_fetch_after_failed(fconn, tmp_path):
|
def test_request_fetch_after_failed(fconn, tmp_path):
|
||||||
root = tmp_path / "files"
|
root = tmp_path / "files"
|
||||||
first = jira_fetch.request_fetch(fconn, root, "31337", "x")
|
first = jira_fetch.request_fetch(fconn, root, "31337", "x")
|
||||||
@ -1367,3 +1373,11 @@ def test_retry_sprint_meta_gets_jql_tail(conn, add_project):
|
|||||||
cmds = queue.batch_view(conn, r["id"])["commands"]
|
cmds = queue.batch_view(conn, r["id"])["commands"]
|
||||||
assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"]
|
assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"]
|
||||||
assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}
|
assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("version", ["²", "10-dev", "١٢"])
|
||||||
|
def test_agent_view_non_digit_version_is_unknown(conn, version):
|
||||||
|
"""I-2: нецифровая версия агента — неизвестная: agent_view не бросает ValueError и не предупреждает."""
|
||||||
|
queue.claim(conn, "jira", agent_version=version, settings_version=1)
|
||||||
|
v = queue.agent_view(conn, "jira")
|
||||||
|
assert v["agent_version"] == version and v["warning"] is None
|
||||||
|
|||||||
@ -298,3 +298,100 @@ def test_invalid_manifest_warning_not_repeated_across_alternating_dirs(tmp_path,
|
|||||||
assert release.current(bad) is None
|
assert release.current(bad) is None
|
||||||
assert release.current(none) is None
|
assert release.current(none) is None
|
||||||
assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1
|
assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- пакет C: восстановление прерванной публикации (design hub-backend-fixes, решение 9; #39 T2.2, M-6) ----------
|
||||||
|
|
||||||
|
def _no_space(monkeypatch):
|
||||||
|
def no_space(fd):
|
||||||
|
raise OSError(28, "No space left on device")
|
||||||
|
|
||||||
|
monkeypatch.setattr(release.os, "fsync", no_space)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_missing_agent_restores_prev(tmp_path, monkeypatch, caplog):
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||||
|
release.publish(rd, agent_file(tmp_path, 5))
|
||||||
|
os.replace(rd / "agent.py", rd / "agent.py.prev") # процесс упал между переименованием и записью
|
||||||
|
_no_space(monkeypatch)
|
||||||
|
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||||
|
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
|
||||||
|
monkeypatch.undo()
|
||||||
|
assert (rd / "agent.py").read_bytes() == v5
|
||||||
|
assert not (rd / "agent.py.prev").exists()
|
||||||
|
assert release.load(rd)["version"] == 5
|
||||||
|
assert "agent.py.prev" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_partial_agent_keeps_good_prev(tmp_path, monkeypatch):
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||||
|
release.publish(rd, agent_file(tmp_path, 5))
|
||||||
|
(rd / "agent.py.prev").write_bytes(v5)
|
||||||
|
(rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode())
|
||||||
|
_no_space(monkeypatch)
|
||||||
|
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||||
|
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
|
||||||
|
monkeypatch.undo()
|
||||||
|
assert (rd / "agent.py").read_bytes() == v5
|
||||||
|
assert not (rd / "agent.py.prev").exists() # _undo вернул .prev на место agent.py
|
||||||
|
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
|
||||||
|
assert release.load(rd)["version"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_success_after_crash(tmp_path):
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||||
|
release.publish(rd, agent_file(tmp_path, 5))
|
||||||
|
(rd / "agent.py.prev").write_bytes(v5)
|
||||||
|
(rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode())
|
||||||
|
v6 = agent_file(tmp_path, 6, name="new.py")
|
||||||
|
release.publish(rd, v6)
|
||||||
|
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
|
||||||
|
assert (rd / "agent.py").read_bytes() == v6.read_bytes()
|
||||||
|
assert release.load(rd)["version"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_leftover_prev_removed(tmp_path, monkeypatch):
|
||||||
|
"""agent.py совпадает с манифестом, рядом лишний .prev: _recover удаляет его, даже если публикация потом падает
|
||||||
|
до переименования agent.py в .prev."""
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||||
|
release.publish(rd, agent_file(tmp_path, 5))
|
||||||
|
(rd / "agent.py.prev").write_bytes(v5)
|
||||||
|
_fail_replace_into(monkeypatch, "agent.py.prev")
|
||||||
|
with pytest.raises(release.PublishError, match="не удалось записать публикацию"):
|
||||||
|
release.publish(rd, agent_file(tmp_path, 6, name="new.py"))
|
||||||
|
monkeypatch.undo()
|
||||||
|
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
|
||||||
|
assert release.load(rd)["version"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_unknown_state_untouched(tmp_path, caplog):
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
rd.mkdir()
|
||||||
|
(rd / "agent.py.prev").write_bytes(b"AGENT_VERSION = 3\n")
|
||||||
|
release._recover(rd)
|
||||||
|
assert _listing(rd) == {"agent.py.prev": b"AGENT_VERSION = 3\n"}
|
||||||
|
assert "agent.py.prev" in caplog.text
|
||||||
|
assert release.publish(rd, agent_file(tmp_path, 1))["version"] == 1 # первая публикация проходит
|
||||||
|
assert release.load(rd)["version"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_oserror_does_not_stop_publish(tmp_path, monkeypatch, caplog):
|
||||||
|
rd = tmp_path / "release"
|
||||||
|
v5 = agent_file(tmp_path, 5).read_bytes()
|
||||||
|
release.publish(rd, agent_file(tmp_path, 5))
|
||||||
|
(rd / "agent.py.prev").write_bytes(v5)
|
||||||
|
real = Path.unlink
|
||||||
|
|
||||||
|
def fake(self, *a, **kw):
|
||||||
|
if self.name == "agent.py.prev" and not getattr(fake, "done", False):
|
||||||
|
fake.done = True
|
||||||
|
raise OSError(13, "Permission denied")
|
||||||
|
return real(self, *a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "unlink", fake)
|
||||||
|
assert release.publish(rd, agent_file(tmp_path, 6, name="new.py"))["version"] == 6
|
||||||
|
assert "Permission denied" in caplog.text
|
||||||
|
|||||||
@ -138,3 +138,48 @@ def test_sprint_types_pacing(conn):
|
|||||||
assert jira_settings.pacing(conn)["ensure_rank"] == 30
|
assert jira_settings.pacing(conn)["ensure_rank"] == 30
|
||||||
settings.validate("pacing", {"ensure_rank": 10})
|
settings.validate("pacing", {"ensure_rank": 10})
|
||||||
assert queue.estimate(conn, ["ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"]) == 90
|
assert queue.estimate(conn, ["ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"]) == 90
|
||||||
|
|
||||||
|
|
||||||
|
def test_v3_keys_removed_on_open(conn):
|
||||||
|
v0 = settings.get(conn, "settings_version")
|
||||||
|
conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES ('board_id', '4216'), ('base_jql', '\"project = AURORA\"')")
|
||||||
|
settings.ensure_defaults(conn)
|
||||||
|
stored = {r["key"] for r in conn.execute("SELECT key FROM setting")}
|
||||||
|
assert {"board_id", "base_jql"}.isdisjoint(stored)
|
||||||
|
assert settings.get(conn, "settings_version") == v0 # версия настроек не растёт
|
||||||
|
with pytest.raises(ValueError, match="доска Jira — свойство проекта"):
|
||||||
|
settings.validate("board_id", 4216)
|
||||||
|
with pytest.raises(ValueError, match="собирается из проектов"):
|
||||||
|
settings.validate("base_jql", "project = AURORA")
|
||||||
|
|
||||||
|
|
||||||
|
def test_v3_keys_removed_on_hub_start_api(tmp_path):
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from sdlc_hub.core import api, db
|
||||||
|
from sdlc_hub.core.client import HubClient, HubError
|
||||||
|
path = tmp_path / "hub.sqlite"
|
||||||
|
c = db.connect(path)
|
||||||
|
settings.ensure_defaults(c)
|
||||||
|
c.execute("INSERT OR REPLACE INTO setting(key, value) VALUES ('board_id', '4216'), ('base_jql', '\"project = AURORA\"')")
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
h = api.Hub(path, backups_dir=tmp_path / "backups")
|
||||||
|
conn = h.conn()
|
||||||
|
token = settings.tokens(conn)["client"]
|
||||||
|
conn.close()
|
||||||
|
srv = api.make_server(h, "127.0.0.1", 0)
|
||||||
|
th = threading.Thread(target=srv.serve_forever, daemon=True)
|
||||||
|
th.start()
|
||||||
|
try:
|
||||||
|
client = HubClient(f"http://127.0.0.1:{srv.server_port}", token)
|
||||||
|
got = client.get("/settings")
|
||||||
|
assert "board_id" not in got and got["base_jql"] is None # base_jql вычисляется (без проектов — None)
|
||||||
|
for key in ("board_id", "base_jql"):
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
client.put("/settings", {key: 1})
|
||||||
|
assert e.value.status == 422 and e.value.detail
|
||||||
|
finally:
|
||||||
|
srv.shutdown()
|
||||||
|
srv.server_close()
|
||||||
|
th.join(5)
|
||||||
|
|||||||
@ -228,3 +228,12 @@ def test_page_attachment_failed_error_visible(api):
|
|||||||
c.close()
|
c.close()
|
||||||
att = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"][0]
|
att = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"][0]
|
||||||
assert att["state"] == "failed" and att["error"] == "Eva: 404"
|
assert att["state"] == "failed" and att["error"] == "Eva: 404"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("project", [5, ["AWPCA"], {"a": 1}, True])
|
||||||
|
def test_sync_project_not_string_422(api, project):
|
||||||
|
first_sync(api)
|
||||||
|
before = api.rows("SELECT id FROM batch")
|
||||||
|
e = err(api.client.post, "/wiki/sync", {"project": project})
|
||||||
|
assert e.status == 422 and e.error == "bad_request" and e.detail == "project — строка"
|
||||||
|
assert api.rows("SELECT id FROM batch") == before # пакета не создано
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user