From e180bb4e5ff2bdf59e5f2359c2915f946f5b86e9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 15 Oct 2026 10:42:03 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=20PUT=20/?= =?UTF-8?q?settings,=20=C2=AB=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=BE=C2=BB,=20CRLF=20=D0=B2=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8,=20=D1=80=D0=B5=D0=BB=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=20resolve()=20(O-11,=20O-10,=20O-9,=20minor-1,=20T3.1=20#16)?= =?UTF-8?q?=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUT /settings отвечает всеми настройками (R-3); пропуск при cancelled-создателе — «создание задачи new:N отменено» (R-4); ensure_fields сравнивает строки без различия CRLF/LF; _cleaned по resolve(); _type_error по веткам (решения 9–12, 15). Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/jira_hub/api.py | 8 ++++---- src/jira_hub/commands.py | 29 +++++++++++++++++++---------- src/jira_hub/queue.py | 8 +++++++- src/jira_hub/relay.py | 16 +++++++++++++--- tests/test_api.py | 17 +++++++++++++++++ tests/test_commands.py | 30 ++++++++++++++++++++++++++++++ tests/test_queue.py | 37 +++++++++++++++++++++++++++++++++++++ tests/test_relay.py | 18 ++++++++++++++++++ 9 files changed, 146 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d2b3f36..03e5d97 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | | `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | -| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`) | +| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) | ```bash TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p') diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 1c53223..2a20aae 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -306,7 +306,8 @@ def h_settings_get(hub, conn, m, q, body): def h_settings_put(hub, conn, m, q, body): """Записать настройки пакетом (всё или ничего). До записи проверяются защищённые ключи и значения - (settings.validate): ошибка — 422, версия настроек не меняется.""" + (settings.validate): ошибка — 422, версия настроек не меняется. Ответ — все настройки после записи, как у + GET /settings (hub-hygiene-d, решение 9).""" bad = [k for k in body if k in settings.PROTECTED] if bad: raise ApiError(422, "protected", ", ".join(bad)) @@ -315,11 +316,10 @@ def h_settings_put(hub, conn, m, q, body): settings.validate(key, value) except ValueError as e: raise ApiError(422, "bad_setting", str(e)) - version = settings.get(conn, "settings_version") with db.transaction(conn): for key, value in body.items(): - version = settings.set_(conn, key, value) - return 200, {"settings_version": version} + settings.set_(conn, key, value) + return 200, settings.get_all(conn) ROUTES = [ diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index a1752fc..9ffd44f 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -57,15 +57,16 @@ def _type_error(ctype, name, value) -> str | None: if value is None: return None if name in LIST_PARAMS: - ok, want = isinstance(value, list) and all(isinstance(v, str) for v in value), "список строк" - elif name in BOOL_PARAMS: - ok, want = isinstance(value, bool), "true или false" - elif ctype == "ensure_status" and name == "target": - ok = isinstance(value, str) or (isinstance(value, list) and all(isinstance(v, str) for v in value)) - want = "строка или список строк" - else: - ok, want = isinstance(value, str), "строка" - return None if ok else f"{name} — {want}" + return None if _is_str_list(value) else f"{name} — список строк" + if name in BOOL_PARAMS: + return None if isinstance(value, bool) else f"{name} — true или false" + if ctype == "ensure_status" and name == "target": + return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк" + return None if isinstance(value, str) else f"{name} — строка" + + +def _is_str_list(value) -> bool: + return isinstance(value, list) and all(isinstance(v, str) for v in value) def validate(commands) -> list[dict]: @@ -133,6 +134,14 @@ def estimate(types, pacing: dict) -> int: return sum(int(pacing.get(t, 0)) for t in types) +def _norm_text(value): + """Значение поля для сравнения с зеркалом: переводы строки CRLF и LF не различаются (описание, правленное в UI Jira, + приходит с CRLF, O-9 #16); null и пустая строка равны (решение 11).""" + if isinstance(value, str): + value = value.replace("\r\n", "\n") + return value or None + + def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, @@ -145,7 +154,7 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: return False, None if ctype == "ensure_fields": for field in FIELD_PARAMS: - if field in p and (issue.get(field) or None) != (p[field] or None): + if field in p and _norm_text(issue.get(field)) != _norm_text(p[field]): return False, None if any(l not in issue["labels"] for l in p.get("labels_add", [])): return False, None diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 2d46c71..3faead8 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -114,6 +114,12 @@ def _definer_status(conn, batch_id, temp_key): return r["status"] if r else "failed" +def _skip_reason(conn, batch_id, unresolved) -> str: + """Текст skipped для зависимой команды: отменённое создание называется отменой (O-10 #16, решение 10).""" + cancelled = [k for k in unresolved if _definer_status(conn, batch_id, k) == "cancelled"] + return f"создание задачи {cancelled[0]} отменено" if cancelled else f"задача {unresolved[0]} не создана" + + def _next(conn) -> dict | None: rows = conn.execute("SELECT c.* FROM command c JOIN batch b ON b.id = c.batch_id " "WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall() @@ -131,7 +137,7 @@ def _next(conn) -> dict | None: unresolved = [] if cmd["params"]["keys"] else unresolved if unresolved: conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?", - (db.now(), f"задача {unresolved[0]} не создана", cmd["id"])) + (db.now(), _skip_reason(conn, cmd["batch_id"], unresolved), cmd["id"])) continue return cmd return None diff --git a/src/jira_hub/relay.py b/src/jira_hub/relay.py index 68ffb61..9f90f3c 100644 --- a/src/jira_hub/relay.py +++ b/src/jira_hub/relay.py @@ -61,7 +61,7 @@ class Relay: self._cleanup_at = 0.0 self._ready = False self._warned: str | None = None - self._cleaned: set[Path] = set() # папки, чей work/ вычищен после старта реле + self._cleaned: set[Path] = set() # папки (по resolve()), чей work/ вычищен после старта реле self.workers: list[threading.Thread] = [] # --- цикл --------------------------------------------------------------------- @@ -124,12 +124,13 @@ class Relay: def _prepare(self) -> None: for sub in ("req", "resp", "work"): (self.root / sub).mkdir(exist_ok=True) - if self.root not in self._cleaned: + key = _same_dir_key(self.root) + if key not in self._cleaned: # первая подготовка этой папки после старта: взятые прошлым процессом запросы уже никто не обработает for stale in (self.root / "work").iterdir(): stale.unlink(missing_ok=True) say(f"relay: удалён незавершённый запрос {stale.name} (старт реле)") - self._cleaned.add(self.root) + self._cleaned.add(key) else: # пропал подкаталог или не было доступа: взятые запросы ещё обрабатываются своими потоками — work/ не трогаем say(f"relay: папка {self.root} подготовлена заново") @@ -225,6 +226,15 @@ class Relay: p.unlink(missing_ok=True) +def _same_dir_key(path: Path) -> Path: + """Ключ папки для «уже вычищена»: resolve() — симлинк и другая запись той же папки совпадают (minor-1 #16, + решение 12); ошибка resolve — путь как записан.""" + try: + return path.resolve() + except (OSError, RuntimeError): + return path + + def _error(error: str, detail: str) -> str: return json.dumps({"error": error, "detail": detail}, ensure_ascii=False) diff --git a/tests/test_api.py b/tests/test_api.py index e41ee42..384812a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -509,3 +509,20 @@ def test_agent_reported_even_with_bad_update_fields(hub, tmp_path): v = hub["client"].get("/agent") assert v["agent_reported"] is True and v["launcher_version"] is None assert v["update_hint"] == "агент запущен без run.py — обновится только вручную" + + +def test_settings_put_returns_all_settings(hub): + """O-11 #16, решение 9, спека «Ответ на запись настроек»: все настройки после записи, как GET /settings, без токенов.""" + c = hub["client"] + v0 = c.get("/settings")["settings_version"] + out = c.put("/settings", {"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"}) + assert out["relay_dir"] == "/Volumes/externalSSD/utm/rshb/jira-bridge" and out["settings_version"] == v0 + 1 + assert "tokens" not in out and out["base_jql"] == "project = AURORA AND labels = AWPCA" + assert out == c.get("/settings") + + +def test_settings_put_empty_body_returns_settings_unchanged(hub): + """Пустое тело — 200, все настройки, версия не меняется.""" + c = hub["client"] + before = c.get("/settings") + assert c.put("/settings", {}) == before diff --git a/tests/test_commands.py b/tests/test_commands.py index 8f8b746..5041530 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -147,3 +147,33 @@ def test_validate_accepts_null_for_clearable_and_path_target(): {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}}, ]) assert len(out) == 3 + + +@pytest.mark.parametrize("mirror_value, param, expected", [ + ("a\r\nb", "a\nb", True), ("a\nb", "a\r\nb", True), ("a\r\nb\r\n", "a\nb\n", True), + ("a\r\nb", "a b", False), (None, "", True), ("", None, True), ("a\rb", "a\nb", False), +]) +def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expected): + """O-9 #16, решение 11: CRLF и LF не различаются, null и пустая строка равны; одиночный CR — другой текст. + summary с CRLF в зеркале и LF в команде — в каждом случае: сравнение одно для всех строковых полей.""" + conn.execute("UPDATE issue SET description = ?, summary = ? WHERE key = 'A-1'", (mirror_value, "x\r\ny")) + ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields", + "params": {"key": "A-1", "description": param, "summary": "x\ny"}}, LT) + assert ok is expected + + +@pytest.mark.parametrize("ctype, name, value, msg", [ + ("ensure_fields", "duedate", True, "duedate — строка"), + ("ensure_fields", "labels_add", "AWPCA", "labels_add — список строк"), + ("ensure_fields", "labels_add", ["x", 1], "labels_add — список строк"), + ("ensure_link", "replace", "да", "replace — true или false"), + ("ensure_status", "target", 5, "target — строка или список строк"), + ("ensure_status", "target", ["Разработка", "Готово"], None), + ("ensure_status", "target", "Готово", None), + ("ensure_fields", "labels_add", [], None), + ("ensure_fields", "assignee", None, None), + ("ensure_link", "replace", False, None), +]) +def test_type_error_messages(ctype, name, value, msg): + """T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один).""" + assert commands._type_error(ctype, name, value) == msg diff --git a/tests/test_queue.py b/tests/test_queue.py index 66ff077..7391674 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -462,3 +462,40 @@ def test_cancel_batch_reports_still_claimed(conn): view = queue.cancel_batch(conn, b["id"]) assert view["still_claimed"] == [{"id": c["id"], "type": "ensure_fields"}] assert queue.get_command(conn, c["id"])["status"] == "claimed" + + +def test_dependent_skipped_when_creation_cancelled(conn): + """O-10 #16, решение 10, спека «Создание отменено»: зависимая команда — «создание задачи new:N отменено».""" + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:901", "issue_type": "task", "summary": "Отменённая"}}, + {"type": "ensure_comment", "params": {"key": "new:901", "marker": "m", "body": "b"}}]) + c = queue.claim(conn) + queue.cancel_command(conn, c["id"], force=True) + assert queue.claim(conn) is None # комментарий и хвостовой refresh пропущены + cmds = queue.batch_view(conn, b["id"])["commands"] + assert [x["status"] for x in cmds] == ["cancelled", "skipped", "skipped"] + assert cmds[1]["error"] == "создание задачи new:901 отменено" + + +def test_dependent_on_failed_and_cancelled_names_cancelled(conn): + """Review Focus 4: два временных ключа — создатель первого failed, второго cancelled: текст по отменённому.""" + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Упавшая"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Отменённая"}}, + {"type": "ensure_link", "params": {"source": "new:1", "target": "new:2", "link_type": "связанные"}}]) + first = queue.claim(conn) + queue.report_result(conn, first["id"], "failed", error="HTTP 400") + second = queue.claim(conn) + queue.cancel_command(conn, second["id"], force=True) + queue.claim(conn) + link = queue.batch_view(conn, b["id"])["commands"][2] + assert link["status"] == "skipped" and link["error"] == "создание задачи new:2 отменено" + + +def test_reconcile_confirms_fields_with_crlf(conn): + """O-9 #16, решение 11, спека «Описание с CRLF в зеркале»: потерянный отчёт закрывается по состоянию.""" + queue.create_batch(conn, "x", "claude", [{"type": "ensure_fields", "params": {"key": "A-1", "description": "a\nb"}}]) + c = queue.claim(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "description": "a\r\nb", "labels": []}], []) + assert queue.reconcile(conn) == [c["id"]] + assert queue.get_command(conn, c["id"])["status"] == "confirmed_by_state" diff --git a/tests/test_relay.py b/tests/test_relay.py index 088e806..6f92209 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -402,3 +402,21 @@ def test_relay_targets_serve_host(tmp_path, monkeypatch): stop.set() srv.server_close() assert seen == ["127.0.0.1", "localhost"] # конкретный адрес serve — туда же ходит реле (M-1 #8) + + +def test_symlink_to_same_dir_not_cleaned_again(env, tmp_path, capsys): + """minor-1 #16, решение 12: relay_dir сменили на симлинк к той же папке — work/ не чистится второй раз.""" + r, root, h = env["relay"], env["root"], env["hub"] + r.step() # root вычищен + link = tmp_path / "bridge-link" + link.symlink_to(root, target_is_directory=True) + (root / "work" / "busy.json").write_text("{}", encoding="utf-8") # взятый запрос ещё обрабатывается + conn = h.conn() + settings.set_(conn, "relay_dir", str(link)) + conn.close() + r._settings_at = None + capsys.readouterr() + r.step() + out = capsys.readouterr().out + assert (root / "work" / "busy.json").exists() + assert "удалён незавершённый запрос" not in out and "подготовлена заново" in out