fix(pipeline-status): scan all rotated memory files in check_memory (#245)
* refactor(pipeline-status): split get_memory_file_path into _resolve_memory_base + get_memory_files * test(pipeline-status): update check_memory tests for multi-file scan * test(pipeline-status): add rotation/mtime/glob coverage * fix(ci): move os/time imports to top-level in test_pipeline_status --------- Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
parent
1e9ee49a1d
commit
596aa9b18a
2 changed files with 166 additions and 40 deletions
|
|
@ -173,13 +173,23 @@ def parse_remote_url(url: str) -> tuple[str, str, str]:
|
|||
raise ValueError(f"Cannot parse remote URL: {url}")
|
||||
|
||||
|
||||
def get_memory_file_path() -> Path:
|
||||
"""Derive memory file path from ``git remote get-url origin``."""
|
||||
def _resolve_memory_base() -> tuple[Path, str]:
|
||||
"""Derive (memory_dir, repo_name) from ``git remote get-url origin``."""
|
||||
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
|
||||
host, org, repo = parse_remote_url(out.strip())
|
||||
return MEMORY_DIR / host / org / f"{repo}.md"
|
||||
return MEMORY_DIR / host / org, repo
|
||||
|
||||
|
||||
def get_memory_files() -> list[Path]:
|
||||
"""All ``repo*.md`` sorted by mtime descending (newest first)."""
|
||||
base_dir, repo = _resolve_memory_base()
|
||||
if not base_dir.exists():
|
||||
return []
|
||||
rot_pattern = re.compile(rf"^{re.escape(repo)}(-\d+)?$")
|
||||
files = [f for f in base_dir.glob("*.md") if rot_pattern.fullmatch(f.stem)]
|
||||
return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
|
||||
@functools.cache
|
||||
|
|
@ -458,24 +468,24 @@ def check_merge(pr_number: int) -> PhaseResult:
|
|||
def check_memory(pr_number: int) -> PhaseResult:
|
||||
"""Phase 7: MEMORY — PR#N distilled into memory file."""
|
||||
try:
|
||||
memory_file = get_memory_file_path()
|
||||
files = get_memory_files()
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
return PhaseResult(PhaseStatus.NOT_DONE, str(exc))
|
||||
|
||||
if not memory_file.exists():
|
||||
if not files:
|
||||
return PhaseResult(
|
||||
PhaseStatus.NOT_DONE,
|
||||
f"memory file не существует: {memory_file.name}",
|
||||
"memory files не найдены",
|
||||
)
|
||||
|
||||
content = memory_file.read_text()
|
||||
pattern = f"PR#{pr_number}"
|
||||
if pattern in content:
|
||||
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {memory_file.name}")
|
||||
for f in files:
|
||||
if pattern in f.read_text():
|
||||
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {f.name}")
|
||||
|
||||
return PhaseResult(
|
||||
PhaseStatus.NOT_DONE,
|
||||
f"{pattern} не найден в {memory_file.name}",
|
||||
f"{pattern} не найден в {len(files)} файл(ах)",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ without extra boilerplate in every test.
|
|||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
|
@ -488,44 +490,42 @@ def test_check_merge_not_done_pr_missing(monkeypatch):
|
|||
def test_check_memory_done(tmp_path, monkeypatch):
|
||||
memory_file = tmp_path / "opencode.md"
|
||||
memory_file.write_text("- [2026-07-19, PR#46] test entry\n")
|
||||
monkeypatch.setattr(ps, "get_memory_file_path", lambda: memory_file)
|
||||
monkeypatch.setattr(ps, "get_memory_files", lambda: [memory_file])
|
||||
result = ps.check_memory(46)
|
||||
assert result.status == ps.PhaseStatus.DONE
|
||||
assert "PR#46" in result.detail
|
||||
|
||||
|
||||
def test_check_memory_not_done_no_file(tmp_path, monkeypatch):
|
||||
memory_file = tmp_path / "opencode.md"
|
||||
monkeypatch.setattr(ps, "get_memory_file_path", lambda: memory_file)
|
||||
def test_check_memory_not_done_no_files(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(ps, "get_memory_files", lambda: [])
|
||||
result = ps.check_memory(46)
|
||||
assert result.status == ps.PhaseStatus.NOT_DONE
|
||||
assert "не существует" in result.detail
|
||||
assert "не найдены" in result.detail
|
||||
|
||||
|
||||
def test_check_memory_not_done_no_pattern(tmp_path, monkeypatch):
|
||||
memory_file = tmp_path / "opencode.md"
|
||||
memory_file.write_text("- some other entry\n")
|
||||
monkeypatch.setattr(ps, "get_memory_file_path", lambda: memory_file)
|
||||
monkeypatch.setattr(ps, "get_memory_files", lambda: [memory_file])
|
||||
result = ps.check_memory(46)
|
||||
assert result.status == ps.PhaseStatus.NOT_DONE
|
||||
assert "не найден" in result.detail
|
||||
|
||||
|
||||
def test_check_memory_not_done_remote_error(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ps,
|
||||
"get_memory_file_path",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("remote error")),
|
||||
)
|
||||
def raise_err():
|
||||
raise RuntimeError("remote error")
|
||||
|
||||
monkeypatch.setattr(ps, "get_memory_files", raise_err)
|
||||
result = ps.check_memory(46)
|
||||
assert result.status == ps.PhaseStatus.NOT_DONE
|
||||
assert "remote error" in result.detail
|
||||
|
||||
|
||||
# ── get_memory_file_path ─────────────────────────────────────────────────────
|
||||
# ── _resolve_memory_base ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_memory_file_path(monkeypatch):
|
||||
def test_resolve_memory_base(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ps,
|
||||
"run_cmd",
|
||||
|
|
@ -535,32 +535,33 @@ def test_get_memory_file_path(monkeypatch):
|
|||
}
|
||||
),
|
||||
)
|
||||
path = ps.get_memory_file_path()
|
||||
assert path == ps.MEMORY_DIR / "github.com" / "slaid098" / "opencode-config.md"
|
||||
result_dir, result_repo = ps._resolve_memory_base()
|
||||
assert result_repo == "opencode-config"
|
||||
assert result_dir == ps.MEMORY_DIR / "github.com" / "slaid098"
|
||||
|
||||
|
||||
def test_get_memory_file_path_ssh(monkeypatch):
|
||||
def test_resolve_memory_base_ssh(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
ps,
|
||||
"run_cmd",
|
||||
mock_run_cmd({("git", "remote"): (0, "git@github.com:slaid098/opencode-config.git\n", "")}),
|
||||
)
|
||||
path = ps.get_memory_file_path()
|
||||
assert path.name == "opencode-config.md"
|
||||
assert "github.com" in str(path)
|
||||
assert "slaid098" in str(path)
|
||||
result_dir, result_repo = ps._resolve_memory_base()
|
||||
assert result_repo == "opencode-config"
|
||||
assert "github.com" in str(result_dir)
|
||||
assert "slaid098" in str(result_dir)
|
||||
|
||||
|
||||
def test_get_memory_file_path_with_userinfo(monkeypatch):
|
||||
"""get_memory_file_path строит правильный путь когда remote URL содержит userinfo.
|
||||
def test_resolve_memory_base_with_userinfo(monkeypatch):
|
||||
"""_resolve_memory_base строит правильный путь когда remote URL содержит userinfo.
|
||||
|
||||
Regression for PR#53/ADR-022: after ``git config url.insteadOf``, ``git
|
||||
remote get-url origin`` returns
|
||||
``https://x-access-token:TOKEN@github.com/slaid098/opencode-config.git``.
|
||||
Without the regex fix, ``get_memory_file_path`` built
|
||||
``repos/x-access-token:TOKEN@github.com/slaid098/opencode-config.md``
|
||||
(nonexistent) → ``check_memory`` returned NOT_DONE. With the fix, the
|
||||
path is the same as for the plain URL.
|
||||
Without the regex fix, ``_resolve_memory_base`` built
|
||||
``repos/x-access-token:TOKEN@github.com/slaid098`` (nonexistent) →
|
||||
``get_memory_files`` found no files → ``check_memory`` returned NOT_DONE.
|
||||
With the fix, the path is the same as for the plain URL.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
ps,
|
||||
|
|
@ -575,11 +576,126 @@ def test_get_memory_file_path_with_userinfo(monkeypatch):
|
|||
}
|
||||
),
|
||||
)
|
||||
path = ps.get_memory_file_path()
|
||||
assert path == ps.MEMORY_DIR / "github.com" / "slaid098" / "opencode-config.md"
|
||||
result_dir, result_repo = ps._resolve_memory_base()
|
||||
assert result_repo == "opencode-config"
|
||||
assert result_dir == ps.MEMORY_DIR / "github.com" / "slaid098"
|
||||
# userinfo must NOT leak into the path
|
||||
assert "x-access-token" not in str(path)
|
||||
assert "github_pat_TOKEN" not in str(path)
|
||||
assert "x-access-token" not in str(result_dir)
|
||||
assert "github_pat_TOKEN" not in str(result_dir)
|
||||
|
||||
|
||||
# ── check_memory: rotation coverage ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_check_memory_done_rotated_file(tmp_path, monkeypatch):
|
||||
"""{repo}.md frozen (no PR#N), {repo}-002.md with PR#N → DONE, -002.md in detail."""
|
||||
frozen = tmp_path / "opencode-config.md"
|
||||
frozen.write_text("- other entries\n")
|
||||
rotated = tmp_path / "opencode-config-002.md"
|
||||
rotated.write_text("- [2026-08-03, PR#46] test\n")
|
||||
monkeypatch.setattr(ps, "get_memory_files", lambda: [frozen, rotated])
|
||||
result = ps.check_memory(46)
|
||||
assert result.status == ps.PhaseStatus.DONE
|
||||
assert "opencode-config-002.md" in result.detail
|
||||
|
||||
|
||||
def test_check_memory_done_newest_first(tmp_path, monkeypatch):
|
||||
"""PR#N in several files → returns mtime-newest (first in the sorted list)."""
|
||||
file_old = tmp_path / "opencode-config.md"
|
||||
file_old.write_text("- [2026-08-03, PR#46] old\n")
|
||||
file_new = tmp_path / "opencode-config-002.md"
|
||||
file_new.write_text("- [2026-08-03, PR#46] new\n")
|
||||
# get_memory_files already sorted mtime desc — mock in this order
|
||||
monkeypatch.setattr(ps, "get_memory_files", lambda: [file_new, file_old])
|
||||
result = ps.check_memory(46)
|
||||
assert result.status == ps.PhaseStatus.DONE
|
||||
assert "opencode-config-002.md" in result.detail
|
||||
|
||||
|
||||
def test_check_memory_not_done_multiple_files(tmp_path, monkeypatch):
|
||||
"""2 files without PR#N → NOT_DONE, detail mentions count."""
|
||||
f1 = tmp_path / "opencode-config.md"
|
||||
f1.write_text("nope\n")
|
||||
f2 = tmp_path / "opencode-config-002.md"
|
||||
f2.write_text("also nope\n")
|
||||
monkeypatch.setattr(ps, "get_memory_files", lambda: [f1, f2])
|
||||
result = ps.check_memory(46)
|
||||
assert result.status == ps.PhaseStatus.NOT_DONE
|
||||
assert "2" in result.detail
|
||||
|
||||
|
||||
def test_check_memory_not_done_value_error(monkeypatch):
|
||||
"""parse_remote_url raises ValueError → NOT_DONE with message."""
|
||||
|
||||
def raise_err():
|
||||
raise ValueError("bad url")
|
||||
|
||||
monkeypatch.setattr(ps, "get_memory_files", raise_err)
|
||||
result = ps.check_memory(46)
|
||||
assert result.status == ps.PhaseStatus.NOT_DONE
|
||||
assert "bad url" in result.detail
|
||||
|
||||
|
||||
# ── get_memory_files: glob / mtime / filter ───────────────────────────────────
|
||||
|
||||
|
||||
def test_get_memory_files_sorted_by_mtime_desc(tmp_path, monkeypatch):
|
||||
"""Files sorted by mtime descending (newest first)."""
|
||||
base = tmp_path / "github.com" / "slaid098"
|
||||
base.mkdir(parents=True)
|
||||
f1 = base / "opencode-config.md"
|
||||
f1.write_text("a\n")
|
||||
f2 = base / "opencode-config-002.md"
|
||||
f2.write_text("b\n")
|
||||
now = time.time()
|
||||
os.utime(f1, (now - 100, now - 100))
|
||||
os.utime(f2, (now, now))
|
||||
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
|
||||
result = ps.get_memory_files()
|
||||
assert result == [f2, f1]
|
||||
|
||||
|
||||
def test_get_memory_files_excludes_unrelated_repo(tmp_path, monkeypatch):
|
||||
"""Only files matching {repo} stem, not other repos in the same dir."""
|
||||
base = tmp_path / "github.com" / "slaid098"
|
||||
base.mkdir(parents=True)
|
||||
(base / "opencode-config.md").write_text("a\n")
|
||||
(base / "opencode.md").write_text("b\n") # different repo
|
||||
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
|
||||
result = ps.get_memory_files()
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "opencode-config.md"
|
||||
|
||||
|
||||
def test_get_memory_files_excludes_non_rotation_suffix(tmp_path, monkeypatch):
|
||||
"""{repo}-old.md / {repo}-notes.md are excluded (non-numeric suffix)."""
|
||||
base = tmp_path / "github.com" / "slaid098"
|
||||
base.mkdir(parents=True)
|
||||
(base / "opencode-config.md").write_text("a\n")
|
||||
(base / "opencode-config-002.md").write_text("b\n")
|
||||
(base / "opencode-config-old.md").write_text("c\n") # non-numeric — exclude
|
||||
(base / "opencode-config-notes.md").write_text("d\n") # non-numeric — exclude
|
||||
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
|
||||
result = ps.get_memory_files()
|
||||
names = sorted(f.name for f in result)
|
||||
assert names == ["opencode-config-002.md", "opencode-config.md"]
|
||||
|
||||
|
||||
def test_get_memory_files_empty_dir(tmp_path, monkeypatch):
|
||||
"""Empty dir → empty list (not error)."""
|
||||
base = tmp_path / "github.com" / "slaid098"
|
||||
base.mkdir(parents=True)
|
||||
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
|
||||
result = ps.get_memory_files()
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_get_memory_files_no_dir(tmp_path, monkeypatch):
|
||||
"""Nonexistent dir → empty list (not error)."""
|
||||
base = tmp_path / "nonexistent" # does not exist
|
||||
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
|
||||
result = ps.get_memory_files()
|
||||
assert result == []
|
||||
|
||||
|
||||
# ── get_repo_full_name ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue