Лента: крайние даты не роняют хаб, время без смещения — 422 в API и местное в MCP (#70, M-1, M-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a27ebc9586
commit
c6a5ef15b3
@ -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`
|
||||
|
||||
@ -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`; по умолчанию —
|
||||
|
||||
@ -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` — работает.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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] == "за период изменений нет"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user