Разбор журнала изменений Jira и фейк журнала (#39)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 10:27:00 +04:00
parent 285cc1c484
commit de2d5b5252
3 changed files with 197 additions and 3 deletions

View File

@ -526,6 +526,27 @@ def _parse_at(value) -> float:
# ---------------------------------------------------------------------------
# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу
# ---------------------------------------------------------------------------
def _text(value: Any) -> Optional[str]:
return None if value is None else str(value)
def _jira_time(value: Any) -> Optional[datetime]:
"""Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → aware datetime; без
смещения — UTC; иначе None. В 3.9 fromisoformat не разбирает ни `Z`, ни `+0300`."""
if not isinstance(value, str) or not value:
return None
s = value[:-1] + "+0000" if value.endswith("Z") else value
if len(s) > 6 and s[-6] in "+-" and s[-3] == ":": # ±HH:MM → ±HHMM
s = s[:-3] + s[-2:]
for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
try:
d = datetime.strptime(s, fmt)
except ValueError:
continue
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
return None
def _date(value) -> Optional[str]:
"""Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню."""
return (value or "")[:10] or None
@ -983,6 +1004,36 @@ class Executor:
out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t})
return out
def changelog_items(self, key: str, histories: List[dict], since: Optional[datetime],
field_id_of: Callable[[str], Optional[str]]) -> List[dict]:
"""Записи журнала задачи → элементы для хаба (по одному на поле записи). Записи без id или времени и элементы
без поля отбрасываются с записью в лог (хаб отверг бы всю пачку); since — только записи не раньше него.
field_id: fieldId элемента, у custom без него — по имени поля (field_id_of), у прочих — само поле."""
out: List[dict] = []
for h in histories:
hid, created = h.get("id"), h.get("created")
if hid in (None, "") or not created:
log("журнал %s: запись без id или времени отброшена" % key)
continue
at = _jira_time(created)
if since is not None and (at is None or at < since):
continue
a = h.get("author") or {}
author = a.get("displayName") or a.get("name") or None
for seq, it in enumerate(h.get("items") or []):
name = it.get("field")
if not name:
log("журнал %s: элемент без поля в записи %s отброшен" % (key, hid))
continue
fid = it.get("fieldId")
if not fid:
fid = field_id_of(name) if it.get("fieldtype") == "custom" else name
out.append({"history_id": str(hid), "seq": seq, "key": key, "created": created, "author": author,
"field_id": fid, "field_name": name, "from": _text(it.get("from")),
"from_string": _text(it.get("fromString")), "to": _text(it.get("to")),
"to_string": _text(it.get("toString"))})
return out
@staticmethod
def comment_state(c: dict) -> dict:
"""Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira."""

View File

@ -87,6 +87,14 @@ class FakeJira:
self.bad_attachment_response = False # ответить на загрузку пустым списком
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}]
self.history_seq = 50000
self.changelog_cap = None # сколько записей журнала отдаёт search и GET /issue (None — все); total — полное
self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap)
self.jql_results = {} # JQL → ключи (проверяется в search_keys первым)
self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True},
{"id": "customfield_10104", "name": "Sprint", "custom": True},
{"id": "duedate", "name": "Срок исполнения", "custom": False}] # GET /rest/api/2/field
self.lock = threading.Lock()
fake = self
@ -133,6 +141,19 @@ class FakeJira:
self.issues[key] = base
return base
def add_history(self, key, created, items, hid=None, name="petrov", display="Петров Пётр"):
"""Запись журнала задачи; возвращает её id."""
self.history_seq += 1
hid = hid or str(self.history_seq)
self.changelogs.setdefault(key, []).append({"id": hid, "author": {"name": name, "displayName": display},
"created": created, "items": items})
return hid
def changelog_json(self, key, cap):
lst = self.changelogs.get(key, [])
shown = lst if cap is None else lst[:cap]
return {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst), "histories": shown}
def add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300",
updated=None):
author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else None
@ -186,6 +207,8 @@ class FakeJira:
return out
def search_keys(self, jql):
if jql in self.jql_results:
return [k for k in self.jql_results[jql] if k in self.issues]
m = re.search(r"key in \(([^)]*)\)", jql or "")
if m:
keys = [k.strip() for k in m.group(1).split(",")]
@ -261,8 +284,13 @@ class FakeJira:
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
page = keys[start:start + maxr]
fields = (q.get("fields") or "").split(",") if q.get("fields") else []
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys),
"issues": [self.issue_json(k, fields) for k in page]}
issues = [self.issue_json(k, fields) for k in page]
if q.get("expand") == "changelog":
for it in issues:
it["changelog"] = self.changelog_json(it["key"], self.changelog_cap)
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": issues}
if p == "/rest/api/2/field":
return 200, self.fields
if p == "/rest/api/2/issue" and method == "POST":
self.seq += 1
key = "%s-%d" % (self.project, self.seq)
@ -290,7 +318,11 @@ class FakeJira:
if m:
key = m.group(1)
if method == "GET":
return 200, self.issue_json(key)
out = self.issue_json(key)
if q.get("expand") == "changelog":
cap = self.issue_changelog_cap if self.issue_changelog_cap is not None else self.changelog_cap
out["changelog"] = self.changelog_json(key, cap)
return 200, out
if method == "PUT":
f = self.issues[key]
for k, v in body.get("fields", {}).items():

111
tests/test_history.py Normal file
View File

@ -0,0 +1,111 @@
"""Журнал изменений Jira в refresh (агент v13, spec agent «Журнал изменений»)."""
import unittest
from datetime import datetime, timezone
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_hub import FakeHub
from fake_jira import FakeJira
def _st(field, frm, to, fieldtype="jira", **kw):
return dict({"field": field, "fieldtype": fieldtype, "from": frm, "fromString": frm, "to": to, "toString": to}, **kw)
class Base(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.hub = FakeHub(SETTINGS)
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
self.jira = agent.JiraClient(cfg)
self.jira.sleep = no_sleep
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
self.ex.sleep = no_sleep
self.logs = []
self._log = agent.log
agent.log = self.logs.append
def tearDown(self):
agent.log = self._log
self.fake.stop()
self.hub.stop()
class ChangelogItemsTest(Base):
def test_jira_time_forms(self):
want = datetime(2026, 10, 22, 10, 5, 11, tzinfo=timezone.utc)
for raw in ("2026-10-22T13:05:11.000+0300", "2026-10-22T13:05:11+03:00", "2026-10-22T10:05:11Z",
"2026-10-22T10:05:11.000Z", "2026-10-22T05:05:11.000-0500"):
self.assertEqual(agent._jira_time(raw), want, raw)
for raw in ("мусор", "", None, 42):
self.assertIsNone(agent._jira_time(raw))
def test_changelog_items_since_and_shape(self):
histories = [
{"id": "1", "author": {"name": "petrov", "displayName": "Петров Пётр"}, "created": "2026-10-21T10:00:00.000+0300",
"items": [_st("status", "Открыта", "В работе")]},
{"id": 2, "author": {"name": "ivanov"}, "created": "2026-10-22T12:10:00.000+0300",
"items": [dict(_st("status", "В работе", "Готово"), fieldId="status", **{"from": "3", "to": "10001"}),
_st("resolution", None, "Готово")]}]
items = self.ex.changelog_items("X", histories, agent._jira_time("2026-10-22T08:45:00Z"), lambda n: None)
self.assertEqual([(i["history_id"], i["seq"]) for i in items], [("2", 0), ("2", 1)])
self.assertEqual(items[0], {"history_id": "2", "seq": 0, "key": "X", "created": "2026-10-22T12:10:00.000+0300",
"author": "ivanov", "field_id": "status", "field_name": "status", "from": "3",
"from_string": "В работе", "to": "10001", "to_string": "Готово"})
self.assertEqual(items[1]["field_id"], "resolution")
self.assertIsNone(items[1]["from"])
def test_changelog_items_since_boundary_included(self):
h = [{"id": "1", "author": None, "created": "2026-10-22T11:45:00.000+0300", "items": [_st("status", "a", "b")]}]
items = self.ex.changelog_items("X", h, agent._jira_time("2026-10-22T08:45:00Z"), lambda n: None)
self.assertEqual(len(items), 1)
self.assertIsNone(items[0]["author"])
def test_changelog_items_custom_field_lookup(self):
asked = []
def field_id_of(name):
asked.append(name)
return "customfield_10201"
h = [{"id": "1", "author": {"name": "p"}, "created": "2026-10-22T12:00:00.000+0300",
"items": [_st("Плановое окончание", "2026-10-25", "2026-10-30", fieldtype="custom"),
_st("Sprint", "1", "2", fieldtype="custom", fieldId="customfield_10104"),
_st("duedate", "2026-10-25", "2026-10-30")]}]
items = self.ex.changelog_items("X", h, None, field_id_of)
self.assertEqual([i["field_id"] for i in items], ["customfield_10201", "customfield_10104", "duedate"])
self.assertEqual(asked, ["Плановое окончание"])
def test_changelog_items_drops_bad(self):
h = [{"id": None, "author": None, "created": "2026-10-22T12:00:00.000+0300", "items": [_st("status", "a", "b")]},
{"id": "2", "author": None, "created": "", "items": [_st("status", "a", "b")]},
{"id": "3", "author": None, "created": "2026-10-22T12:00:00.000+0300",
"items": [_st("", "a", "b"), _st("status", 1, 2)]}]
items = self.ex.changelog_items("X", h, None, lambda n: None)
self.assertEqual([(i["history_id"], i["seq"], i["from"], i["to"]) for i in items], [("3", 1, "1", "2")])
self.assertEqual(len([l for l in self.logs if "X" in l]), 3)
def test_changelog_items_no_since(self):
h = [{"id": str(n), "author": None, "created": "2020-01-01T00:00:00.000+0300", "items": [_st("status", "a", "b")]}
for n in range(3)]
self.assertEqual(len(self.ex.changelog_items("X", h, None, lambda n: None)), 3)
class FakeChangelogTest(Base):
def test_fake_search_expands_changelog(self):
self.fake.add("AURORA-1")
for n in range(3):
self.fake.add_history("AURORA-1", "2026-10-22T12:00:00.000+0300", [_st("status", "a", "b")])
self.fake.changelog_cap = 2
page = self.jira.get(agent.API + "/search", jql="key in (AURORA-1)", fields="key", expand="changelog")
cl = page["issues"][0]["changelog"]
self.assertEqual((cl["total"], len(cl["histories"])), (3, 2))
self.fake.issue_changelog_cap = 3
full = self.jira.get(agent.API + "/issue/AURORA-1", fields="key", expand="changelog")
self.assertEqual(len(full["changelog"]["histories"]), 3)
self.assertNotIn("changelog", self.jira.get(agent.API + "/issue/AURORA-1"))
self.fake.jql_results["x AND sprint in openSprints()"] = ["AURORA-1"]
self.assertEqual(self.fake.search_keys("x AND sprint in openSprints()"), ["AURORA-1"])
self.assertTrue(any(f["name"] == "Плановое окончание" for f in self.jira.get(agent.API + "/field")))
if __name__ == "__main__":
unittest.main()