Модуль jira: заметки, API, MCP и импорт без статуса по факту (#104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6c352dd851
commit
5b01b91710
@ -16,7 +16,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить
|
MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить
|
||||||
DOWNLOADS_EVERY = 24 * 3600 # уборка downloads/ не чаще раза в сутки
|
DOWNLOADS_EVERY = 24 * 3600 # уборка downloads/ не чаще раза в сутки
|
||||||
ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed", "project")
|
ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q", "hide_closed", "project")
|
||||||
_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
|
_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,7 @@ def _import(conn, folder: Path) -> dict:
|
|||||||
if not key:
|
if not key:
|
||||||
continue
|
continue
|
||||||
_note(conn, key, {
|
_note(conn, key, {
|
||||||
"fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")),
|
"work_kind": _clean(r.get("Вид работы")),
|
||||||
"done_text": _clean(r.get("Что сделано по факту (внутр.)")), "rest_text": _clean(r.get("Что осталось (внутр.)")),
|
"done_text": _clean(r.get("Что сделано по факту (внутр.)")), "rest_text": _clean(r.get("Что осталось (внутр.)")),
|
||||||
"owner_note": _clean(r.get("Комментарий актуализации"))})
|
"owner_note": _clean(r.get("Комментарий актуализации"))})
|
||||||
notes += 1
|
notes += 1
|
||||||
@ -56,7 +56,7 @@ def _import(conn, folder: Path) -> dict:
|
|||||||
new_keys[f"new:{i}"] = real
|
new_keys[f"new:{i}"] = real
|
||||||
csv_keys.append(real)
|
csv_keys.append(real)
|
||||||
_note(conn, key, {
|
_note(conn, key, {
|
||||||
"fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")),
|
"work_kind": _clean(r.get("Вид работы")),
|
||||||
"rest_text": _clean(r.get("Основание (внутр.)")),
|
"rest_text": _clean(r.get("Основание (внутр.)")),
|
||||||
"owner_note": None if real else _clean(r.get("Тема"))})
|
"owner_note": None if real else _clean(r.get("Тема"))})
|
||||||
notes += 1
|
notes += 1
|
||||||
|
|||||||
@ -26,10 +26,10 @@ fmt_agent = core.fmt_agent
|
|||||||
def fmt_issues(issues) -> str:
|
def fmt_issues(issues) -> str:
|
||||||
if not issues:
|
if not issues:
|
||||||
return "задач нет"
|
return "задач нет"
|
||||||
lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"]
|
lines = ["ключ | тема | статус | срок | исполнитель | спринт"]
|
||||||
for i in issues:
|
for i in issues:
|
||||||
lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")),
|
lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")),
|
||||||
core.value(i.get("assignee")), core.value(i.get("sprint")), core.value(i.get("fact_status"))]))
|
core.value(i.get("assignee")), core.value(i.get("sprint"))]))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@ -73,7 +73,7 @@ def fmt_issue(d) -> str:
|
|||||||
links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \
|
links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \
|
||||||
[f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])]
|
[f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])]
|
||||||
note = d.get("note") or {}
|
note = d.get("note") or {}
|
||||||
notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)]
|
notes = [f" {k}: {note[k]}" for k in ("work_kind", "done_text", "rest_text", "owner_note") if note.get(k)]
|
||||||
cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])]
|
cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])]
|
||||||
comments = d.get("comments") or []
|
comments = d.get("comments") or []
|
||||||
if comments:
|
if comments:
|
||||||
@ -180,11 +180,11 @@ def fmt_batch(b) -> str:
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@core.guard
|
@core.guard
|
||||||
@core.scoped(True)
|
@core.scoped(True)
|
||||||
def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "",
|
def issues(sprint: str = "", assignee: str = "", work_kind: str = "", status: str = "",
|
||||||
label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str:
|
label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str:
|
||||||
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,
|
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус Jira, метка, эпик,
|
||||||
подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица."""
|
подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица."""
|
||||||
data = core.client().get("/issues", project=core.PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status,
|
data = core.client().get("/issues", project=core.PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind,
|
||||||
status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit)
|
status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit)
|
||||||
return fmt_issues(data["issues"])
|
return fmt_issues(data["issues"])
|
||||||
|
|
||||||
@ -297,17 +297,17 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds:
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@core.guard
|
@core.guard
|
||||||
@core.scoped(True)
|
@core.scoped(True)
|
||||||
def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None,
|
def notes_set(key: str, work_kind: str | None = None, done_text: str | None = None,
|
||||||
rest_text: str | None = None, owner_note: str | None = None) -> str:
|
rest_text: str | None = None, owner_note: str | None = None) -> str:
|
||||||
"""Правка служебных заметок задачи (только переданные поля)."""
|
"""Правка служебных заметок задачи (только переданные поля)."""
|
||||||
fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text,
|
fields = {k: v for k, v in dict(work_kind=work_kind, done_text=done_text,
|
||||||
rest_text=rest_text, owner_note=owner_note).items() if v is not None}
|
rest_text=rest_text, owner_note=owner_note).items() if v is not None}
|
||||||
fields["updated_by"] = core.AUTHOR
|
fields["updated_by"] = core.AUTHOR
|
||||||
if not is_temp(key): # новая задача пакета проекта — своя по построению
|
if not is_temp(key): # новая задача пакета проекта — своя по построению
|
||||||
if core.PROJECT not in (core.client().get(f"/issues/{key}").get("labels") or []):
|
if core.PROJECT not in (core.client().get(f"/issues/{key}").get("labels") or []):
|
||||||
return f"{key} не в проекте {core.PROJECT}"
|
return f"{key} не в проекте {core.PROJECT}"
|
||||||
note = core.client().put(f"/issues/{key}/note", fields)
|
note = core.client().put(f"/issues/{key}/note", fields)
|
||||||
return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k))
|
return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("work_kind", "done_text", "rest_text", "owner_note") if note.get(k))
|
||||||
|
|
||||||
|
|
||||||
def _read_local(path) -> bytes | str:
|
def _read_local(path) -> bytes | str:
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from ...core import db
|
|||||||
|
|
||||||
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
|
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
|
||||||
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
|
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
|
||||||
NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"]
|
NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"]
|
||||||
|
|
||||||
def check_comments(issues) -> None:
|
def check_comments(issues) -> None:
|
||||||
"""Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем."""
|
"""Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем."""
|
||||||
@ -54,7 +54,7 @@ def check_attachments(issues) -> None:
|
|||||||
|
|
||||||
|
|
||||||
_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?",
|
_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?",
|
||||||
"work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"}
|
"work_kind": "n.work_kind = ?"}
|
||||||
|
|
||||||
|
|
||||||
def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict:
|
def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict:
|
||||||
@ -155,11 +155,11 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l
|
|||||||
where.append("(i.summary LIKE ? OR i.key LIKE ?)")
|
where.append("(i.summary LIKE ? OR i.key LIKE ?)")
|
||||||
args += [f"%{filters['q']}%", f"%{filters['q']}%"]
|
args += [f"%{filters['q']}%", f"%{filters['q']}%"]
|
||||||
if project:
|
if project:
|
||||||
sql = (SCOPE_CTE + "SELECT i.*, s.nb AS scope_nb, n.fact_status, n.work_kind FROM issue i "
|
sql = (SCOPE_CTE + "SELECT i.*, s.nb AS scope_nb, n.work_kind FROM issue i "
|
||||||
"JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key")
|
"JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key")
|
||||||
args = [project] + args
|
args = [project] + args
|
||||||
else:
|
else:
|
||||||
sql = "SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key"
|
sql = "SELECT i.*, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key"
|
||||||
sql = (sql
|
sql = (sql
|
||||||
+ (" WHERE " + " AND ".join(where) if where else "")
|
+ (" WHERE " + " AND ".join(where) if where else "")
|
||||||
+ " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?")
|
+ " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?")
|
||||||
@ -223,7 +223,7 @@ BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee"
|
|||||||
|
|
||||||
|
|
||||||
def board(conn, base_jql, project: str | None = None) -> dict:
|
def board(conn, base_jql, project: str | None = None) -> dict:
|
||||||
"""Снимок зеркала для доски связей (GET /board): все задачи с заметками fact_status/work_kind, все связи и последний
|
"""Снимок зеркала для доски связей (GET /board): все задачи с work_kind из заметок, все связи и последний
|
||||||
успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски
|
успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски
|
||||||
делает интерфейс. С project — свои задачи проекта и соседи (neighbor относительно проекта), связи — те, у которых
|
делает интерфейс. С project — свои задачи проекта и соседи (neighbor относительно проекта), связи — те, у которых
|
||||||
source или target — своя задача; last_sync — как без проекта. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы."""
|
source или target — своя задача; last_sync — как без проекта. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы."""
|
||||||
@ -234,7 +234,7 @@ def board(conn, base_jql, project: str | None = None) -> dict:
|
|||||||
if project:
|
if project:
|
||||||
cols = ", ".join("i." + c for c in BOARD_FIELDS if c != "neighbor")
|
cols = ", ".join("i." + c for c in BOARD_FIELDS if c != "neighbor")
|
||||||
issues = [_issue_dict(r) for r in conn.execute(
|
issues = [_issue_dict(r) for r in conn.execute(
|
||||||
SCOPE_CTE + f"SELECT {cols}, s.nb AS neighbor, n.fact_status, n.work_kind FROM issue i "
|
SCOPE_CTE + f"SELECT {cols}, s.nb AS neighbor, n.work_kind FROM issue i "
|
||||||
"JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key", (project,))]
|
"JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key", (project,))]
|
||||||
links = [dict(r) for r in conn.execute(
|
links = [dict(r) for r in conn.execute(
|
||||||
SCOPE_CTE + "SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
|
SCOPE_CTE + "SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
|
||||||
@ -242,7 +242,7 @@ def board(conn, base_jql, project: str | None = None) -> dict:
|
|||||||
"ORDER BY source_key, target_key, link_type", (project,))]
|
"ORDER BY source_key, target_key, link_type", (project,))]
|
||||||
else:
|
else:
|
||||||
issues = [_issue_dict(r) for r in conn.execute(
|
issues = [_issue_dict(r) for r in conn.execute(
|
||||||
f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind "
|
f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.work_kind "
|
||||||
"FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")]
|
"FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")]
|
||||||
links = [dict(r) for r in conn.execute(
|
links = [dict(r) for r in conn.execute(
|
||||||
"SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
|
"SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
|
||||||
|
|||||||
19
tests/fixtures/baseline/mcp_tools.expected.json
vendored
19
tests/fixtures/baseline/mcp_tools.expected.json
vendored
@ -1,6 +1,6 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"description": "Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,\nподстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.",
|
"description": "Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус Jira, метка, эпик,\nподстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"assignee": {
|
"assignee": {
|
||||||
@ -13,11 +13,6 @@
|
|||||||
"title": "Epic",
|
"title": "Epic",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"fact_status": {
|
|
||||||
"default": "",
|
|
||||||
"title": "Fact Status",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"label": {
|
"label": {
|
||||||
"default": "",
|
"default": "",
|
||||||
"title": "Label",
|
"title": "Label",
|
||||||
@ -183,18 +178,6 @@
|
|||||||
"default": null,
|
"default": null,
|
||||||
"title": "Done Text"
|
"title": "Done Text"
|
||||||
},
|
},
|
||||||
"fact_status": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"default": null,
|
|
||||||
"title": "Fact Status"
|
|
||||||
},
|
|
||||||
"key": {
|
"key": {
|
||||||
"title": "Key",
|
"title": "Key",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
|||||||
@ -112,8 +112,14 @@ def test_interface_routes(hub, add_project):
|
|||||||
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "sprint": "Спринт 2", "labels": []},
|
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "sprint": "Спринт 2", "labels": []},
|
||||||
{"key": "A-2", "summary": "Д", "sprint": "Спринт 1", "labels": []}], "links": []})
|
{"key": "A-2", "summary": "Д", "sprint": "Спринт 1", "labels": []}], "links": []})
|
||||||
assert [i["key"] for i in c.get("/issues", sprint="Спринт 2")["issues"]] == ["A-1"]
|
assert [i["key"] for i in c.get("/issues", sprint="Спринт 2")["issues"]] == ["A-1"]
|
||||||
note = c.put("/issues/A-1/note", {"fact_status": "В работе", "updated_by": "owner"})
|
assert [i["key"] for i in c.get("/issues", fact_status="В работе")["issues"]] == ["A-1", "A-2"] # параметр игнорируется
|
||||||
assert note["fact_status"] == "В работе" and note["updated_by"] == "owner"
|
assert all("fact_status" not in i for i in c.get("/issues")["issues"])
|
||||||
|
with pytest.raises(HubError) as e: # статуса по факту нет: поле — неизвестное (fact-status-removal)
|
||||||
|
c.put("/issues/A-1/note", {"fact_status": "В работе", "work_kind": "Аналитика", "updated_by": "owner"})
|
||||||
|
assert e.value.status == 422 and "неизвестные поля заметки: fact_status" in e.value.detail
|
||||||
|
assert c.get("/issues/A-1")["note"] is None # и work_kind не записан
|
||||||
|
note = c.put("/issues/A-1/note", {"work_kind": "Аналитика", "updated_by": "owner"})
|
||||||
|
assert note["work_kind"] == "Аналитика" and note["updated_by"] == "owner" and note.get("fact_status") is None
|
||||||
with pytest.raises(HubError) as e:
|
with pytest.raises(HubError) as e:
|
||||||
c.put("/issues/A-1/note", {"summary": "x"})
|
c.put("/issues/A-1/note", {"summary": "x"})
|
||||||
assert e.value.status == 422
|
assert e.value.status == 422
|
||||||
|
|||||||
@ -27,7 +27,7 @@ def live(tmp_path, add_project):
|
|||||||
|
|
||||||
def test_create_link_sprint_flow(live):
|
def test_create_link_sprint_flow(live):
|
||||||
agent, client = live
|
agent, client = live
|
||||||
client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"})
|
client.put("/issues/new:60/note", {"rest_text": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"})
|
||||||
b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [
|
b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23",
|
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23",
|
||||||
"labels": ["AWPCA"]}},
|
"labels": ["AWPCA"]}},
|
||||||
|
|||||||
@ -48,7 +48,7 @@ def test_import_notes_and_report(conn, tmp_path):
|
|||||||
rep = importer.import_package(conn, tmp_path)
|
rep = importer.import_package(conn, tmp_path)
|
||||||
assert rep["notes"] == 6
|
assert rep["notes"] == 6
|
||||||
n = mirror.get_note(conn, "AURORA-1533")
|
n = mirror.get_note(conn, "AURORA-1533")
|
||||||
assert n["fact_status"] == "Ожидание разработки" and n["work_kind"] == "тестирование"
|
assert n["work_kind"] == "тестирование" and n.get("fact_status") is None # колонка CSV пропускается (fact-status-removal)
|
||||||
assert n["rest_text"] == "Прогнать на стенде" and n["owner_note"] == "Актуализация 10.10.2026. Порядок" and n["updated_by"] == "import"
|
assert n["rest_text"] == "Прогнать на стенде" and n["owner_note"] == "Актуализация 10.10.2026. Порядок" and n["updated_by"] == "import"
|
||||||
assert mirror.get_note(conn, "AURORA-1416")["done_text"] == "Сделан список"
|
assert mirror.get_note(conn, "AURORA-1416")["done_text"] == "Сделан список"
|
||||||
assert mirror.get_note(conn, "AURORA-1586")["rest_text"] == "решение 11.10"
|
assert mirror.get_note(conn, "AURORA-1586")["rest_text"] == "решение 11.10"
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import inspect
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@ -47,9 +48,9 @@ def test_tools_roundtrip(live):
|
|||||||
"assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []})
|
"assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []})
|
||||||
out = jmcp.issues(sprint="Спринт 2")
|
out = jmcp.issues(sprint="Спринт 2")
|
||||||
assert "A-1" in out and "Тема раз" in out and "ivanov" in out
|
assert "A-1" in out and "Тема раз" in out and "ivanov" in out
|
||||||
assert "В работе" in jmcp.notes_set("A-1", fact_status="В работе")
|
assert "Аналитика" in jmcp.notes_set("A-1", work_kind="Аналитика")
|
||||||
detail = jmcp.issue("A-1")
|
detail = jmcp.issue("A-1")
|
||||||
assert "fact_status: В работе" in detail and "Готово" in detail
|
assert "work_kind: Аналитика" in detail and "Готово" in detail
|
||||||
created = jmcp.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}])
|
created = jmcp.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}])
|
||||||
batch_id = int(created.split("#")[1].split()[0])
|
batch_id = int(created.split("#")[1].split()[0])
|
||||||
assert "2 команд" in created and "мин" in created
|
assert "2 команд" in created and "мин" in created
|
||||||
@ -73,11 +74,19 @@ def test_tools_roundtrip(live):
|
|||||||
assert jmcp.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
|
assert jmcp.settings_set("tokens", '"x"').startswith("ошибка хаба 422")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_fact_status_params():
|
||||||
|
for tool in (jmcp.issues, jmcp.notes_set):
|
||||||
|
assert "fact_status" not in inspect.signature(tool).parameters
|
||||||
|
|
||||||
|
|
||||||
def test_formatters():
|
def test_formatters():
|
||||||
assert jmcp.fmt_issues([]) == "задач нет"
|
assert jmcp.fmt_issues([]) == "задач нет"
|
||||||
text = jmcp.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None,
|
text = jmcp.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None,
|
||||||
"sprint": None, "fact_status": None}])
|
"sprint": None, "fact_status": "В работе"}])
|
||||||
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
|
assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text
|
||||||
|
head, row = text.splitlines()
|
||||||
|
assert head == "ключ | тема | статус | срок | исполнитель | спринт" # статуса по факту нет (fact-status-removal)
|
||||||
|
assert len(row.split(" | ")) == 6 and "В работе" not in row
|
||||||
assert "очередь" in jmcp.fmt_agent({"online": False, "agent_version": None, "current_command_id": None,
|
assert "очередь" in jmcp.fmt_agent({"online": False, "agent_version": None, "current_command_id": None,
|
||||||
"rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None,
|
"rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None,
|
||||||
"settings_version": None})
|
"settings_version": None})
|
||||||
|
|||||||
@ -60,14 +60,16 @@ def test_partial_load_never_lowers_neighbor(conn):
|
|||||||
|
|
||||||
|
|
||||||
def test_note_survives_refresh_and_renames(conn):
|
def test_note_survives_refresh_and_renames(conn):
|
||||||
mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import")
|
mirror.set_note(conn, "new:7", {"rest_text": "В работе", "work_kind": "разработка"}, "import")
|
||||||
mirror.rename_note(conn, "new:7", "A-5")
|
mirror.rename_note(conn, "new:7", "A-5")
|
||||||
mirror.apply_state(conn, [issue("A-5")], [])
|
mirror.apply_state(conn, [issue("A-5")], [])
|
||||||
got = mirror.get_issue(conn, "A-5")
|
got = mirror.get_issue(conn, "A-5")
|
||||||
assert got["note"]["fact_status"] == "В работе" and got["note"]["updated_by"] == "import"
|
assert got["note"]["rest_text"] == "В работе" and got["note"]["updated_by"] == "import"
|
||||||
assert mirror.get_note(conn, "new:7") is None
|
assert mirror.get_note(conn, "new:7") is None
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
|
mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x")
|
||||||
|
with pytest.raises(ValueError, match="неизвестные поля заметки: fact_status"): # fact-status-removal
|
||||||
|
mirror.set_note(conn, "A-5", {"fact_status": "В работе"}, "x")
|
||||||
|
|
||||||
|
|
||||||
def test_list_issues_filters(conn):
|
def test_list_issues_filters(conn):
|
||||||
@ -86,9 +88,9 @@ def test_list_issues_filters(conn):
|
|||||||
|
|
||||||
def test_rename_note_same_key_keeps_note(conn):
|
def test_rename_note_same_key_keeps_note(conn):
|
||||||
mirror.apply_state(conn, [issue("A-1")], [])
|
mirror.apply_state(conn, [issue("A-1")], [])
|
||||||
mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x")
|
mirror.set_note(conn, "A-1", {"rest_text": "В работе"}, "x")
|
||||||
mirror.rename_note(conn, "A-1", "A-1")
|
mirror.rename_note(conn, "A-1", "A-1")
|
||||||
assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе"
|
assert mirror.get_note(conn, "A-1")["rest_text"] == "В работе"
|
||||||
|
|
||||||
|
|
||||||
def test_rename_note_missing_is_noop(conn):
|
def test_rename_note_missing_is_noop(conn):
|
||||||
@ -99,32 +101,32 @@ def test_rename_note_missing_is_noop(conn):
|
|||||||
|
|
||||||
def test_set_note_partial_keeps_other_fields(conn):
|
def test_set_note_partial_keeps_other_fields(conn):
|
||||||
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
|
mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x")
|
||||||
got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y")
|
got = mirror.set_note(conn, "A-1", {"rest_text": "Готово"}, "y")
|
||||||
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
|
assert got["work_kind"] == "разработка" and got["done_text"] == "сделано"
|
||||||
assert got["fact_status"] == "Готово" and got["updated_by"] == "y"
|
assert got["rest_text"] == "Готово" and got["updated_by"] == "y"
|
||||||
|
|
||||||
|
|
||||||
def test_rename_note_merges_with_existing(conn):
|
def test_rename_note_merges_with_existing(conn):
|
||||||
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
|
mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human")
|
||||||
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import")
|
mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "rest_text": "В работе"}, "import")
|
||||||
mirror.rename_note(conn, "new:5", "A-5")
|
mirror.rename_note(conn, "new:5", "A-5")
|
||||||
note = mirror.get_note(conn, "A-5")
|
note = mirror.get_note(conn, "A-5")
|
||||||
assert note["work_kind"] == "Аналитика" # непустое поле временной заметки побеждает
|
assert note["work_kind"] == "Аналитика" # непустое поле временной заметки побеждает
|
||||||
assert note["owner_note"] == "спросить ИБ" # пустое у временной — остаётся под ключом
|
assert note["owner_note"] == "спросить ИБ" # пустое у временной — остаётся под ключом
|
||||||
assert note["fact_status"] == "В работе"
|
assert note["rest_text"] == "В работе"
|
||||||
assert note["updated_by"] == "import" # временная заметка записана позже
|
assert note["updated_by"] == "import" # временная заметка записана позже
|
||||||
assert mirror.get_note(conn, "new:5") is None
|
assert mirror.get_note(conn, "new:5") is None
|
||||||
|
|
||||||
|
|
||||||
def test_rename_note_keeps_newer_real_key_stamp(conn):
|
def test_rename_note_keeps_newer_real_key_stamp(conn):
|
||||||
mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import")
|
mirror.set_note(conn, "new:6", {"rest_text": "В работе"}, "import")
|
||||||
mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human")
|
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 = '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'")
|
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")
|
mirror.rename_note(conn, "new:6", "A-6")
|
||||||
note = mirror.get_note(conn, "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["updated_by"] == "human" and note["updated_at"] == "2030-01-01T00:00:00+00:00"
|
||||||
assert note["fact_status"] == "В работе" and note["owner_note"] == "позже"
|
assert note["rest_text"] == "В работе" and note["owner_note"] == "позже"
|
||||||
|
|
||||||
|
|
||||||
def test_rename_note_empty_string_does_not_overwrite(conn):
|
def test_rename_note_empty_string_does_not_overwrite(conn):
|
||||||
@ -160,9 +162,9 @@ def test_board_snapshot_raw_mirror_data(conn):
|
|||||||
assert [i["key"] for i in snap["issues"]] == ["AURORA-1351", "AURORA-1620", "AURORA-1621"]
|
assert [i["key"] for i in snap["issues"]] == ["AURORA-1351", "AURORA-1620", "AURORA-1621"]
|
||||||
a = snap["issues"][1]
|
a = snap["issues"][1]
|
||||||
assert set(a) == {"key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate",
|
assert set(a) == {"key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate",
|
||||||
"planned_start", "planned_end", "epic_key", "labels", "neighbor", "fact_status", "work_kind"}
|
"planned_start", "planned_end", "epic_key", "labels", "neighbor", "work_kind"}
|
||||||
assert a["labels"] == ["kind-dev"] and a["epic_key"] == "AURORA-1351"
|
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 a["work_kind"] == "разработка" and a["neighbor"] is False
|
||||||
assert snap["issues"][2]["neighbor"] is True and snap["issues"][2]["work_kind"] is None
|
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["links"] == [{"source": "AURORA-1620", "target": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}]
|
||||||
assert snap["last_sync"] is None and snap["generated_at"]
|
assert snap["last_sync"] is None and snap["generated_at"]
|
||||||
|
|||||||
@ -101,7 +101,7 @@ def test_backup_failure_does_not_block_claim(conn, caplog):
|
|||||||
|
|
||||||
def test_temp_key_substitution_and_skip(conn, add_project):
|
def test_temp_key_substitution_and_skip(conn, add_project):
|
||||||
add_project(conn)
|
add_project(conn)
|
||||||
mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import")
|
mirror.set_note(conn, "new:60", {"rest_text": "В работе"}, "import")
|
||||||
queue.create_batch(conn, "создать", "claude", [
|
queue.create_batch(conn, "создать", "claude", [
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
|
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
|
||||||
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
|
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
|
||||||
@ -111,7 +111,7 @@ def test_temp_key_substitution_and_skip(conn, add_project):
|
|||||||
])
|
])
|
||||||
c1 = queue.claim(conn, "jira")
|
c1 = queue.claim(conn, "jira")
|
||||||
queue.report_result(conn, c1["id"], "done", result={"key": "A-90"})
|
queue.report_result(conn, c1["id"], "done", result={"key": "A-90"})
|
||||||
assert mirror.get_note(conn, "A-90")["fact_status"] == "В работе" and mirror.get_note(conn, "new:60") is None
|
assert mirror.get_note(conn, "A-90")["rest_text"] == "В работе" and mirror.get_note(conn, "new:60") is None
|
||||||
c2 = queue.claim(conn, "jira")
|
c2 = queue.claim(conn, "jira")
|
||||||
assert c2["type"] == "ensure_link" and c2["params"]["source"] == "A-90"
|
assert c2["type"] == "ensure_link" and c2["params"]["source"] == "A-90"
|
||||||
queue.report_result(conn, c2["id"], "done")
|
queue.report_result(conn, c2["id"], "done")
|
||||||
@ -362,7 +362,7 @@ def test_report_for_queued_command_rejected(conn):
|
|||||||
|
|
||||||
|
|
||||||
def test_late_report_after_force_cancel_keeps_issue_key(conn):
|
def test_late_report_after_force_cancel_keeps_issue_key(conn):
|
||||||
mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import")
|
mirror.set_note(conn, "new:7", {"rest_text": "В работе"}, "import")
|
||||||
queue.create_batch(conn, "x", "claude", [
|
queue.create_batch(conn, "x", "claude", [
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
{"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}])
|
||||||
c = queue.claim(conn, "jira")
|
c = queue.claim(conn, "jira")
|
||||||
@ -370,7 +370,7 @@ def test_late_report_after_force_cancel_keeps_issue_key(conn):
|
|||||||
late = queue.report_result(conn, c["id"], "done", result={"key": "A-20", "created": True}, jira_calls=3)
|
late = queue.report_result(conn, c["id"], "done", result={"key": "A-20", "created": True}, jira_calls=3)
|
||||||
assert late["status"] == "cancelled" and late["result"] == {"key": "A-20", "created": True} and late["jira_calls"] == 3
|
assert late["status"] == "cancelled" and late["result"] == {"key": "A-20", "created": True} and late["jira_calls"] == 3
|
||||||
assert late["finished_at"] == cancelled["finished_at"]
|
assert late["finished_at"] == cancelled["finished_at"]
|
||||||
assert mirror.get_note(conn, "A-20")["fact_status"] == "В работе" and mirror.get_note(conn, "new:7") is None
|
assert mirror.get_note(conn, "A-20")["rest_text"] == "В работе" and mirror.get_note(conn, "new:7") is None
|
||||||
again = queue.report_result(conn, c["id"], "done", result={"key": "A-21"})
|
again = queue.report_result(conn, c["id"], "done", result={"key": "A-21"})
|
||||||
assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет
|
assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет
|
||||||
|
|
||||||
@ -399,7 +399,7 @@ def test_error_is_trimmed_to_1500(conn):
|
|||||||
|
|
||||||
|
|
||||||
def test_late_done_after_delivery_limit_keeps_issue_key(conn):
|
def test_late_done_after_delivery_limit_keeps_issue_key(conn):
|
||||||
mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import")
|
mirror.set_note(conn, "new:8", {"rest_text": "План"}, "import")
|
||||||
queue.create_batch(conn, "x", "claude", [
|
queue.create_batch(conn, "x", "claude", [
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
|
{"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}])
|
||||||
c = queue.claim(conn, "jira")
|
c = queue.claim(conn, "jira")
|
||||||
@ -407,7 +407,7 @@ def test_late_done_after_delivery_limit_keeps_issue_key(conn):
|
|||||||
late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2)
|
late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2)
|
||||||
assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True}
|
assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True}
|
||||||
assert late["error"] == "агент не отчитался после 3 выдач"
|
assert late["error"] == "агент не отчитался после 3 выдач"
|
||||||
assert mirror.get_note(conn, "A-30")["fact_status"] == "План" and mirror.get_note(conn, "new:8") is None
|
assert mirror.get_note(conn, "A-30")["rest_text"] == "План" and mirror.get_note(conn, "new:8") is None
|
||||||
again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"})
|
again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"})
|
||||||
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
|
assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user