Агент: ранг, оценка и все спринты задачи в состоянии refresh (#49)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 18:37:03 +04:00
parent 79f18abbfa
commit 9de78842b1
3 changed files with 107 additions and 8 deletions

View File

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

View File

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

View File

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