Compare commits

..

10 Commits

Author SHA1 Message Date
Andrey Epifancev
1cb56be010 Спека: поле ensure_fields из пробелов — 422 (R-8, #129)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:23:19 +04:00
Andrey Epifancev
e20977ef49 Финальное ревью: пробельные поля ensure_fields, версия агента, чтение файлов, форма issues (#129, I-1, I-2, I-3, M-2, T2.1)
I-1 (R-8): ensure_fields с полем из одних пробелов даёт 422, очистка только "" или null.
I-2: agent_view не падает на нецифровой версии агента (GET /agent, /agents).
I-3 (R-9): files.size через fstat у открытого файла, _intact ловит OSError, put переписывает нечитаемый файл.
M-2: /agent/state отвергает пустые не-списки в issues.
T2.1, T3.1, T4.1-T4.3, T5.2, T6.1, T1.5: тесты и мелкие правки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:16:39 +04:00
Andrey Epifancev
98faabad67 Спека: рулинги R-8, R-9, R-10 по итогам финального ревью (#129)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:10:20 +04:00
Andrey Epifancev
fd13770427 Настройки: ключи v3 base_jql и board_id удаляются при открытии хаба (#129, #64)
OBSOLETE расширен ключами base_jql и board_id: ensure_defaults убирает их при каждом открытии, миграция v7 -> v8 тоже.
settings_version не растёт. PUT /settings для этих ключей по-прежнему отвечает 422 с пояснением. Тест миграции v3
обновлён: после миграции ключей в настройках нет (проекты уже созданы из них).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:02:11 +04:00
Andrey Epifancev
5dc96dd43c Публикация агента: прерванная прошлая публикация восстанавливается по манифесту (#129, #39 T2.2, M-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:48:21 +04:00
Andrey Epifancev
151a5e6175 Спека: рулинг R-7 (size каталога — None), design.md (#129)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:42:05 +04:00
Andrey Epifancev
75b1559354 Хранилище: нечитаемый файл — «файла нет» с предупреждением; пустой SDLC_HUB_HOME — по умолчанию (#129, #49 M-3, #102 T3.2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:41:20 +04:00
Andrey Epifancev
f1dae5a811 API: состояние агента и синхронизация вики неверной формы — 422, а не 500 (#129, #42 T2.1, #94 T8.1)
mirror.check_issues проверяет форму issues до записи; /wiki/sync отвергает project не строку.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:32:39 +04:00
Andrey Epifancev
4a6855d70b Цифровые id и версия агента — только ASCII 0-9, без 500 на «²» (#129, #86 F.1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:27:47 +04:00
Andrey Epifancev
abbcf0cc1f Команды: параметры обрезаются при постановке, имя вложения — NFC (#129, #111 T-F1, #49 M-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:20:17 +04:00
29 changed files with 557 additions and 25 deletions

View File

@ -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`). Цена ошибки: потеря неразобранного значения, которое нигде не читается.

View File

@ -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 даёт ту же строку

View File

@ -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

View File

@ -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))

View File

@ -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:

View File

@ -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"]

View File

@ -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, которого не сдвинули (сбой до переименования), не трогаем.

View File

@ -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)

View File

@ -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]:

View File

@ -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}")

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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 — непустая строка")

View File

@ -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",

View File

@ -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):

View File

@ -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"])

View File

@ -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")

View File

@ -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"] == ""

View File

@ -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" # завершённые не трогаем

View File

@ -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 == ""

View File

@ -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"

View File

@ -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

View File

@ -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"),

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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 # пакета не создано