sdlc-hub/tests/test_mirror.py
Andrey Epifancev a88bb1e6e8 refactor(sdlc-rename): пакет sdlc_hub, CLI sdlc-hub, каталог данных sdlc-hub
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 11:27:17 +04:00

407 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import pytest
from sdlc_hub.core import db
from sdlc_hub.modules.jira import mirror
@pytest.fixture
def conn(open_db):
"""Пустое зеркало в памяти; закрывается после теста."""
return open_db()
def issue(key, **over):
base = {"key": key, "id": "1", "issue_type": "Задача", "summary": f"Тема {key}", "description": "h3. Контекст",
"status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "me",
"duedate": "2026-10-23", "planned_start": None, "planned_end": "2026-10-23", "epic_key": "AURORA-1350",
"labels": ["AWPCA"], "sprint": "Спринт 2", "created": "2026-10-10T12:00:00.000+0300",
"jira_updated": "2026-10-11T12:00:00.000+0300"}
base.update(over)
return base
def test_apply_state_upserts_and_replaces_links(conn):
links = [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Блокер_Начала"}]
out = mirror.apply_state(conn, [issue("A-1"), issue("A-2")], links)
assert out == {"issues": 2, "links": 1}
mirror.apply_state(conn, [issue("A-1", summary="Новая тема")],
[{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
got = mirror.get_issue(conn, "A-1")
assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"]
assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные", "summary": "Тема A-2",
"status": "Ожидание разработки"}]
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные",
"summary": "Новая тема", "status": "Ожидание разработки"}]
def test_links_from_other_source_survive(conn):
mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные",
"summary": "Тема A-2", "status": "Ожидание разработки"}]
def test_apply_state_marks_neighbors(conn):
mirror.apply_state(conn, [issue("A-1"), issue("B-9")], [], neighbor_keys={"B-9"})
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
assert mirror.get_issue(conn, "A-1")["neighbor"] is False
mirror.apply_state(conn, [issue("B-9")], []) # вошла в базовую JQL
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
assert mirror.known_keys(conn) == ["A-1", "B-9"]
def test_partial_load_never_lowers_neighbor(conn):
mirror.apply_state(conn, [issue("B-9")], [], neighbor_keys={"B-9"})
mirror.apply_state(conn, [issue("B-9", summary="новая")], [], authoritative=False) # refresh {keys}
assert mirror.get_issue(conn, "B-9")["neighbor"] is True
mirror.apply_state(conn, [issue("B-9")], [], authoritative=True) # полный прогон: вошла в базовую
assert mirror.get_issue(conn, "B-9")["neighbor"] is False
def test_note_survives_refresh_and_renames(conn):
mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import")
mirror.rename_note(conn, "new:7", "A-5")
mirror.apply_state(conn, [issue("A-5")], [])
got = mirror.get_issue(conn, "A-5")
assert got["note"]["fact_status"] == "В работе" and got["note"]["updated_by"] == "import"
assert mirror.get_note(conn, "new:7") is None
with pytest.raises(ValueError):
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
def test_list_issues_filters(conn):
mirror.apply_state(conn, [issue("A-1", sprint="Спринт 2", assignee="ivanov", duedate="2026-10-20"),
issue("A-2", sprint="Спринт 1", labels=["AWPCA", "fact-done"]),
issue("A-3", sprint="Спринт 2", duedate=None)], [])
mirror.set_note(conn, "A-3", {"work_kind": "тестирование"}, "x")
keys = [i["key"] for i in mirror.list_issues(conn, {"sprint": "Спринт 2"})]
assert keys == ["A-1", "A-3"] # без срока — последней
assert [i["key"] for i in mirror.list_issues(conn, {"label": "fact-done"})] == ["A-2"]
assert [i["key"] for i in mirror.list_issues(conn, {"work_kind": "тестирование"})] == ["A-3"]
assert [i["key"] for i in mirror.list_issues(conn, {"q": "A-2"})] == ["A-2"]
assert mirror.list_issues(conn, {"assignee": "ivanov"})[0]["work_kind"] is None
assert len(mirror.list_issues(conn, None, limit=1)) == 1
def test_rename_note_same_key_keeps_note(conn):
mirror.apply_state(conn, [issue("A-1")], [])
mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x")
mirror.rename_note(conn, "A-1", "A-1")
assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе"
def test_rename_note_missing_is_noop(conn):
mirror.rename_note(conn, "new:404", "A-9") # заметки нет — не падает
assert mirror.get_note(conn, "new:404") is None
assert mirror.get_note(conn, "A-9") is None
def test_set_note_partial_keeps_other_fields(conn):
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
assert got["fact_status"] == "Готово" and got["updated_by"] == "y"
def test_rename_note_merges_with_existing(conn):
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import")
mirror.rename_note(conn, "new:5", "A-5")
note = mirror.get_note(conn, "A-5")
assert note["work_kind"] == "Аналитика" # непустое поле временной заметки побеждает
assert note["owner_note"] == "спросить ИБ" # пустое у временной — остаётся под ключом
assert note["fact_status"] == "В работе"
assert note["updated_by"] == "import" # временная заметка записана позже
assert mirror.get_note(conn, "new:5") is None
def test_rename_note_keeps_newer_real_key_stamp(conn):
mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import")
mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human")
conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'")
conn.execute("UPDATE issue_note SET updated_at = '2030-01-01T00:00:00+00:00' WHERE key = 'A-6'")
mirror.rename_note(conn, "new:6", "A-6")
note = mirror.get_note(conn, "A-6")
assert note["updated_by"] == "human" and note["updated_at"] == "2030-01-01T00:00:00+00:00"
assert note["fact_status"] == "В работе" and note["owner_note"] == "позже"
def test_rename_note_empty_string_does_not_overwrite(conn):
mirror.set_note(conn, "A-6", {"rest_text": "доделать отчёт"}, "human")
mirror.set_note(conn, "new:6", {"rest_text": "", "done_text": "сделано"}, "import")
mirror.rename_note(conn, "new:6", "A-6")
note = mirror.get_note(conn, "A-6")
assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано"
BASE = "project = AURORA AND labels = AWPCA"
def _sync_run(conn, started, finished, kind, query, status="done", count=5):
"""Строка журнала синхронизаций с командой refresh в нужном статусе."""
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('t', 'test', ?)", (started,))
batch_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at) VALUES (?, 1, 'refresh', '{}', ?, ?)",
(batch_id, status, finished))
command_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query, issues_count, command_id) VALUES (?, ?, ?, ?, ?, ?)",
(started, finished, kind, query, count, command_id))
def test_board_snapshot_raw_mirror_data(conn):
mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None),
issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"),
issue("AURORA-1621", epic_key="AURORA-1351")],
[{"source_key": "AURORA-1620", "target_key": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}],
neighbor_keys=["AURORA-1621"])
mirror.set_note(conn, "AURORA-1620", {"work_kind": "разработка"}, "test")
snap = mirror.board(conn, BASE)
assert [i["key"] for i in snap["issues"]] == ["AURORA-1351", "AURORA-1620", "AURORA-1621"]
a = snap["issues"][1]
assert set(a) == {"key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate",
"planned_start", "planned_end", "epic_key", "labels", "neighbor", "fact_status", "work_kind"}
assert a["labels"] == ["kind-dev"] and a["epic_key"] == "AURORA-1351"
assert a["work_kind"] == "разработка" and a["fact_status"] is None and a["neighbor"] is False
assert snap["issues"][2]["neighbor"] is True and snap["issues"][2]["work_kind"] is None
assert snap["links"] == [{"source": "AURORA-1620", "target": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}]
assert snap["last_sync"] is None and snap["generated_at"]
def test_board_last_sync_is_last_successful_full_refresh(conn):
_sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198)
_sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1)
_sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1)
_sync_run(conn, "2026-10-14T12:00:00+00:00", "2026-10-14T12:01:00+00:00", "jql", BASE, status="failed", count=0)
_sync_run(conn, "2026-10-14T13:00:00+00:00", None, "jql", BASE, status="claimed", count=50)
assert mirror.board(conn, BASE)["last_sync"] == {"started_at": "2026-10-14T09:59:00+00:00",
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}
def test_board_inside_open_transaction_keeps_it_open(conn):
"""M-7 #20: вызов внутри уже открытой транзакции не открывает и не закрывает свою."""
mirror.apply_state(conn, [issue("AURORA-1620")], [])
with db.transaction(conn):
assert conn.in_transaction
snap = mirror.board(conn, BASE)
assert conn.in_transaction
assert not conn.in_transaction
assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"]
def comment(cid, created="2026-10-14T12:00:00.000+0300", **over):
base = {"id": cid, "author": "Петров Пётр", "created": created, "updated": created, "body": f"текст {cid}"}
base.update(over)
return base
def test_comments_replaced_and_kept_without_field(conn):
mirror.apply_state(conn, [issue("A-1", comments=[comment("101"), comment("102")])], [])
mirror.apply_state(conn, [issue("A-1", comments=[comment("102", body="новый текст")])], [])
got = mirror.get_issue(conn, "A-1")["comments"]
assert [(c["comment_id"], c["body"]) for c in got] == [("102", "новый текст")]
mirror.apply_state(conn, [issue("A-1", summary="Без поля comments")], []) # агент до v10
assert [c["comment_id"] for c in mirror.get_issue(conn, "A-1")["comments"]] == ["102"]
mirror.apply_state(conn, [issue("A-1", comments=[])], [])
assert mirror.get_issue(conn, "A-1")["comments"] == []
def test_comments_newest_first_and_shape(conn):
mirror.apply_state(conn, [issue("A-1", comments=[
comment("9", created="2026-10-14T10:00:00.000+0300"),
comment("10", created="2026-10-15T10:00:00.000+0300", author=None, body=None),
comment("11", created="2026-10-15T10:00:00.000+0300")])], [])
got = mirror.get_issue(conn, "A-1")["comments"]
assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число
assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300",
"updated": "2026-10-15T10:00:00.000+0300", "body": "", "attachment_ids": []}
def test_issue_epic_summary_and_absent_link(conn):
mirror.apply_state(conn, [issue("A-1", epic_key="E-1"), issue("E-1", summary="Эпик свода", epic_key=None)],
[{"source_key": "A-1", "target_key": "Z-9", "link_type": "ASUPR_Связанные"}])
got = mirror.get_issue(conn, "A-1")
assert got["epic_summary"] == "Эпик свода"
assert got["links_out"] == [{"target_key": "Z-9", "link_type": "ASUPR_Связанные", "summary": None, "status": None}]
assert mirror.get_issue(conn, "E-1")["epic_summary"] is None
@pytest.mark.parametrize("bad, field", [
("x", "comments"), (["x"], "comments[0]"), ([{"id": "", "body": "т"}], "comments[0].id"),
([{"id": 5, "body": "т"}], "comments[0].id"), ([{"id": "5", "body": 42}], "comments[0].body"),
([{"id": "5", "body": "т", "author": 7}], "comments[0].author"),
([{"id": "5", "body": "т", "created": 1}], "comments[0].created"),
([{"id": "5", "body": "т", "updated": []}], "comments[0].updated"),
])
def test_check_comments_rejects(bad, field):
with pytest.raises(ValueError) as e:
mirror.check_comments([issue("A-1"), issue("A-2", comments=bad)])
assert str(e.value).startswith(f"A-2: {field}")
def test_check_comments_accepts_missing_and_valid():
mirror.check_comments([issue("A-1"), issue("A-2", comments=[comment("1"), comment("2", author=None, body="")])])
def test_list_issues_hide_closed(conn):
mirror.apply_state(conn, [issue("A-1", status="В работе"), issue("A-2", status="Готово"),
issue("A-3", status="Снято"), issue("A-4", status=None)], [])
closed = ["Готово", "Снято"]
def keys(f, c=closed):
return sorted(i["key"] for i in mirror.list_issues(conn, f, closed_statuses=c))
assert keys({"hide_closed": "1"}) == ["A-1", "A-4"] # без статуса — не закрыта
assert keys({"hide_closed": "true"}) == ["A-1", "A-4"]
assert keys({}) == ["A-1", "A-2", "A-3", "A-4"]
assert keys({"hide_closed": "0"}) == ["A-1", "A-2", "A-3", "A-4"]
assert keys({"hide_closed": "1"}, []) == ["A-1", "A-2", "A-3", "A-4"] # пустой набор — ничего не скрыто
assert keys({"hide_closed": "1", "status": "Готово"}) == [] # отменяет скрытие приложение, не сервер
PROJ = "STOCKS_DRRK"
def _scene(conn): # сценарий data-model «Свои и соседи» + hub-api «Снимок проекта»
mirror.apply_state(conn, [
issue("AURORA-1", labels=["STOCKS_DRRK"], epic_key="AURORA-9"),
issue("AURORA-2", labels=["AWPCA"], epic_key=None),
issue("AURORA-3", labels=["AWPCA"], epic_key=None),
issue("AURORA-4", labels=["STOCKS_DRRK", "kind-dev"], epic_key="AURORA-404"), # эпика нет в зеркале
issue("AURORA-5", labels=["AWPCA"], epic_key=None),
issue("AURORA-9", labels=["AWPCA"], issue_type="Фича", epic_key=None)],
[{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "L"},
{"source_key": "AURORA-2", "target_key": "AURORA-3", "link_type": "L"},
{"source_key": "AURORA-5", "target_key": "AURORA-4", "link_type": "L"}, # входящая — тоже сосед
{"source_key": "AURORA-4", "target_key": "AURORA-777", "link_type": "L"}], # конца нет в зеркале
neighbor_keys=["AURORA-1"]) # признак зеркала не важен в разрезе
def test_board_project_scope(conn):
_scene(conn)
snap = mirror.board(conn, BASE, project=PROJ)
assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [
("AURORA-1", False), ("AURORA-2", True), ("AURORA-4", False), ("AURORA-5", True), ("AURORA-9", True)]
assert {(l["source"], l["target"]) for l in snap["links"]} == {
("AURORA-1", "AURORA-2"), ("AURORA-5", "AURORA-4"), ("AURORA-4", "AURORA-777")}
assert len(mirror.board(conn, BASE)["issues"]) == 6 # без проекта — всё зеркало
def test_board_project_last_sync_same(conn):
_scene(conn)
assert mirror.board(conn, BASE, project=PROJ)["last_sync"] == mirror.board(conn, BASE)["last_sync"]
def test_list_issues_project(conn): # сценарий «Список проекта»
_scene(conn)
keys = lambda **f: [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, **f})]
assert sorted(keys()) == ["AURORA-1", "AURORA-2", "AURORA-4", "AURORA-5", "AURORA-9"]
assert sorted(keys(neighbor="1")) == ["AURORA-2", "AURORA-5", "AURORA-9"]
assert sorted(keys(neighbor="0")) == ["AURORA-1", "AURORA-4"]
assert keys(label="kind-dev") == ["AURORA-4"]
assert {i["key"]: i["neighbor"] for i in mirror.list_issues(conn, {"project": PROJ})}["AURORA-1"] is False
def test_list_issues_project_paging_and_filters(conn):
_scene(conn)
page = mirror.list_issues(conn, {"project": PROJ}, limit=2, offset=1)
assert [i["key"] for i in page] == ["AURORA-2", "AURORA-4"]
assert [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, "q": "AURORA-5"})] == ["AURORA-5"]
def test_labels_of(conn):
_scene(conn)
assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"]
assert mirror.labels_of(conn, "AURORA-404") is None
def test_apply_state_comment_created_at(conn):
mirror.apply_state(conn, [{"key": "X", "comments": [
{"id": "1", "body": "a", "created": "2026-10-22T13:05:11.000+0300"},
{"id": "2", "body": "b", "created": "мусор"}]}], [])
rows = {r["comment_id"]: r["created_at"] for r in conn.execute("SELECT * FROM issue_comment")}
assert rows == {"1": "2026-10-22T10:05:11+00:00", "2": None}
def attachment(aid, **over):
base = {"id": aid, "filename": f"файл{aid}.png", "size": 1000, "mime_type": "image/png", "author": "Петров Пётр",
"created": "2026-10-14T12:00:00.000+0300"}
base.update(over)
return base
def _atts(conn, key="A-1"):
return {r["attachment_id"]: dict(r) for r in conn.execute("SELECT * FROM issue_attachment WHERE key = ?", (key,))}
def test_attachments_replace(conn):
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1"), attachment("2")])], [])
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("2", filename="новое.pdf", size=5), attachment("3")])], [])
got = _atts(conn)
assert sorted(got) == ["2", "3"]
assert got["2"]["filename"] == "новое.pdf" and got["2"]["size"] == 5
assert got["2"]["created_at"] == "2026-10-14T09:00:00+00:00"
def test_attachments_absent_kept(conn):
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], [])
mirror.apply_state(conn, [issue("A-1", summary="Без поля")], [])
assert sorted(_atts(conn)) == ["1"]
def test_attachment_file_untouched(conn):
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], [])
conn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('1', 'abc', 1000, '2026-10-22T10:00:00+00:00')")
mirror.apply_state(conn, [issue("A-1", attachments=[])], [])
assert _atts(conn) == {}
assert conn.execute("SELECT file FROM attachment_file WHERE attachment_id = '1'").fetchone()["file"] == "abc"
def test_attachment_moves_to_other_key(conn):
mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], [])
mirror.apply_state(conn, [issue("A-2", attachments=[attachment("1")])], [])
assert _atts(conn, "A-1") == {} and sorted(_atts(conn, "A-2")) == ["1"]
@pytest.mark.parametrize("bad, field", [
("x", "attachments"), (["x"], "attachments[0]"),
([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": "1a", "filename": "a"}], "attachments[0].id"),
([{"id": "-5", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"),
([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"),
([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"),
([{"id": "5", "filename": "a", "size": "1"}], "attachments[0].size"),
([{"id": "5", "filename": "a", "size": True}], "attachments[0].size"),
([{"id": "5", "filename": "a", "mime_type": 5}], "attachments[0].mime_type"),
])
def test_check_attachments_errors(bad, field):
with pytest.raises(ValueError) as e:
mirror.check_attachments([issue("A-1"), issue("A-2", attachments=bad)])
assert str(e.value).startswith(f"A-2: {field}")
def test_check_attachments_accepts_missing_and_valid():
mirror.check_attachments([issue("A-1"), issue("A-2", attachments=[attachment("1"), attachment("2", size=None, mime_type=None)])])
def test_get_issue_attachments(conn):
mirror.apply_state(conn, [issue(
"A-1", description="см. !старый.png!",
attachments=[attachment("31337", filename="скрин 1.png", created="2026-10-14T12:00:00.000+0300"),
attachment("31338", filename="ОСВ 60.xlsx", created="2026-10-15T12:00:00.000+0300")],
comments=[comment("101", body="см. !скрин 1.png|thumbnail! и [^ОСВ 60.xlsx], ещё раз !скрин 1.png!"),
comment("102", body="без ссылок")])], [])
got = mirror.get_issue(conn, "A-1")
assert [a["attachment_id"] for a in got["attachments"]] == ["31338", "31337"]
assert got["attachments"][0]["state"] == "none" and got["attachments"][0]["error"] is None
assert got["description_attachment_ids"] == []
by_id = {c["comment_id"]: c for c in got["comments"]}
assert by_id["101"]["attachment_ids"] == ["31337", "31338"] and by_id["102"]["attachment_ids"] == []
def test_get_issue_commands_skip_fetch(conn):
mirror.apply_state(conn, [issue("A-1")], [])
conn.execute("INSERT INTO batch(id, title, author, created_at) VALUES (1, 't', 'a', ?)", (db.now(),))
for seq, typ in enumerate(["fetch_attachment", "ensure_fields"]):
conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (1, ?, ?, ?)",
(seq, typ, '{"key": "A-1", "attachment_id": "1"}'))
assert [c["type"] for c in mirror.get_issue(conn, "A-1")["commands"]] == ["ensure_fields"]