Агент v15: спринты, ранг, оценки (#49) #50

Merged
aep merged 19 commits from feat/49-v15-sprints into main 2026-10-25 13:56:49 +03:00
3 changed files with 106 additions and 0 deletions
Showing only changes of commit 5dba989015 - Show all commits

View File

@ -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 с повтором,

View File

@ -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():

View File

@ -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: Ранжирование недоступно"))