Лента: крайние даты не роняют хаб, время без смещения — 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 ключей; из журнала каждой задачи взять
|
`search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей; из журнала каждой задачи взять
|
||||||
записи с `created` не раньше `since`. Таких задач нет — вызовов Jira на этом шаге нет.
|
записи с `created` не раньше `since`. Таких задач нет — вызовов Jira на этом шаге нет.
|
||||||
- `jql` (стартовая загрузка) — агент SHALL выполнить `search` по `history.jql` с `fields = key`, `expand = changelog`
|
- `jql` (стартовая загрузка) — агент SHALL выполнить `search` по `history.jql` с `fields = key`, `expand = changelog`
|
||||||
(постранично, `maxResults = 100`) и взять журнал найденных задач целиком. Задача, уже прочитанная регулярной частью,
|
(постранично, `maxResults = 100`) и взять журнал найденных задач целиком. Стартовая часть выполняется первой;
|
||||||
повторно не читается.
|
регулярная часть не запрашивает журнал задач, уже прочитанных стартовой (их журнал уже взят целиком).
|
||||||
|
|
||||||
Если у задачи `changelog.total` больше числа записей в ответе, агент SHALL дочитать журнал
|
Если у задачи `changelog.total` больше числа записей в ответе, агент SHALL дочитать журнал
|
||||||
`GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и он неполный — передать что есть, записать в лог ключ
|
`GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и он неполный — передать что есть, записать в лог ключ
|
||||||
@ -47,6 +47,11 @@ from_string, to, to_string}`: `history_id` — id записи строкой; `
|
|||||||
12 задач
|
12 задач
|
||||||
- **THEN** `search` по `history.jql` с `expand = changelog`; журнал 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
|
#### Scenario: Плановая дата без fieldId
|
||||||
- **WHEN** элемент журнала `{field: "Плановое окончание", fieldtype: "custom"}` без `fieldId`; в `GET /field` у поля с этим
|
- **WHEN** элемент журнала `{field: "Плановое окончание", fieldtype: "custom"}` без `fieldId`; в `GET /field` у поля с этим
|
||||||
именем id `customfield_10201`
|
именем id `customfield_10201`
|
||||||
|
|||||||
@ -24,7 +24,7 @@ from_string, to, to_string}`. До записи хаб SHALL проверить
|
|||||||
|
|
||||||
### Requirement: Лента изменений
|
### Requirement: Лента изменений
|
||||||
Хаб SHALL отдавать интерфейсам (клиентский токен) ленту событий за период маршрутом `GET /changes` с параметрами:
|
Хаб SHALL отдавать интерфейсам (клиентский токен) ленту событий за период маршрутом `GET /changes` с параметрами:
|
||||||
- `since`, `until` — ISO-8601 со смещением или `Z`; без `until` — текущее время; без `since` — `until` минус 24 часа;
|
- `since`, `until` — ISO-8601 со смещением или `Z` (время без смещения — 422); без `until` — текущее время; без `since` — `until` минус 24 часа;
|
||||||
- `project` — только свои задачи проекта (метка проекта; соседи не входят); неизвестный проект — 404;
|
- `project` — только свои задачи проекта (метка проекта; соседи не входят); неизвестный проект — 404;
|
||||||
- `key` — события одной задачи (без ограничения проектом);
|
- `key` — события одной задачи (без ограничения проектом);
|
||||||
- `kinds` — через запятую из `status`, `description`, `dates`, `comment`, `other`, или `all`; по умолчанию —
|
- `kinds` — через запятую из `status`, `description`, `dates`, `comment`, `other`, или `all`; по умолчанию —
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
### Requirement: Лента изменений в MCP
|
### Requirement: Лента изменений в MCP
|
||||||
Инструмент `changes` SHALL отдавать ленту изменений (`GET /changes`) с параметрами `hours` (число > 0), `since`,
|
Инструмент `changes` SHALL отдавать ленту изменений (`GET /changes`) с параметрами `hours` (число > 0), `since`,
|
||||||
`until`, `kinds`, `key`. `hours` и `since` вместе — ответ «либо hours, либо since». Без `hours` и `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` — ответ «сессия не привязана к
|
проекту P, без `key` — `project = P`; с `key` — без проекта. Без привязки и без `key` — ответ «сессия не привязана к
|
||||||
проекту: запустите MCP с --project <метка> (список — projects)», в хаб не ходить; с `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")
|
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):
|
def _time_param(q, name, default):
|
||||||
raw = q.get(name)
|
raw = q.get(name)
|
||||||
if raw in (None, ""):
|
if raw in (None, ""):
|
||||||
return default
|
return default
|
||||||
value = db.jira_utc(raw)
|
value = db.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2)
|
||||||
if value is None:
|
if value is None:
|
||||||
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
|
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -264,10 +264,10 @@ def jira_utc(value) -> str | None:
|
|||||||
s = s[:-2] + ":" + s[-2:]
|
s = s[:-2] + ":" + s[-2:]
|
||||||
try:
|
try:
|
||||||
d = datetime.fromisoformat(s)
|
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
|
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:
|
def utcnow() -> datetime:
|
||||||
|
|||||||
@ -261,11 +261,22 @@ def issue(key: str) -> str:
|
|||||||
return fmt_issue(_client().get(f"/issues/{key}"))
|
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()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str:
|
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 — одна задача (без привязки к проекту)."""
|
status, description, dates, comment, other или all; key — одна задача (без привязки к проекту)."""
|
||||||
if hours is not None and since:
|
if hours is not None and since:
|
||||||
return "либо hours, либо 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()
|
since = (now - timedelta(hours=hours)).isoformat()
|
||||||
elif not since:
|
elif not since:
|
||||||
since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
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)
|
project=None if key else PROJECT)
|
||||||
return fmt_changes(data, now)
|
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"),
|
({"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"),
|
({"kinds": "foo"}, 422, "kinds"), ({"limit": "0"}, 422, "limit"), ({"limit": "2001"}, 422, "limit"),
|
||||||
({"limit": "x"}, 422, "limit"), ({"project": "NOPE"}, 404, None),
|
({"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):
|
def test_changes_route_bad_params(hub, params, status, name):
|
||||||
with pytest.raises(HubError) as e:
|
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-22T10:05:11.123", "2026-10-22T10:05:11+00:00"),
|
||||||
("2026-10-22T02:05:11-0500", "2026-10-22T07:05:11+00:00"),
|
("2026-10-22T02:05:11-0500", "2026-10-22T07:05:11+00:00"),
|
||||||
("вчера", None), ("", None), (None, None), (42, None),
|
("вчера", 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):
|
def test_jira_utc(raw, want):
|
||||||
assert db.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")
|
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):
|
def test_changes_empty(live):
|
||||||
assert mcp_server.changes(hours=1).split("\n")[1] == "за период изменений нет"
|
assert mcp_server.changes(hours=1).split("\n")[1] == "за период изменений нет"
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user