diff --git a/openspec/changes/change-history/specs/agent/spec.md b/openspec/changes/change-history/specs/agent/spec.md index 0fbad25..43882cb 100644 --- a/openspec/changes/change-history/specs/agent/spec.md +++ b/openspec/changes/change-history/specs/agent/spec.md @@ -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` diff --git a/openspec/changes/change-history/specs/hub-api/spec.md b/openspec/changes/change-history/specs/hub-api/spec.md index cadacb8..2b8f58c 100644 --- a/openspec/changes/change-history/specs/hub-api/spec.md +++ b/openspec/changes/change-history/specs/hub-api/spec.md @@ -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`; по умолчанию — diff --git a/openspec/changes/change-history/specs/mcp/spec.md b/openspec/changes/change-history/specs/mcp/spec.md index ec3982d..0aea727 100644 --- a/openspec/changes/change-history/specs/mcp/spec.md +++ b/openspec/changes/change-history/specs/mcp/spec.md @@ -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` — работает. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 722cc02..18b102a 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -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 diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index b3546eb..8647a0b 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -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: diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 53b59b0..be5bc4e 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -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) diff --git a/tests/test_api.py b/tests/test_api.py index dd7564f..3ad6f5e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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: diff --git a/tests/test_db.py b/tests/test_db.py index a3ae7ea..c4d562f 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -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 diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7eb2564..715daf0 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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] == "за период изменений нет"