diff --git a/README.md b/README.md index 00a766c..99bc5b2 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ uv run jira-hub import ../admin/jira-sync Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON: `notes` (сколько заметок записано), `expected_links`, `missing_keys` (ключи из CSV, которых нет в зеркале) и -`missing_links` (ожидаемые связи, которых нет). Запускать после первого `refresh` с `full: true`: пока зеркало пусто, +`missing_links` (ожидаемые связи, которых нет), `reversed_links` (ожидаемые связи, которые в Jira стоят в обратную сторону — для «Связанных» это та же связь). Запускать после первого `refresh` с `full: true`: пока зеркало пусто, все ключи будут в `missing_keys`. Заметки к ещё не загруженным задачам сохраняются. ## MCP в Claude diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index 320f6b3..f44a249 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -111,21 +111,13 @@ def estimate(types, pacing: dict) -> int: return sum(int(pacing.get(t, 0)) for t in types) -def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]: - """Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются. - reporter: логин Jira, под которым агент заводит задачи. ensure_issue подтверждается ТОЛЬКО задачей этого - автора; пока reporter неизвестен (None), по состоянию не подтверждается (R-11): совпадение темы с чужой - задачей не доказательство, а запись в чужую задачу недопустима.""" +def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: + """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются: + потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, + решение 4); совпадение темы с задачей неизвестного автора не доказательство.""" ctype, p = cmd["type"], cmd["params"] - if ctype in ("ensure_comment", "refresh"): + if ctype in ("ensure_issue", "ensure_comment", "refresh"): return False, None - if ctype == "ensure_issue": - if reporter is None: - return False, None - row = conn.execute("SELECT key FROM issue WHERE summary = ? AND reporter = ? " - "AND substr(created, 1, 10) >= date('now', '-7 days') ORDER BY created DESC LIMIT 1", - (p["summary"], reporter)).fetchone() - return (True, {"key": row["key"]}) if row else (False, None) issue = mirror.get_issue(conn, p.get("key") or p.get("source")) if issue is None: return False, None diff --git a/src/jira_hub/importer.py b/src/jira_hub/importer.py index 6f269cb..7e99b50 100644 --- a/src/jira_hub/importer.py +++ b/src/jira_hub/importer.py @@ -69,7 +69,7 @@ def _import(conn, folder: Path) -> dict: csv_keys.append(key) link_types = settings.get(conn, "link_types") or {} - expected, missing_links = [], [] + expected, missing_links, reversed_links = [], [], [] for r in _rows(folder / "links.csv"): src = new_keys.get(_clean(r.get("Откуда")) or "", _clean(r.get("Откуда"))) dst = new_keys.get(_clean(r.get("Куда")) or "", _clean(r.get("Куда"))) @@ -79,10 +79,14 @@ def _import(conn, folder: Path) -> dict: link = {"source": src, "target": dst, "link_type": link_types.get(kind, kind)} expected.append(link) issue = mirror.get_issue(conn, src) - present = issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"]) - if not present: + if issue and any(l["target_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_out"]): + continue + if issue and any(l["source_key"] == dst and l["link_type"] == link["link_type"] for l in issue["links_in"]): + reversed_links.append(link) # связь есть, но в Jira стоит в обратную сторону (O-3) + else: missing_links.append(link) known = set(mirror.known_keys(conn)) missing_keys = sorted({k for k in csv_keys if k not in known}) - return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links} + return {"notes": notes, "expected_links": len(expected), "missing_keys": missing_keys, "missing_links": missing_links, + "reversed_links": reversed_links} diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 3b265f4..2f8a6b6 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -251,12 +251,10 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= def reconcile(conn) -> list[int]: """Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state.""" link_types = settings.get(conn, "link_types") or {} - reporter = settings.get(conn, "reporter") # задел: сейчас не задаётся, ensure_issue по состоянию не подтверждается (R-11); - # позже сюда придёт jira_user от агента changed = [] for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall(): cmd = _cmd(row) - ok, result = C.check_confirmed(conn, cmd, link_types, reporter) + ok, result = C.check_confirmed(conn, cmd, link_types) if not ok: continue with db.transaction(conn): @@ -265,8 +263,6 @@ def reconcile(conn) -> list[int]: (db.now(), _dump(result) if result else None, cmd["id"])) if cur.rowcount == 0: # отчёт агента или отмена опередили сверку continue - if cmd["type"] == "ensure_issue" and result: - mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) changed.append(cmd["id"]) return changed diff --git a/tests/test_commands.py b/tests/test_commands.py index b087f17..9e42b2f 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -108,47 +108,11 @@ def _ensure_issue(summary): return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}} -def test_check_confirmed_issue_by_summary(): - conn = _mirror() - conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") - ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") - assert ok and result == {"key": "A-1"} - ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT, reporter="petrov") - assert not ok - - -def test_ensure_issue_without_reporter_never_confirmed(): - """R-11: пока автор неизвестен, совпадение темы не подтверждает ensure_issue.""" +def test_ensure_issue_never_confirmed_by_state(): + """bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом.""" conn = _mirror() conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None) - assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter=None) == (False, None) - - -def test_ensure_issue_older_than_7_days_not_confirmed(): - conn = _mirror() - conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") - conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-30 days') WHERE key = 'A-1'") - ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") - assert ok is False - - -def test_ensure_issue_reporter_filter(): - conn = _mirror() - conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'") - ok, _ = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="ivanov") - assert ok is False - ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") - assert ok and result == {"key": "A-1"} - - -def test_ensure_issue_two_matches_takes_newest(): - conn = _mirror() - mirror.apply_state(conn, [{"key": "A-3", "summary": "Тема", "reporter": "petrov"}], []) - conn.execute(f"UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now', '-1 day') WHERE key = 'A-1'") - conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-3'") - ok, result = commands.check_confirmed(conn, _ensure_issue("Тема"), LT, reporter="petrov") - assert ok and result == {"key": "A-3"} def test_validate_rejects_duplicate_issue_summary(): diff --git a/tests/test_importer.py b/tests/test_importer.py index 7e77125..6339981 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -48,6 +48,7 @@ def test_import_notes_and_report(tmp_path): assert rep["missing_keys"] == ["AURORA-1350", "AURORA-1416", "AURORA-1529"] assert rep["expected_links"] == 2 # new:3 без ключа — не сверяется assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] + assert rep["reversed_links"] == [] assert importer.import_package(conn, tmp_path)["notes"] == 6 assert conn.execute("SELECT COUNT(*) AS n FROM issue_note").fetchone()["n"] == 6 @@ -98,4 +99,19 @@ def test_missing_csv_files_give_empty_report(tmp_path): conn = db.connect(":memory:") settings.ensure_defaults(conn) rep = importer.import_package(conn, tmp_path) - assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": []} + assert rep == {"notes": 0, "expected_links": 0, "missing_keys": [], "missing_links": [], "reversed_links": []} + + +def test_reversed_link_reported_separately(tmp_path): + make_package(tmp_path) + write_csv(tmp_path / "links.csv", ["Откуда", "Тип", "Куда", "Пояснение (внутр.)"], + [["AURORA-1533", "связанные", "AURORA-1586", "в Jira стоит наоборот"], + ["AURORA-1533", "блокирует начало", "AURORA-1416", "нет совсем"]]) + conn = db.connect(":memory:") + settings.ensure_defaults(conn) + settings.set_(conn, "link_types", {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"}) + mirror.apply_state(conn, [{"key": "AURORA-1533", "summary": "т", "labels": []}, {"key": "AURORA-1586", "summary": "т", "labels": []}], + [{"source_key": "AURORA-1586", "target_key": "AURORA-1533", "link_type": "ASUPR_Связанные"}]) + rep = importer.import_package(conn, tmp_path) + assert rep["reversed_links"] == [{"source": "AURORA-1533", "target": "AURORA-1586", "link_type": "ASUPR_Связанные"}] + assert rep["missing_links"] == [{"source": "AURORA-1533", "target": "AURORA-1416", "link_type": "ASUPR_Блокер_Начала"}] diff --git a/tests/test_queue.py b/tests/test_queue.py index 5f2d0df..0f4f40f 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -205,20 +205,6 @@ def test_make_backup_rotates(tmp_path): assert db.connect(tmp_path / "backups" / names[-1]).execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 -def test_reconcile_respects_reporter_setting(): - conn = fresh() - settings.set_(conn, "reporter", "robot") - queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}]) - c = queue.claim(conn) - conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE id = ?", (c["id"],)) - created = db.utcnow().isoformat() - mirror.apply_state(conn, [{"key": "A-7", "summary": "Тема", "reporter": "other", "created": created, "labels": []}], []) - assert queue.reconcile(conn) == [] # чужая задача не подтверждает - mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", "created": created, "labels": []}], []) - assert queue.reconcile(conn) == [c["id"]] - assert queue.get_command(conn, c["id"])["result"] == {"key": "A-8"} - - def _claimed_ensure_issue(conn, summary="Тема"): queue.create_batch(conn, "x", "claude", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}]) c = queue.claim(conn) @@ -227,7 +213,7 @@ def _claimed_ensure_issue(conn, summary="Тема"): def test_reconcile_does_not_confirm_ensure_issue_without_reporter(): - """R-11: настройка reporter не задана — совпадение темы не подтверждает, заметка new:N не переезжает.""" + """ensure_issue по состоянию не подтверждается: совпадение темы не закрывает команду, заметка new:N не переезжает.""" conn = fresh() c = _claimed_ensure_issue(conn) mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t") @@ -241,11 +227,9 @@ def test_reconcile_does_not_confirm_ensure_issue_without_reporter(): def test_reconcile_does_not_overwrite_finished_command(monkeypatch): """M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются.""" conn = fresh() - settings.set_(conn, "reporter", "robot") - c = _claimed_ensure_issue(conn) - mirror.set_note(conn, "new:1", {"work_kind": "разработка"}, "t") - mirror.apply_state(conn, [{"key": "A-8", "summary": "Тема", "reporter": "robot", - "created": db.utcnow().isoformat(), "labels": []}], []) + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) + c = queue.claim(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) real = queue.C.check_confirmed def racing(*a, **kw): @@ -256,7 +240,6 @@ def test_reconcile_does_not_overwrite_finished_command(monkeypatch): monkeypatch.setattr(queue.C, "check_confirmed", racing) assert queue.reconcile(conn) == [] assert queue.get_command(conn, c["id"])["status"] == "cancelled" - assert mirror.get_note(conn, "new:1") is not None # rename_note не выполнялся def _two_batches_with_dependent(conn):