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}")
|
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)} файл(ах)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue