diff --git a/src/agent.py b/src/agent.py index f048097..90e8d26 100644 --- a/src/agent.py +++ b/src/agent.py @@ -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.""" diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 24a70a2..4a4b7a9 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -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(): diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..97c781b --- /dev/null +++ b/tests/test_history.py @@ -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()