From 5dba9890152e7db65d54a445a8b39deb1ea5f9a2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:18:19 +0400 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0=20ensure=5Frank=20=E2=80=94=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=88=D0=B5=20=D0=B8=D0=BB=D0=B8=20=D0=BD=D0=B8?= =?UTF-8?q?=D0=B6=D0=B5=20=D0=B4=D1=80=D1=83=D0=B3=D0=BE=D0=B9=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20(#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 | 32 ++++++++++++++++++++++ tests/fake_jira.py | 25 +++++++++++++++++ tests/test_executor_sprints.py | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/agent.py b/src/agent.py index 3a3ab41..3708a14 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1215,6 +1215,38 @@ class Executor: self.jira.post(AGILE + "/sprint/%s" % sp["id"], {"state": "closed"}) return self._out("done", {"sprint_id": sp["id"], "moved": moved}) + # --- ensure_rank ----------------------------------------------------------- + def do_ensure_rank(self, p: dict, cmd: dict) -> dict: + """Положение задачи относительно другой: `before` — выше, `after` — ниже (ровно одно). Ранги читаются одним + поиском; задача уже на месте — ничего не пишем. Ошибка в entries ответа PUT — failed (R-73).""" + before, after = p.get("before"), p.get("after") + if bool(before) == bool(after): + return self._out("failed", error="нужно ровно одно из before, after") + key = p["key"] + other = before or after + if other == key: + return self._out("failed", error="задача не может стоять выше или ниже самой себя") + rid = self.jira_fields().get("rank") + if not rid: + return self._out("failed", error="поле ранга не найдено") + page = self.jira.get(API + "/search", jql="key in (%s,%s)" % (key, other), fields=rid) + ranks = {i.get("key"): (i.get("fields") or {}).get(rid) for i in page.get("issues") or []} + mine, theirs = ranks.get(key), ranks.get(other) + if isinstance(mine, str) and isinstance(theirs, str): + if (before and mine < theirs) or (after and mine > theirs): + return self._out("done", {"changed": False}) + body = {"issues": [key], "rankBeforeIssue" if before else "rankAfterIssue": other} + digits = re.search(r"(\d+)$", rid) + if digits: # id без цифрового хвоста — без rankCustomFieldId + body["rankCustomFieldId"] = int(digits.group(1)) + resp = self.jira.put(AGILE + "/issue/rank", body) + for entry in (resp or {}).get("entries") or []: # 207: ошибка по задаче — в entries, не в коде ответа + status = entry.get("status") + if status is not None and not 200 <= int(status) < 300: + errors = "; ".join(str(x) for x in entry.get("errors") or []) or "статус %s" % status + return self._out("failed", error="Jira не переранжировала %s: %s" % (key, errors)) + return self._out("done", {"changed": True}) + # --- ensure_attachment ----------------------------------------------------- def hub_file(self, sha: str, size: int) -> Optional[bytes]: """Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором, diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 50ae550..fd1eae7 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -84,6 +84,8 @@ class FakeJira: self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи) self.sprint_seq = 500 # id следующего созданного спринта self.sprint_posts = [] # (путь, тело) каждой записи спринта: POST /sprint и POST /sprint/{id} + self.rank_bodies = [] # тела PUT /rest/agile/1.0/issue/rank + self.rank_errors = {} # ключ → текст ошибки ранжирования: ответ 207, ранг задачи не меняется self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 self.id_seq = 10000 self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой @@ -552,6 +554,8 @@ class FakeJira: for k in body["issues"]: self.issues[k]["sprint"] = None return 204, None + if p == "/rest/agile/1.0/issue/rank" and method == "PUT": + return self._rank(body) m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p) if m: name = self.issues[m.group(1)]["sprint"] @@ -561,6 +565,27 @@ class FakeJira: return 200, {"key": m.group(1), "fields": {"sprint": sp}} return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]} + def _rank(self, body): + """PUT /issue/rank: задача из issues встаёт строго выше (rankBeforeIssue) или ниже (rankAfterIssue) задачи-ориентира, + ранг которой — ранг RANK_FIELD. Задача из rank_errors не меняется и получает status 400 в entries (ответ 207).""" + self.rank_bodies.append(body) + entries, failed = [], False + for k in body["issues"]: + if k in self.rank_errors: + failed = True + entries.append({"issueKey": k, "issueId": self.issues[k]["id"], "status": 400, + "errors": [self.rank_errors[k]]}) + continue + if body.get("rankBeforeIssue"): + ref = self.issues[body["rankBeforeIssue"]][RANK_FIELD] + self.issues[k][RANK_FIELD] = ref[:-1] + chr(ord(ref[-1]) - 1) + "z" + else: + self.issues[k][RANK_FIELD] = self.issues[body["rankAfterIssue"]][RANK_FIELD] + "m" + entries.append({"issueKey": k, "issueId": self.issues[k]["id"], "status": 204, "errors": []}) + if failed: + return 207, {"entries": entries} + return 204, None + def all_sprints(self): out = list(self.sprints) for vals in self.board_sprints.values(): diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py index 7a45071..375a650 100644 --- a/tests/test_executor_sprints.py +++ b/tests/test_executor_sprints.py @@ -178,3 +178,52 @@ class ExecutorSprintsTest(unittest.TestCase): out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B) self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски")) self.assertEqual(self.fake.writes(), []) + + # --- ensure_rank --- + def test_rank_already(self): # сценарий «Ранг уже такой» + self.fake.add("AURORA-1612", customfield_10105="0|i0001:") + self.fake.add("AURORA-1601", customfield_10105="0|i0002:") + out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601") + self.assertEqual((out["status"], out["result"]), ("done", {"changed": False})) + self.assertEqual(self.fake.writes(), []) + + def test_rank_before_and_after_write(self): + self.fake.add("AURORA-1612", customfield_10105="0|i0003:") + self.fake.add("AURORA-1601", customfield_10105="0|i0002:") + out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601") + self.assertEqual(out["result"], {"changed": True}) + self.assertEqual(self.fake.rank_bodies[-1], {"issues": ["AURORA-1612"], "rankBeforeIssue": "AURORA-1601", "rankCustomFieldId": 10105}) + self.assertLess(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"]) + self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")["result"], {"changed": False}) + self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", after="AURORA-1601")["result"], {"changed": True}) + self.assertGreater(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"]) + + def test_rank_null_rank_writes(self): # у задачи нет ранга (None) — сравнивать нечего, пишем + self.fake.add("AURORA-1612") + self.fake.add("AURORA-1601", customfield_10105="0|i0002:") + out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601") + self.assertEqual((out["status"], out["result"]), ("done", {"changed": True})) + self.assertEqual(self.fake.rank_bodies[-1], {"issues": ["AURORA-1612"], "rankBeforeIssue": "AURORA-1601", "rankCustomFieldId": 10105}) + self.assertLess(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"]) + + def test_rank_bad_params(self): # до любых запросов к Jira — ни поиска, ни записи + self.fake.add("AURORA-1612", customfield_10105="0|i0003:") + self.fake.add("AURORA-1601", customfield_10105="0|i0002:") + out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601", after="AURORA-1601") + self.assertEqual((out["status"], out["error"]), ("failed", "нужно ровно одно из before, after")) + out = self.run_cmd("ensure_rank", key="AURORA-1612") + self.assertEqual((out["status"], out["error"]), ("failed", "нужно ровно одно из before, after")) + out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1612") + self.assertEqual((out["status"], out["error"]), ("failed", "задача не может стоять выше или ниже самой себя")) + self.assertEqual(self.fake.writes(), []) + + def test_rank_no_field(self): + self.fake.add("AURORA-1"); self.fake.add("AURORA-2") + self.fake.fields = [] + self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1", after="AURORA-2")["error"], "поле ранга не найдено") + + def test_rank_207_error(self): # Review Focus 1 + self.fake.add("AURORA-1", customfield_10105="0|i0009:"); self.fake.add("AURORA-2", customfield_10105="0|i0001:") + self.fake.rank_errors["AURORA-1"] = "Ранжирование недоступно" + out = self.run_cmd("ensure_rank", key="AURORA-1", before="AURORA-2") + self.assertEqual((out["status"], out["error"]), ("failed", "Jira не переранжировала AURORA-1: Ранжирование недоступно"))