Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
abbcf0cc1f
commit
4a6855d70b
@ -30,6 +30,12 @@ def int_group(m, i=1) -> int:
|
||||
return int(m.group(i))
|
||||
|
||||
|
||||
def is_digits(value) -> bool:
|
||||
"""Непустая строка только из ASCII-цифр 0-9. str.isdigit() принимает «²» и арабо-индийские цифры, на которых int()
|
||||
падает (#86 F.1); идентификаторы из цифр проверять только этим."""
|
||||
return isinstance(value, str) and value.isascii() and value.isdigit()
|
||||
|
||||
|
||||
def int_param(q, name, default) -> int:
|
||||
try:
|
||||
return int(q.get(name, default))
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import re
|
||||
|
||||
from ...core import files
|
||||
from ...core.http import is_digits
|
||||
|
||||
# lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки;
|
||||
# после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя
|
||||
@ -40,7 +41,7 @@ def ref_names(text: str | None) -> list[str]:
|
||||
|
||||
def _newest_key(item: dict):
|
||||
aid = str(item["attachment_id"])
|
||||
return item.get("created_at") or "", int(aid) if aid.isdigit() else -1, aid
|
||||
return item.get("created_at") or "", int(aid) if is_digits(aid) else -1, aid
|
||||
|
||||
|
||||
def resolve(names: list[str], items: list[dict]) -> list[str]:
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Скачивание вложения из Jira по запросу (fetch_attachment): постановка в очередь и ошибки постановки."""
|
||||
from ...core import db, queue
|
||||
from ...core.errors import CommandError, HttpMapped
|
||||
from ...core.http import is_digits
|
||||
from . import attachments, commands
|
||||
|
||||
FETCH_AGENT_VERSION = 14 # первая версия агента с fetch_attachment
|
||||
@ -31,7 +32,7 @@ def request_fetch(conn, files_dir, attachment_id: str, author: str | None) -> di
|
||||
if info["state"] in ("ready", "fetching"):
|
||||
return info
|
||||
v = queue.agent_version(conn, "jira")
|
||||
if v is not None and str(v).isdigit() and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем
|
||||
if v is not None and is_digits(str(v)) and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем
|
||||
raise AgentTooOld(f"агент v{v} не умеет скачивать вложения — нужен v{FETCH_AGENT_VERSION}")
|
||||
cmds = commands.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}])
|
||||
hooks.check_setup(conn, cmds, files_dir)
|
||||
|
||||
@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import projects
|
||||
from ...core import db, settings
|
||||
from ...core.http import is_digits
|
||||
|
||||
SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5)
|
||||
|
||||
@ -14,7 +15,7 @@ def jira_utc(value) -> str | None:
|
||||
if not isinstance(value, str) or not value:
|
||||
return None
|
||||
s = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||
if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM
|
||||
if len(s) > 5 and s[-5] in "+-" and is_digits(s[-4:]): # ±HHMM → ±HH:MM
|
||||
s = s[:-2] + ":" + s[-2:]
|
||||
try:
|
||||
d = datetime.fromisoformat(s)
|
||||
|
||||
@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
|
||||
from ...core import files, projects, queue, settings
|
||||
from ...core.http import is_digits
|
||||
from ...core.errors import CommandError
|
||||
from ...core.modules import CommandHooks
|
||||
from . import attachments, commands, history, mirror, refresh
|
||||
@ -40,7 +41,7 @@ def check_setup(conn, cmds, files_dir) -> None:
|
||||
# кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2)
|
||||
raise CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)")
|
||||
if c["type"] == "fetch_attachment":
|
||||
if not p["attachment_id"].isdigit():
|
||||
if not is_digits(p["attachment_id"]):
|
||||
raise CommandError(f"команда {i}: attachment_id — цифры id вложения из issue")
|
||||
row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?",
|
||||
(p["attachment_id"], p["key"])).fetchone()
|
||||
|
||||
@ -15,6 +15,7 @@ from mcp.server.fastmcp import FastMCP
|
||||
from ...core import mcp as core
|
||||
from ...core.client import HubError
|
||||
from ...core.files import MAX_SIZE
|
||||
from ...core.http import is_digits
|
||||
from .attachments import fmt_size
|
||||
from .commands import describe, is_temp
|
||||
|
||||
@ -46,7 +47,7 @@ def fmt_day(value) -> str:
|
||||
text = value.strip()
|
||||
if text.endswith(("Z", "z")):
|
||||
text = text[:-1] + "+00:00"
|
||||
if len(text) > 5 and text[-5] in "+-" and text[-4:].isdigit():
|
||||
if len(text) > 5 and text[-5] in "+-" and is_digits(text[-4:]):
|
||||
text = text[:-2] + ":" + text[-2:]
|
||||
try:
|
||||
at = datetime.fromisoformat(text)
|
||||
@ -218,7 +219,7 @@ DATE_LABELS = {"duedate": "срок", "planned_start": "плановое нач
|
||||
|
||||
def _day(value, text) -> str:
|
||||
"""Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—»."""
|
||||
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit():
|
||||
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and is_digits(value[:4]):
|
||||
return f"{value[8:10]}.{value[5:7]}"
|
||||
return core.value(text)
|
||||
|
||||
@ -346,7 +347,7 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
|
||||
d = await asyncio.to_thread(client.get, f"/issues/{key}")
|
||||
atts = d.get("attachments") or []
|
||||
ref = attachment.strip().lstrip("#")
|
||||
found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else []
|
||||
found = [a for a in atts if str(a["attachment_id"]) == ref] if is_digits(ref) else []
|
||||
prefix = ""
|
||||
if not found:
|
||||
want = unicodedata.normalize("NFC", attachment.strip())
|
||||
|
||||
@ -5,6 +5,7 @@ import math
|
||||
|
||||
from . import attachments, history, projects
|
||||
from ...core import db
|
||||
from ...core.http import is_digits
|
||||
|
||||
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
|
||||
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
|
||||
@ -43,7 +44,7 @@ def check_attachments(issues) -> None:
|
||||
where = f"{key}: attachments[{n}]"
|
||||
if not isinstance(a, dict):
|
||||
raise ValueError(f"{where} — объект вложения")
|
||||
if not isinstance(a.get("id"), str) or not a["id"].isdigit():
|
||||
if not is_digits(a.get("id")):
|
||||
raise ValueError(f"{where}.id — строка из цифр")
|
||||
if not isinstance(a.get("filename"), str) or not a["filename"]:
|
||||
raise ValueError(f"{where}.filename — непустая строка")
|
||||
|
||||
@ -1188,6 +1188,26 @@ def test_batches_post_fetch_too_large_413(hub):
|
||||
assert hub["client"].get("/batches")["batches"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["²", "١٢", 7])
|
||||
def test_batches_post_fetch_attachment_id_not_ascii_digits_422(hub, bad):
|
||||
"""#86 F.1: attachment_id из не-ASCII цифр или числом — 422, не 500; пакет не создан."""
|
||||
_seed_attachment(hub)
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["client"].post("/batches", {"title": "x", "commands": [
|
||||
{"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": bad}}]})
|
||||
assert e.value.status == 422
|
||||
assert hub["client"].get("/batches")["batches"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_id", ["²", 7])
|
||||
def test_agent_state_attachment_id_ascii_only_422(hub, bad_id):
|
||||
"""#86 F.1: id вложения в состоянии агента — только строка из ASCII-цифр (ascii digit)."""
|
||||
with pytest.raises(HubError) as e:
|
||||
hub["agent"].post("/agent/state", {"issues": [{"key": "Y-1", "summary": "Игрек", "labels": [],
|
||||
"attachments": [{"id": bad_id, "filename": "a"}]}], "links": []})
|
||||
assert e.value.status == 422 and e.value.error == "bad_state"
|
||||
|
||||
|
||||
def test_command_retry_fetch_too_large_413(hub):
|
||||
"""M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large."""
|
||||
_seed_attachment(hub)
|
||||
|
||||
@ -93,3 +93,9 @@ def test_state_failed_and_cancelled(conn, tmp_path):
|
||||
|
||||
def test_get_missing(conn, tmp_path):
|
||||
assert attachments.get(conn, tmp_path, "999") is None
|
||||
|
||||
|
||||
def test_newest_key_non_ascii_digit_goes_last_ascii():
|
||||
"""#86 F.1: id «²» не бросает в int() и уходит в хвост, как нецифровой (ascii digit)."""
|
||||
assert attachments._newest_key({"attachment_id": "²", "created_at": "t"}) == ("t", -1, "²")
|
||||
assert attachments._newest_key({"attachment_id": "12", "created_at": "t"}) == ("t", 12, "12")
|
||||
|
||||
@ -378,3 +378,10 @@ def test_normalize_summary_description_goal_untouched():
|
||||
out = commands.validate([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task",
|
||||
"summary": " Т ", "description": " d\n"}}])
|
||||
assert out[0]["params"]["summary"] == " Т " and out[0]["params"]["description"] == " d\n"
|
||||
|
||||
|
||||
def test_is_digits_ascii_only():
|
||||
from sdlc_hub.core.http import is_digits
|
||||
assert is_digits("123") is True
|
||||
for bad in ("", "²", "١٢", "12a", 7, None):
|
||||
assert is_digits(bad) is False
|
||||
|
||||
@ -369,6 +369,7 @@ def test_attachment_moves_to_other_key(conn):
|
||||
@pytest.mark.parametrize("bad, field", [
|
||||
("x", "attachments"), (["x"], "attachments[0]"),
|
||||
([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": "1a", "filename": "a"}], "attachments[0].id"),
|
||||
([{"id": "²", "filename": "a"}], "attachments[0].id"), ([{"id": "١٢", "filename": "a"}], "attachments[0].id"),
|
||||
([{"id": "-5", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"),
|
||||
([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"),
|
||||
([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"),
|
||||
|
||||
@ -1287,6 +1287,12 @@ def test_request_fetch_agent_too_old(fconn, tmp_path):
|
||||
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
|
||||
|
||||
|
||||
def test_request_fetch_agent_version_not_ascii_digits_ascii(fconn, tmp_path):
|
||||
"""#86 F.1: версия агента «²» — неизвестная, не блокирует и не бросает ValueError (ascii digit)."""
|
||||
fconn.execute("UPDATE agent_status SET agent_version = '²' WHERE agent = 'jira'")
|
||||
assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching"
|
||||
|
||||
|
||||
def test_request_fetch_after_failed(fconn, tmp_path):
|
||||
root = tmp_path / "files"
|
||||
first = jira_fetch.request_fetch(fconn, root, "31337", "x")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user