Агент v15: спринты, ранг, оценки (#49) #50
32
src/agent.py
32
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 с повтором,
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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: Ранжирование недоступно"))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user