From 9de78842b1fa2d73bddf4c5ac3429e1b0c4ec2ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:37:03 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B3,=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B8=20refresh=20(#?= =?UTF-8?q?49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 44 ++++++++++++++++++++++++++++++++++++-- tests/fake_jira.py | 22 +++++++++++++++++-- tests/test_refresh.py | 49 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/agent.py b/src/agent.py index 7c04c37..b9aef90 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1118,7 +1118,8 @@ class Executor: ep = (self.s.get("epic_link") or {}) # режим связи с эпиком — только field (mode=link не поддержан) if ep.get("mode", "field") == "field" and ep.get("field_id"): extra.append(ep["field_id"]) - return ",".join(self.STATE_FIELDS + extra) + extra += [v for v in self.jira_fields().values() if v] + return ",".join(dict.fromkeys(self.STATE_FIELDS + extra)) def search_all(self, jql: str) -> List[dict]: issues, start = [], 0 @@ -1157,15 +1158,49 @@ class Executor: def name(obj): return (obj or {}).get("name") + jf = self.jira_fields() ep = self.s.get("epic_link") or {} epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None - return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"), + st = {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"), "description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")), "assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")), "planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None, "planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None, "epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None, "created": f.get("created"), "jira_updated": f.get("updated"), "attachments": self.attachments_of(raw)} + if jf["rank"]: # поле не найдено — ключа нет, а не null (R-64) + v = f.get(jf["rank"]) + st["rank"] = v if isinstance(v, str) else None + if jf["story_points"]: + v = f.get(jf["story_points"]) + st["story_points"] = v if isinstance(v, (int, float)) and not isinstance(v, bool) else None + if jf["sprint"]: + st["sprint_ids"] = self.sprint_ids_of(raw["key"], f.get(jf["sprint"])) + return st + + @staticmethod + def sprint_ids_of(key: str, value: Any) -> List[int]: + """id спринтов из поля спринтов: элементы — словари (id — число или строка цифр) или строки greenhopper + («...[id=72,...]»). Не разобранный элемент — лог и пропуск; повторы убираются.""" + out: List[int] = [] + if not isinstance(value, list): + return out + for it in value: + sid = None + if isinstance(it, dict): + i = it.get("id") + if isinstance(i, int) and not isinstance(i, bool): + sid = i + elif isinstance(i, str) and i.isdigit(): + sid = int(i) + elif isinstance(it, str): + m = re.search(r"[\[,]id=(\d+)", it) + sid = int(m.group(1)) if m else None + if sid is None: + log(" ! %s: элемент поля спринта не разобран" % key) + elif sid not in out: + out.append(sid) + return out def links_from(self, raw: dict) -> List[dict]: key, out = raw["key"], [] @@ -1367,6 +1402,11 @@ class Executor: for i in range(0, len(absent), 100): neighbors += self.search_tolerant(absent[i:i + 100], missing, errors) everything = raws + neighbors + if everything: + jf = self.jira_fields() + for k, what, name in (("rank", "ранга", "rank"), ("sprint", "спринта", "sprint_ids"), ("story_points", "оценки", "story_points")): + if not jf[k]: + log("refresh: поле %s не найдено в Jira — %s у задач не передаётся" % (what, name)) types = dict(types or {}) names = self.sprint_names([r["key"] for r in everything], scrum, types) issues, links, seen = [], [], set() diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 9a176e6..25e172c 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -24,7 +24,7 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал SPRINT_FIELD = "customfield_10104" RANK_FIELD = "customfield_10105" POINTS_FIELD = "customfield_10106" -CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") +CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304", RANK_FIELD, POINTS_FIELD) def parse_multipart(raw, content_type): @@ -74,6 +74,7 @@ class FakeJira: self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}] self.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id} self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints + self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи) self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 self.id_seq = 10000 @@ -149,7 +150,7 @@ class FakeJira: base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [], "issuetype": "Задача", "created": self.ts(self.now - timedelta(hours=1)), "updated": "2026-10-01T10:00:00.000+0300", - "sprint": None} + "sprint": None, "sprint_ids": None} self.id_seq += 1 base["id"] = str(self.id_seq) base.update({c: None for c in CUSTOM}) @@ -211,6 +212,7 @@ class FakeJira: "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} out.update({c: f.get(c) for c in CUSTOM}) + out[SPRINT_FIELD] = self.sprint_value(f) if fields is None or "comment" in fields: lst = self.comments.get(key, []) cap = self.search_comment_cap if fields is not None else None @@ -221,6 +223,22 @@ class FakeJira: out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])] return {"key": key, "id": f["id"], "fields": out} + def sprint_value(self, f): + """Значение поля спринтов задачи: sprint_raw как есть; иначе спринты по sprint_ids, иначе по имени sprint.""" + if "sprint_raw" in f: + return f["sprint_raw"] + allsp = self.all_sprints() + if f.get("sprint_ids") is not None: + sps = [s for i in f["sprint_ids"] for s in allsp if s["id"] == i] + else: + sps = [s for s in allsp if s["name"] == f.get("sprint")][:1] if f.get("sprint") else [] + if not sps: + return None + if self.sprint_field_format == "string": + return ["com.atlassian.greenhopper.service.sprint.Sprint@1f2e3d[id=%d,rapidViewId=4216,state=%s,name=%s," + "sequence=%d]" % (s["id"], s["state"].upper(), s["name"], s["id"]) for s in sps] + return [{"id": s["id"], "name": s["name"], "state": s["state"]} for s in sps] + def links_of(self, key): out = [] for l in self.links: diff --git a/tests/test_refresh.py b/tests/test_refresh.py index ba0aecd..bd318eb 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -336,6 +336,7 @@ class RefreshTest(unittest.TestCase): self.assertFalse(out["retryable"]) def test_jira_error_has_no_peer(self): + self.ex.jira_fields() self.fake.fail_next.append((500, {})) out = self.ex.run({"id": 1, "type": "refresh", "params": {"jql": "project = AURORA"}}) self.assertTrue(out["retryable"]) @@ -354,6 +355,46 @@ class RefreshTest(unittest.TestCase): finally: agent.log = orig + def test_state_rank_points_sprint_ids(self): # сценарий «Доска с закрытыми спринтами», часть задач + self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": s} + for i, s in ((1, "closed"), (2, "closed"), (3, "active"), (4, "future"))] + self.fake.add("AURORA-1", sprint="S3", sprint_ids=[2, 3], customfield_10105="0|i0001:", customfield_10106=3.0) + self.fake.add("AURORA-2") + self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}}) + by = {i["key"]: i for i in self.hub.states[0]["issues"]} + self.assertEqual((by["AURORA-1"]["rank"], by["AURORA-1"]["story_points"], by["AURORA-1"]["sprint_ids"]), + ("0|i0001:", 3.0, [2, 3])) + self.assertEqual((by["AURORA-2"]["rank"], by["AURORA-2"]["story_points"], by["AURORA-2"]["sprint_ids"]), (None, None, [])) + fields = [q["fields"].split(",") for q in self.fake.queries if "jql" in q and "fields" in q][0] + for f in ("customfield_10104", "customfield_10105", "customfield_10106"): + self.assertIn(f, fields) + + def test_sprint_field_server_strings(self): + self.fake.add("AURORA-1", sprint_raw=[ + "com.atlassian.greenhopper.service.sprint.Sprint@1a[id=72,rapidViewId=4216,state=CLOSED,name=A,sequence=72]", + "com.atlassian.greenhopper.service.sprint.Sprint@1b[id=73,rapidViewId=4216,state=ACTIVE,name=B,sequence=73]", + "мусор", {"id": "74"}, {"name": "без id"}]) + out, logs = self.logged(lambda: self.refresh(keys=["AURORA-1"])) + self.assertEqual(self.hub.states[0]["issues"][0]["sprint_ids"], [72, 73, 74]) + self.assertEqual(len([l for l in logs if "AURORA-1" in l and "не разобран" in l]), 2, logs) + + def test_story_points_non_number_is_null(self): + for key, v in (("AURORA-1", "3"), ("AURORA-2", True), ("AURORA-3", 5)): + self.fake.add(key, customfield_10106=v) + self.refresh(keys=["AURORA-1", "AURORA-2", "AURORA-3"]) + self.assertEqual([i["story_points"] for i in self.hub.states[0]["issues"]], [None, None, 5]) + + def test_missing_fields_not_sent_logged_once(self): # сценарий «Нет поля оценки», сторона refresh + self.fake.fields = [] + for i in range(1, 121): + self.fake.add("AURORA-%d" % i, customfield_10106=2) + out, logs = self.logged(lambda: self.refresh(jql=self.JQL)) + self.assertEqual(out["result"]["pages"], 2) + for st in self.hub.states[0]["issues"] + self.hub.states[1]["issues"]: + self.assertFalse({"rank", "story_points", "sprint_ids"} & set(st), st) + for name in ("rank", "sprint_ids", "story_points"): + self.assertEqual(len([l for l in logs if "не найдено" in l and name in l]), 1, logs) + def test_two_boards_one_new(self): # сценарий «Две доски, одна новая» self.fake.boards[5120] = "kanban" self.fake.add("AURORA-1", sprint="Спринт 2") @@ -467,10 +508,6 @@ class RefreshTest(unittest.TestCase): self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) -if __name__ == "__main__": - unittest.main() - - class JiraFieldsTest(unittest.TestCase): def setUp(self): self.fake = FakeJira() @@ -523,3 +560,7 @@ class JiraFieldsTest(unittest.TestCase): self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) # в той же команде — не повторяем self.ex.run({"id": 2, "type": "nope", "params": {}}) self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105") + + +if __name__ == "__main__": + unittest.main()