fix(hub): подтверждение ensure_link с конфликтами и ensure_issue по автору и давности

check_confirmed для ensure_link: связь есть и нет конфликтов (другой тип между теми же задачами
или обратное направление любого типа). check_confirmed для ensure_issue: необязательный reporter,
сужает поиск по теме до задач этого автора. Тесты: конфликты, обратное направление при обоих
ключах в зеркале, давность 7 дней, фильтр по reporter, выбор самой новой из двух.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 22:03:59 +04:00
parent 1fd64e974d
commit ccf519c9e7
2 changed files with 79 additions and 13 deletions

View File

@ -106,14 +106,19 @@ 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) -> tuple[bool, dict | None]:
"""Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются."""
def check_confirmed(conn, cmd, link_types: dict, reporter: str | None = None) -> tuple[bool, dict | None]:
"""Виден ли желаемый результат в зеркале. ensure_comment и refresh по состоянию не подтверждаются.
reporter: если задан, ensure_issue подтверждается только задачей, заведённой этим пользователем."""
ctype, p = cmd["type"], cmd["params"]
if ctype in ("ensure_comment", "refresh"):
return False, None
if ctype == "ensure_issue":
row = conn.execute("SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days') "
"ORDER BY created DESC LIMIT 1", (p["summary"],)).fetchone()
sql = "SELECT key FROM issue WHERE summary = ? AND substr(created, 1, 10) >= date('now', '-7 days')"
args = [p["summary"]]
if reporter is not None:
sql += " AND reporter = ?"
args.append(reporter)
row = conn.execute(sql + " ORDER BY created DESC LIMIT 1", args).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:
@ -132,8 +137,14 @@ def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]:
final = target[-1] if isinstance(target, list) else target
return issue["status"] == final, None
if ctype == "ensure_link":
# связь есть И конфликтов нет: другой тип между теми же задачами или обратное направление любого типа
jira_type = link_types.get(p["link_type"], p["link_type"])
return any(l["target_key"] == p["target"] and l["link_type"] == jira_type for l in issue["links_out"]), None
all_types = set(link_types.values())
present = any(l["target_key"] == p["target"] and l["link_type"] == jira_type for l in issue["links_out"])
other_type = any(l["target_key"] == p["target"] and l["link_type"] in all_types - {jira_type}
for l in issue["links_out"])
reverse = any(l["source_key"] == p["target"] and l["link_type"] in all_types for l in issue["links_in"])
return present and not other_type and not reverse, None
if ctype == "ensure_sprint":
want = None if p["sprint"] == "backlog" else p["sprint"]
return (issue.get("sprint") or None) == want, None

View File

@ -3,6 +3,8 @@ import pytest
from jira_hub import commands, db, mirror
from jira_hub.commands import CommandError
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"
def test_validate_ok_and_tail_keys():
cmds = commands.validate([
@ -48,10 +50,12 @@ def test_substitute_and_estimate():
def _mirror():
conn = db.connect(":memory:")
mirror.apply_state(conn, [{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий",
"duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2",
"created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov"}],
[{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
mirror.apply_state(conn, [
{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий",
"duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2",
"created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"},
{"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"},
], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
return conn
@ -79,10 +83,61 @@ def test_check_confirmed(cmd, expected):
assert ok is expected
def test_check_confirmed_issue_by_summary(monkeypatch):
def test_ensure_link_reverse_direction_with_both_keys_in_mirror():
# A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт
conn = _mirror()
conn.execute("UPDATE issue SET created = strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now') WHERE key = 'A-1'")
ok, result = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Тема"}}, LT)
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT)
assert ok is False
def test_ensure_link_conflict_other_type_blocks_confirmation():
conn = _mirror()
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False
def test_ensure_link_conflict_reverse_blocks_confirmation():
conn = _mirror()
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False
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)
assert ok and result == {"key": "A-1"}
ok, _ = commands.check_confirmed(conn, {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Другая"}}, LT)
ok, _ = commands.check_confirmed(conn, _ensure_issue("Другая"), LT)
assert not ok
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)
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"}