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:
Sergey 2026-08-03 19:10:18 +03:00 committed by GitHub
parent 1e9ee49a1d
commit 596aa9b18a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 166 additions and 40 deletions

View file

@ -173,13 +173,23 @@ def parse_remote_url(url: str) -> tuple[str, str, str]:
raise ValueError(f"Cannot parse remote URL: {url}") raise ValueError(f"Cannot parse remote URL: {url}")
def get_memory_file_path() -> Path: def _resolve_memory_base() -> tuple[Path, str]:
"""Derive memory file path from ``git remote get-url origin``.""" """Derive (memory_dir, repo_name) from ``git remote get-url origin``."""
rc, out, err = run_cmd(["git", "remote", "get-url", "origin"]) rc, out, err = run_cmd(["git", "remote", "get-url", "origin"])
if rc != 0: if rc != 0:
raise RuntimeError(f"Cannot get git remote URL: {err.strip()}") raise RuntimeError(f"Cannot get git remote URL: {err.strip()}")
host, org, repo = parse_remote_url(out.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 @functools.cache
@ -458,24 +468,24 @@ def check_merge(pr_number: int) -> PhaseResult:
def check_memory(pr_number: int) -> PhaseResult: def check_memory(pr_number: int) -> PhaseResult:
"""Phase 7: MEMORY — PR#N distilled into memory file.""" """Phase 7: MEMORY — PR#N distilled into memory file."""
try: try:
memory_file = get_memory_file_path() files = get_memory_files()
except (RuntimeError, ValueError) as exc: except (RuntimeError, ValueError) as exc:
return PhaseResult(PhaseStatus.NOT_DONE, str(exc)) return PhaseResult(PhaseStatus.NOT_DONE, str(exc))
if not memory_file.exists(): if not files:
return PhaseResult( return PhaseResult(
PhaseStatus.NOT_DONE, PhaseStatus.NOT_DONE,
f"memory file не существует: {memory_file.name}", "memory files не найдены",
) )
content = memory_file.read_text()
pattern = f"PR#{pr_number}" pattern = f"PR#{pr_number}"
if pattern in content: for f in files:
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {memory_file.name}") if pattern in f.read_text():
return PhaseResult(PhaseStatus.DONE, f"{pattern} в {f.name}")
return PhaseResult( return PhaseResult(
PhaseStatus.NOT_DONE, PhaseStatus.NOT_DONE,
f"{pattern} не найден в {memory_file.name}", f"{pattern} не найден в {len(files)} файл(ах)",
) )

View file

@ -13,7 +13,9 @@ without extra boilerplate in every test.
import importlib.util import importlib.util
import json import json
import os
import sys import sys
import time
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -488,44 +490,42 @@ def test_check_merge_not_done_pr_missing(monkeypatch):
def test_check_memory_done(tmp_path, monkeypatch): def test_check_memory_done(tmp_path, monkeypatch):
memory_file = tmp_path / "opencode.md" memory_file = tmp_path / "opencode.md"
memory_file.write_text("- [2026-07-19, PR#46] test entry\n") 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) result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.DONE assert result.status == ps.PhaseStatus.DONE
assert "PR#46" in result.detail assert "PR#46" in result.detail
def test_check_memory_not_done_no_file(tmp_path, monkeypatch): def test_check_memory_not_done_no_files(tmp_path, monkeypatch):
memory_file = tmp_path / "opencode.md" monkeypatch.setattr(ps, "get_memory_files", lambda: [])
monkeypatch.setattr(ps, "get_memory_file_path", lambda: memory_file)
result = ps.check_memory(46) result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.NOT_DONE 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): def test_check_memory_not_done_no_pattern(tmp_path, monkeypatch):
memory_file = tmp_path / "opencode.md" memory_file = tmp_path / "opencode.md"
memory_file.write_text("- some other entry\n") 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) result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.NOT_DONE assert result.status == ps.PhaseStatus.NOT_DONE
assert "не найден" in result.detail assert "не найден" in result.detail
def test_check_memory_not_done_remote_error(monkeypatch): def test_check_memory_not_done_remote_error(monkeypatch):
monkeypatch.setattr( def raise_err():
ps, raise RuntimeError("remote error")
"get_memory_file_path",
lambda: (_ for _ in ()).throw(RuntimeError("remote error")), monkeypatch.setattr(ps, "get_memory_files", raise_err)
)
result = ps.check_memory(46) result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.NOT_DONE assert result.status == ps.PhaseStatus.NOT_DONE
assert "remote error" in result.detail 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( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
@ -535,32 +535,33 @@ def test_get_memory_file_path(monkeypatch):
} }
), ),
) )
path = ps.get_memory_file_path() result_dir, result_repo = ps._resolve_memory_base()
assert path == ps.MEMORY_DIR / "github.com" / "slaid098" / "opencode-config.md" 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( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd({("git", "remote"): (0, "git@github.com:slaid098/opencode-config.git\n", "")}), mock_run_cmd({("git", "remote"): (0, "git@github.com:slaid098/opencode-config.git\n", "")}),
) )
path = ps.get_memory_file_path() result_dir, result_repo = ps._resolve_memory_base()
assert path.name == "opencode-config.md" assert result_repo == "opencode-config"
assert "github.com" in str(path) assert "github.com" in str(result_dir)
assert "slaid098" in str(path) assert "slaid098" in str(result_dir)
def test_get_memory_file_path_with_userinfo(monkeypatch): def test_resolve_memory_base_with_userinfo(monkeypatch):
"""get_memory_file_path строит правильный путь когда remote URL содержит userinfo. """_resolve_memory_base строит правильный путь когда remote URL содержит userinfo.
Regression for PR#53/ADR-022: after ``git config url.insteadOf``, ``git Regression for PR#53/ADR-022: after ``git config url.insteadOf``, ``git
remote get-url origin`` returns remote get-url origin`` returns
``https://x-access-token:TOKEN@github.com/slaid098/opencode-config.git``. ``https://x-access-token:TOKEN@github.com/slaid098/opencode-config.git``.
Without the regex fix, ``get_memory_file_path`` built Without the regex fix, ``_resolve_memory_base`` built
``repos/x-access-token:TOKEN@github.com/slaid098/opencode-config.md`` ``repos/x-access-token:TOKEN@github.com/slaid098`` (nonexistent)
(nonexistent) ``check_memory`` returned NOT_DONE. With the fix, the ``get_memory_files`` found no files ``check_memory`` returned NOT_DONE.
path is the same as for the plain URL. With the fix, the path is the same as for the plain URL.
""" """
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
@ -575,11 +576,126 @@ def test_get_memory_file_path_with_userinfo(monkeypatch):
} }
), ),
) )
path = ps.get_memory_file_path() result_dir, result_repo = ps._resolve_memory_base()
assert path == ps.MEMORY_DIR / "github.com" / "slaid098" / "opencode-config.md" assert result_repo == "opencode-config"
assert result_dir == ps.MEMORY_DIR / "github.com" / "slaid098"
# userinfo must NOT leak into the path # userinfo must NOT leak into the path
assert "x-access-token" not in str(path) assert "x-access-token" not in str(result_dir)
assert "github_pat_TOKEN" not in str(path) 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 ─────────────────────────────────────────────────────── # ── get_repo_full_name ───────────────────────────────────────────────────────