Лента: крайние даты не роняют хаб, время без смещения — 422 в API и местное в MCP (#70, M-1, M-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 10:50:26 +04:00
parent a27ebc9586
commit c6a5ef15b3
9 changed files with 37 additions and 10 deletions

View File

@ -8,8 +8,8 @@
`search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей; из журнала каждой задачи взять
записи с `created` не раньше `since`. Таких задач нет — вызовов Jira на этом шаге нет.
- `jql` (стартовая загрузка) — агент SHALL выполнить `search` по `history.jql` с `fields = key`, `expand = changelog`
(постранично, `maxResults = 100`) и взять журнал найденных задач целиком. Задача, уже прочитанная регулярной частью,
повторно не читается.
(постранично, `maxResults = 100`) и взять журнал найденных задач целиком. Стартовая часть выполняется первой;
регулярная часть не запрашивает журнал задач, уже прочитанных стартовой (их журнал уже взят целиком).
Если у задачи `changelog.total` больше числа записей в ответе, агент SHALL дочитать журнал
`GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и он неполный — передать что есть, записать в лог ключ
@ -47,6 +47,11 @@ from_string, to, to_string}`: `history_id` — id записи строкой; `
12 задач
- **THEN** `search` по `history.jql` с `expand = changelog`; журнал 12 задач отправлен целиком
#### Scenario: Задача в обеих выборках
- **WHEN** `history = {since, jql}`, у X записи журнала от 2020 года и от сегодня, X изменилась после `since` и входит в
выборку `history.jql`
- **THEN** хабу отправлены обе записи X; журнал X запрошен одним `search` (стартовым)
#### Scenario: Плановая дата без fieldId
- **WHEN** элемент журнала `{field: "Плановое окончание", fieldtype: "custom"}` без `fieldId`; в `GET /field` у поля с этим
именем id `customfield_10201`

View File

@ -24,7 +24,7 @@ from_string, to, to_string}`. До записи хаб SHALL проверить
### Requirement: Лента изменений
Хаб SHALL отдавать интерфейсам (клиентский токен) ленту событий за период маршрутом `GET /changes` с параметрами:
- `since`, `until` — ISO-8601 со смещением или `Z`; без `until` — текущее время; без `since` — `until` минус 24 часа;
- `since`, `until` — ISO-8601 со смещением или `Z` (время без смещения — 422); без `until` — текущее время; без `since` — `until` минус 24 часа;
- `project` — только свои задачи проекта (метка проекта; соседи не входят); неизвестный проект — 404;
- `key` — события одной задачи (без ограничения проектом);
- `kinds` — через запятую из `status`, `description`, `dates`, `comment`, `other`, или `all`; по умолчанию —

View File

@ -3,7 +3,8 @@
### Requirement: Лента изменений в MCP
Инструмент `changes` SHALL отдавать ленту изменений (`GET /changes`) с параметрами `hours` (число > 0), `since`,
`until`, `kinds`, `key`. `hours` и `since` вместе — ответ «либо hours, либо since». Без `hours` и `since` — с начала
текущих суток по местному времени машины MCP-сервера; с `hours` — `since = сейчас − hours`. В сессии, привязанной к
текущих суток по местному времени машины MCP-сервера; с `hours` — `since = сейчас − hours`. `since`/`until` без
смещения считаются местным временем машины MCP-сервера (хаб время без смещения отвергает). В сессии, привязанной к
проекту P, без `key` — `project = P`; с `key` — без проекта. Без привязки и без `key` — ответ «сессия не привязана к
проекту: запустите MCP с --project <метка> (список — projects)», в хаб не ходить; с `key` — работает.

View File

@ -251,11 +251,14 @@ def h_issue(hub, conn, m, q, body):
return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue")
_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
def _time_param(q, name, default):
raw = q.get(name)
if raw in (None, ""):
return default
value = db.jira_utc(raw)
value = db.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2)
if value is None:
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
return value

View File

@ -264,10 +264,10 @@ def jira_utc(value) -> str | None:
s = s[:-2] + ":" + s[-2:]
try:
d = datetime.fromisoformat(s)
except ValueError:
d = d if d.tzinfo else d.replace(tzinfo=timezone.utc)
return d.astimezone(timezone.utc).replace(microsecond=0).isoformat()
except (ValueError, OverflowError): # время за краем диапазона datetime — тоже «не разобралось»
return None
d = d if d.tzinfo else d.replace(tzinfo=timezone.utc)
return d.astimezone(timezone.utc).replace(microsecond=0).isoformat()
def utcnow() -> datetime:

View File

@ -261,11 +261,22 @@ def issue(key: str) -> str:
return fmt_issue(_client().get(f"/issues/{key}"))
def _local_if_naive(value: str) -> str:
"""Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть."""
if not value:
return value
try:
d = datetime.fromisoformat(value)
except ValueError:
return value
return d.astimezone().isoformat() if d.tzinfo is None else value
@mcp.tool()
@guard
def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str:
"""Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала
сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 со смещением; kinds — через запятую из
сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из
status, description, dates, comment, other или all; key — одна задача (без привязки к проекту)."""
if hours is not None and since:
return "либо hours, либо since"
@ -279,7 +290,7 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds:
since = (now - timedelta(hours=hours)).isoformat()
elif not since:
since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
data = _client().get("/changes", since=since, until=until, kinds=kinds, key=key,
data = _client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key,
project=None if key else PROJECT)
return fmt_changes(data, now)

View File

@ -997,6 +997,7 @@ def test_changes_route(hub, add_project):
({"since": "2026-10-22T12:00:00Z", "until": "2026-10-22T10:00:00Z"}, 422, "since"),
({"kinds": "foo"}, 422, "kinds"), ({"limit": "0"}, 422, "limit"), ({"limit": "2001"}, 422, "limit"),
({"limit": "x"}, 422, "limit"), ({"project": "NOPE"}, 404, None),
({"since": "2020-01-01T09:00"}, 422, "since"), ({"until": "2026-10-22"}, 422, "until"), # M-2: без смещения
])
def test_changes_route_bad_params(hub, params, status, name):
with pytest.raises(HubError) as e:

View File

@ -314,6 +314,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog):
("2026-10-22T10:05:11.123", "2026-10-22T10:05:11+00:00"),
("2026-10-22T02:05:11-0500", "2026-10-22T07:05:11+00:00"),
("вчера", None), ("", None), (None, None), (42, None),
("9999-12-31T23:00:00-0300", None), ("0001-01-01T00:00:00+0300", None), # M-1: выход за диапазон datetime
])
def test_jira_utc(raw, want):
assert db.jira_utc(raw) == want

View File

@ -587,6 +587,11 @@ def test_changes_bad_since(live):
assert mcp_server.changes(since="вчера").startswith("ошибка хаба 422")
def test_changes_naive_since_is_local(live): # M-2: время без смещения — местное
out = mcp_server.changes(since="2026-10-22T09:00", until="2026-10-22T10:00")
assert out.split("\n")[0] == "изменения с 22.10 09:00 по 22.10 10:00"
def test_changes_empty(live):
assert mcp_server.changes(hours=1).split("\n")[1] == "за период изменений нет"