Агент v13: журнал изменений в refresh (#39)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
de2d5b5252
commit
71e182a162
@ -11,7 +11,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл
|
|||||||
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` |
|
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` |
|
||||||
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
|
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
|
||||||
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
|
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
|
||||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
|
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
|
||||||
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
|
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
|
||||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||||
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
||||||
|
|||||||
@ -174,7 +174,7 @@ python3 agent.py prune
|
|||||||
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
|
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
|
||||||
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
|
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
|
||||||
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
|
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
|
||||||
| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
|
| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
|
||||||
|
|
||||||
Правила работы:
|
Правила работы:
|
||||||
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
|
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
|
||||||
@ -203,6 +203,8 @@ python3 agent.py prune
|
|||||||
Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате
|
Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате
|
||||||
`refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами
|
`refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами
|
||||||
выкатывается только после v12 (design хаба `projects`, решение 14).
|
выкатывается только после v12 (design хаба `projects`, решение 14).
|
||||||
|
Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая
|
||||||
|
загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`).
|
||||||
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
||||||
при этом выдаются по-прежнему.
|
при этом выдаются по-прежнему.
|
||||||
|
|
||||||
@ -252,6 +254,10 @@ python3 agent.py prune
|
|||||||
`/issue/{key}/comment`.
|
`/issue/{key}/comment`.
|
||||||
С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии
|
С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии
|
||||||
задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается.
|
задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается.
|
||||||
|
С v13 после состояния `refresh` читает журнал изменений (если хаб передал `history`): по задачам этого прогона с
|
||||||
|
`updated` не раньше `history.since` и по стартовой выборке `history.jql`. Неполный журнал дочитывается
|
||||||
|
`GET /issue/{key}?expand=changelog`, всё ещё неполный — в логе «получено N из M» и в `result.history.incomplete`.
|
||||||
|
Ошибка Jira или хаба (не 2xx) на этом шаге не роняет `refresh`: текст в `result.history.error` или `backfill_error`.
|
||||||
5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на
|
5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на
|
||||||
`\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы,
|
`\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы,
|
||||||
завершающий перевод строки) — изменение.
|
завершающий перевод строки) — изменение.
|
||||||
|
|||||||
104
src/agent.py
104
src/agent.py
@ -33,7 +33,7 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
AGENT_VERSION = 12
|
AGENT_VERSION = 13
|
||||||
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
|
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
|
||||||
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
|
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
@ -363,6 +363,9 @@ class HubClient(Http):
|
|||||||
def known_keys(self) -> List[str]:
|
def known_keys(self) -> List[str]:
|
||||||
return self.call("GET", "/agent/known-keys")["keys"]
|
return self.call("GET", "/agent/known-keys")["keys"]
|
||||||
|
|
||||||
|
def history(self, payload: dict) -> dict:
|
||||||
|
return self.call("POST", "/agent/history", payload)
|
||||||
|
|
||||||
def file(self, sha256: str) -> dict:
|
def file(self, sha256: str) -> dict:
|
||||||
"""Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file."""
|
"""Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file."""
|
||||||
return self.call("GET", "/agent/files/%s" % sha256)
|
return self.call("GET", "/agent/files/%s" % sha256)
|
||||||
@ -1199,6 +1202,9 @@ class Executor:
|
|||||||
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
|
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
|
||||||
"page": pages, "last_page": i + 100 >= len(issues)})
|
"page": pages, "last_page": i + 100 >= len(issues)})
|
||||||
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
||||||
|
history = self.refresh_history(p, cmd, everything)
|
||||||
|
if history is not None:
|
||||||
|
result["history"] = history
|
||||||
if missing:
|
if missing:
|
||||||
result["missing"] = list(dict.fromkeys(missing))[:50]
|
result["missing"] = list(dict.fromkeys(missing))[:50]
|
||||||
if types:
|
if types:
|
||||||
@ -1206,6 +1212,102 @@ class Executor:
|
|||||||
return self._out("done", result)
|
return self._out("done", result)
|
||||||
|
|
||||||
|
|
||||||
|
# --- журнал изменений (v13, spec agent «Журнал изменений») -----------------------------------------------
|
||||||
|
HISTORY_BATCH = 500
|
||||||
|
|
||||||
|
def search_changelog(self, jql: str) -> List[dict]:
|
||||||
|
out, start = [], 0
|
||||||
|
while True:
|
||||||
|
page = self.jira.get(API + "/search", jql=jql, fields="key", expand="changelog", startAt=start, maxResults=100)
|
||||||
|
got = page.get("issues") or []
|
||||||
|
out += got
|
||||||
|
start += len(got)
|
||||||
|
if not got or start >= int(page.get("total") or 0):
|
||||||
|
return out
|
||||||
|
|
||||||
|
def full_changelog(self, raw: dict, incomplete: List[str]) -> List[dict]:
|
||||||
|
"""Журнал задачи из ответа search; неполный — дочитать GET /issue, всё ещё неполный — что есть, ключ в incomplete."""
|
||||||
|
key, cl = raw["key"], raw.get("changelog") or {}
|
||||||
|
got = list(cl.get("histories") or [])
|
||||||
|
total = cl.get("total")
|
||||||
|
if isinstance(total, int) and total > len(got):
|
||||||
|
more = (self.jira.get(API + "/issue/%s" % key, fields="key", expand="changelog").get("changelog") or {})
|
||||||
|
more_got = list(more.get("histories") or [])
|
||||||
|
if len(more_got) > len(got):
|
||||||
|
got = more_got
|
||||||
|
if total > len(got):
|
||||||
|
log("журнал %s: получено %d из %d" % (key, len(got), total))
|
||||||
|
incomplete.append(key)
|
||||||
|
return got
|
||||||
|
|
||||||
|
def refresh_history(self, p: dict, cmd: dict, fetched: List[dict]) -> Optional[dict]:
|
||||||
|
"""Журнал после состояния: since — задачи этого refresh с updated не раньше since, jql — стартовая загрузка.
|
||||||
|
Ответ не 2xx Jira или хаба — текст в error (since, отправка) или backfill_error (jql), refresh остаётся done;
|
||||||
|
обрыв связи — как во всём refresh."""
|
||||||
|
h = p.get("history")
|
||||||
|
if not isinstance(h, dict):
|
||||||
|
return None
|
||||||
|
out: Dict[str, Any] = {"issues": 0, "changes": 0, "incomplete": []}
|
||||||
|
names: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def field_id_of(name: str) -> Optional[str]:
|
||||||
|
if "map" not in names: # один GET /field за refresh, только по нужде
|
||||||
|
names["map"] = {f.get("name"): f.get("id") for f in self.jira.get(API + "/field") or []
|
||||||
|
if isinstance(f, dict)}
|
||||||
|
return names["map"].get(name)
|
||||||
|
|
||||||
|
items: List[dict] = []
|
||||||
|
read = set()
|
||||||
|
|
||||||
|
def collect(raws: List[dict], since: Optional[datetime]) -> None:
|
||||||
|
for raw in raws:
|
||||||
|
if raw["key"] in read:
|
||||||
|
continue
|
||||||
|
read.add(raw["key"])
|
||||||
|
items.extend(self.changelog_items(raw["key"], self.full_changelog(raw, out["incomplete"]), since,
|
||||||
|
field_id_of))
|
||||||
|
|
||||||
|
if h.get("since"):
|
||||||
|
since = _jira_time(h["since"])
|
||||||
|
try:
|
||||||
|
if since is None:
|
||||||
|
raise ValueError("history.since — неразбираемое время: %r" % h["since"])
|
||||||
|
changed = []
|
||||||
|
for r in fetched:
|
||||||
|
upd = _jira_time(((r.get("fields") or {}).get("updated")))
|
||||||
|
if upd is not None and upd >= since and r["key"] not in changed:
|
||||||
|
changed.append(r["key"])
|
||||||
|
for i in range(0, len(changed), 100):
|
||||||
|
collect(self.search_changelog("key in (%s)" % ",".join(changed[i:i + 100])), since)
|
||||||
|
except HttpError as e:
|
||||||
|
out["error"] = "HTTP %d %s" % (e.code, e.text[:500])
|
||||||
|
log("refresh: журнал не прочитан: %s" % out["error"])
|
||||||
|
except ValueError as e:
|
||||||
|
out["error"] = str(e)
|
||||||
|
log("refresh: %s" % e)
|
||||||
|
if h.get("jql"):
|
||||||
|
try:
|
||||||
|
collect(self.search_changelog(h["jql"]), None)
|
||||||
|
except HttpError as e:
|
||||||
|
out["backfill_error"] = "HTTP %d %s" % (e.code, e.text[:500])
|
||||||
|
log("refresh: стартовая загрузка журнала не прочитана: %s" % out["backfill_error"])
|
||||||
|
sent_keys = set()
|
||||||
|
for i in range(0, len(items), self.HISTORY_BATCH):
|
||||||
|
chunk = items[i:i + self.HISTORY_BATCH]
|
||||||
|
try:
|
||||||
|
self.hub.history({"command_id": cmd.get("id"), "changes": chunk})
|
||||||
|
except HttpError as e:
|
||||||
|
out["error"] = "хаб: HTTP %d %s" % (e.code, e.text[:500])
|
||||||
|
log("refresh: журнал не принят: %s" % out["error"])
|
||||||
|
break
|
||||||
|
except ConnectionError as e:
|
||||||
|
raise HubError(e) from None
|
||||||
|
out["changes"] += len(chunk)
|
||||||
|
sent_keys.update(c["key"] for c in chunk)
|
||||||
|
out["issues"] = len(sent_keys)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
|
# Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import base64
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
@ -28,6 +29,9 @@ class FakeHub:
|
|||||||
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
|
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
|
||||||
self.file_requests = 0
|
self.file_requests = 0
|
||||||
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
|
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
|
||||||
|
self.histories = [] # тела POST /agent/history
|
||||||
|
self.reject_history = None # (код, тело) — ответ на /agent/history ошибкой; "drop" — оборвать без ответа
|
||||||
|
self.posts = [] # порядок POST /agent/state и /agent/history
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
hub = self
|
hub = self
|
||||||
|
|
||||||
@ -81,6 +85,10 @@ class FakeHub:
|
|||||||
if h.headers.get("Authorization") != "Bearer " + self.token:
|
if h.headers.get("Authorization") != "Bearer " + self.token:
|
||||||
return self._send(h, 401, {"error": "unauthorized"})
|
return self._send(h, 401, {"error": "unauthorized"})
|
||||||
path = h.path.split("?")[0]
|
path = h.path.split("?")[0]
|
||||||
|
if path == "/agent/history" and self.reject_history == "drop":
|
||||||
|
h.close_connection = True
|
||||||
|
h.connection.shutdown(socket.SHUT_RDWR) # хаб недоступен: ответа клиент не получит
|
||||||
|
return
|
||||||
if path == "/agent/claim" and self.claim_wait:
|
if path == "/agent/claim" and self.claim_wait:
|
||||||
end = time.monotonic() + self.claim_wait
|
end = time.monotonic() + self.claim_wait
|
||||||
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
|
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
|
||||||
@ -123,7 +131,14 @@ class FakeHub:
|
|||||||
return 200, self.settings
|
return 200, self.settings
|
||||||
if path == "/agent/known-keys":
|
if path == "/agent/known-keys":
|
||||||
return 200, {"keys": list(self.known)}
|
return 200, {"keys": list(self.known)}
|
||||||
|
if path == "/agent/history":
|
||||||
|
self.posts.append(path)
|
||||||
|
if self.reject_history:
|
||||||
|
return self.reject_history
|
||||||
|
self.histories.append(body)
|
||||||
|
return 200, {"accepted": len(body.get("changes") or []), "duplicates": 0}
|
||||||
if path == "/agent/state":
|
if path == "/agent/state":
|
||||||
|
self.posts.append(path)
|
||||||
self.states.append(body)
|
self.states.append(body)
|
||||||
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
|
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
|
||||||
m = re.match(r"^/agent/files/([^/]+)$", path)
|
m = re.match(r"^/agent/files/([^/]+)$", path)
|
||||||
|
|||||||
@ -107,5 +107,180 @@ class FakeChangelogTest(Base):
|
|||||||
self.assertTrue(any(f["name"] == "Плановое окончание" for f in self.jira.get(agent.API + "/field")))
|
self.assertTrue(any(f["name"] == "Плановое окончание" for f in self.jira.get(agent.API + "/field")))
|
||||||
|
|
||||||
|
|
||||||
|
SINCE = "2026-10-22T08:45:00Z"
|
||||||
|
CHANGED = "2026-10-22T12:30:00.000+0300" # 09:30 UTC — позже SINCE
|
||||||
|
BASE = "project = AURORA AND labels = AWPCA"
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshHistoryTest(Base):
|
||||||
|
def refresh(self, history=None, **params):
|
||||||
|
p = dict({"jql": BASE}, **params)
|
||||||
|
if history is not None:
|
||||||
|
p["history"] = history
|
||||||
|
return self.ex.run({"id": 5, "type": "refresh", "params": p})
|
||||||
|
|
||||||
|
def changelog_queries(self):
|
||||||
|
return [q for q in self.fake.queries if q.get("expand") == "changelog"]
|
||||||
|
|
||||||
|
def sent(self):
|
||||||
|
return [c for b in self.hub.histories for c in b["changes"]]
|
||||||
|
|
||||||
|
def test_nothing_changed_no_changelog_calls(self): # «Ничего не менялось»
|
||||||
|
for n in range(3):
|
||||||
|
self.fake.add("AURORA-%d" % n)
|
||||||
|
out = self.refresh({"since": SINCE})
|
||||||
|
self.assertEqual(out["status"], "done")
|
||||||
|
self.assertEqual(out["result"]["history"], {"issues": 0, "changes": 0, "incomplete": []})
|
||||||
|
self.assertEqual(self.changelog_queries(), [])
|
||||||
|
self.assertEqual(self.hub.histories, [])
|
||||||
|
|
||||||
|
def test_two_changed_one_search(self): # «Две задачи изменились»
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
self.fake.add("Y", updated=CHANGED)
|
||||||
|
self.fake.add("Z")
|
||||||
|
self.fake.add_history("X", "2026-10-21T12:00:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "b", "c"), _st("resolution", None, "Готово")])
|
||||||
|
self.fake.add_history("Y", "2026-10-22T12:30:00.000+0300", [_st("description", "старое", "новое")])
|
||||||
|
out = self.refresh({"since": SINCE})
|
||||||
|
qs = self.changelog_queries()
|
||||||
|
self.assertEqual(len(qs), 1)
|
||||||
|
self.assertEqual(qs[0]["jql"], "key in (X,Y)")
|
||||||
|
self.assertEqual(qs[0]["fields"], "key")
|
||||||
|
self.assertEqual([(c["key"], c["seq"]) for c in self.sent()], [("X", 0), ("X", 1), ("Y", 0)])
|
||||||
|
self.assertEqual(self.hub.histories[0]["command_id"], 5)
|
||||||
|
self.assertEqual(out["result"]["history"], {"issues": 2, "changes": 3, "incomplete": []})
|
||||||
|
self.assertEqual(self.hub.posts, ["/agent/state", "/agent/history"]) # журнал — после состояния
|
||||||
|
|
||||||
|
def test_backfill_by_jql(self): # «Стартовая загрузка»
|
||||||
|
jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE
|
||||||
|
keys = ["AURORA-%d" % n for n in range(12)]
|
||||||
|
for k in keys:
|
||||||
|
self.fake.add(k)
|
||||||
|
self.fake.add_history(k, "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.fake.jql_results[jql] = keys
|
||||||
|
out = self.refresh({"jql": jql})
|
||||||
|
self.assertEqual([q["jql"] for q in self.changelog_queries()], [jql])
|
||||||
|
self.assertEqual(len(self.sent()), 12)
|
||||||
|
self.assertEqual(out["result"]["history"], {"issues": 12, "changes": 12, "incomplete": []})
|
||||||
|
|
||||||
|
def test_since_and_jql_dedup(self): # Review Focus 4
|
||||||
|
jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
self.fake.add("Y")
|
||||||
|
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.fake.add_history("Y", "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.fake.jql_results[jql] = ["X", "Y"]
|
||||||
|
out = self.refresh({"since": SINCE, "jql": jql})
|
||||||
|
self.assertEqual(sorted(c["key"] for c in self.sent()), ["X", "Y"])
|
||||||
|
self.assertEqual(out["result"]["history"]["changes"], 2)
|
||||||
|
|
||||||
|
def test_neighbor_changed_included(self): # Review Focus 5
|
||||||
|
self.fake.add("X")
|
||||||
|
self.fake.add("N", in_base=False, updated=CHANGED)
|
||||||
|
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "X", "target": "N"})
|
||||||
|
self.fake.add_history("N", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.refresh({"since": SINCE})
|
||||||
|
self.assertEqual([c["key"] for c in self.sent()], ["N"])
|
||||||
|
|
||||||
|
def test_jira_500_degrades(self): # «Ошибка Jira при чтении журнала»
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
orig = self.fake.route
|
||||||
|
|
||||||
|
def route(method, path, q, body):
|
||||||
|
if q.get("expand") == "changelog":
|
||||||
|
return 500, {"errorMessages": ["сбой"]}
|
||||||
|
return orig(method, path, q, body)
|
||||||
|
self.fake.route = route
|
||||||
|
out = self.refresh({"since": SINCE})
|
||||||
|
self.assertEqual(out["status"], "done")
|
||||||
|
self.assertTrue(out["result"]["history"]["error"].startswith("HTTP 500"))
|
||||||
|
self.assertNotIn("retryable", out)
|
||||||
|
self.assertTrue(self.hub.states)
|
||||||
|
self.assertTrue(any("журнал" in l for l in self.logs))
|
||||||
|
|
||||||
|
def test_backfill_400(self): # «Ошибка стартовой загрузки»
|
||||||
|
jql = "bad jql"
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
orig = self.fake.route
|
||||||
|
|
||||||
|
def route(method, path, q, body):
|
||||||
|
if q.get("jql") == jql:
|
||||||
|
return 400, {"errorMessages": ["Значение не существует"]}
|
||||||
|
return orig(method, path, q, body)
|
||||||
|
self.fake.route = route
|
||||||
|
out = self.refresh({"since": SINCE, "jql": jql})
|
||||||
|
h = out["result"]["history"]
|
||||||
|
self.assertEqual(out["status"], "done")
|
||||||
|
self.assertTrue(h["backfill_error"].startswith("HTTP 400"))
|
||||||
|
self.assertNotIn("error", h)
|
||||||
|
self.assertEqual([c["key"] for c in self.sent()], ["X"])
|
||||||
|
|
||||||
|
def test_incomplete(self): # «Неполный журнал»
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
for n in range(250):
|
||||||
|
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.fake.changelog_cap = 100
|
||||||
|
out = self.refresh({"since": SINCE})
|
||||||
|
self.assertEqual(out["result"]["history"]["incomplete"], ["X"])
|
||||||
|
self.assertEqual(len(self.sent()), 100)
|
||||||
|
self.assertTrue(any("X: получено 100 из 250" in l for l in self.logs))
|
||||||
|
self.assertIn(("GET", "/rest/api/2/issue/X"), self.fake.requests)
|
||||||
|
|
||||||
|
def test_truncated_search_completed_by_issue(self):
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
for n in range(150):
|
||||||
|
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.fake.changelog_cap = 100
|
||||||
|
self.fake.issue_changelog_cap = 150
|
||||||
|
out = self.refresh({"since": SINCE})
|
||||||
|
self.assertEqual(out["result"]["history"], {"issues": 1, "changes": 150, "incomplete": []})
|
||||||
|
|
||||||
|
def test_custom_field_lookup_once(self): # «Плановая дата без fieldId»
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
self.fake.add("Y", updated=CHANGED)
|
||||||
|
for k in ("X", "Y"):
|
||||||
|
self.fake.add_history(k, "2026-10-22T12:10:00.000+0300",
|
||||||
|
[_st("Плановое окончание", "2026-10-25", "2026-10-30", fieldtype="custom")])
|
||||||
|
self.refresh({"since": SINCE})
|
||||||
|
self.assertEqual([c["field_id"] for c in self.sent()], ["customfield_10201", "customfield_10201"])
|
||||||
|
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1)
|
||||||
|
|
||||||
|
def test_hub_422_degrades(self): # Review Focus 3
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.hub.reject_history = (422, {"error": "bad_history", "detail": "changes[0].seq"})
|
||||||
|
out = self.refresh({"since": SINCE})
|
||||||
|
self.assertEqual(out["status"], "done")
|
||||||
|
self.assertTrue(out["result"]["history"]["error"].startswith("хаб: "))
|
||||||
|
self.assertNotIn("retryable", out)
|
||||||
|
|
||||||
|
def test_hub_down_fails(self):
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.hub.reject_history = "drop"
|
||||||
|
out = self.refresh({"since": SINCE})
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertTrue(out["retryable"])
|
||||||
|
|
||||||
|
def test_batches_of_500(self):
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
for n in range(600):
|
||||||
|
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
out = self.refresh({"since": SINCE})
|
||||||
|
self.assertEqual([len(b["changes"]) for b in self.hub.histories], [500, 100])
|
||||||
|
self.assertEqual(out["result"]["history"]["changes"], 600)
|
||||||
|
|
||||||
|
def test_no_history_param(self): # «Хаб без журнала»
|
||||||
|
self.fake.add("X", updated=CHANGED)
|
||||||
|
out = self.refresh()
|
||||||
|
self.assertNotIn("history", out["result"])
|
||||||
|
self.assertEqual(self.changelog_queries(), [])
|
||||||
|
|
||||||
|
def test_version(self):
|
||||||
|
self.assertEqual(agent.AGENT_VERSION, 13)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user