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}")
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)} файл(ах)",
)

View file

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