From c03da9ac1f967a49ddbb58706847c7b7426ebb9c Mon Sep 17 00:00:00 2001 From: yktsnet Date: Fri, 23 Oct 2026 21:48:29 +0900 Subject: [PATCH 1/5] chore(issues): open 07_example-sessions-plugin.md --- issues/07_example-sessions-plugin.md | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 issues/07_example-sessions-plugin.md diff --git a/issues/07_example-sessions-plugin.md b/issues/07_example-sessions-plugin.md new file mode 100644 index 0000000..3018efd --- /dev/null +++ b/issues/07_example-sessions-plugin.md @@ -0,0 +1,46 @@ +## ccreel のセッションを載せる拡張を、例として同梱する +id: 07 +branch-slug: example-sessions-plugin +status: open +type: feat +対象: examples/plugins/sessions.py (新規), tests/test_example_sessions.py (新規), README.md, README.en.md, docs/guarantees.md, context/structure.md +内容: 拡張の口の実例として、Claude Code のセッションを [ccreel](https://github.com/yktsnet/ccreel) の JSON 出力から読み、一覧と本文のページを足す拡張を `examples/plugins/` に置く。README の Extensions 節から1行で案内し、読者が自分の設定の隣へ写して試せるようにする。 +対象外: 本体(`codeview/` 配下)の変更。パッケージへの同梱(`codeview.contrib` のような import できる形)。ccreel 側の README への言及。非公開の利用側のリポが持つ写しを、この例へ切り替えること +仮定: 例は本体のパッケージに入れず、利用者が `codeview.toml` の隣へ写して使う(拡張の口は公開 API だが、例の中身は約束にしない)。元にする実装が持つ環境変数 `CODEVIEW_LOCAL` による無効化は、利用側の運用の都合なので例からは外す +確認: `nix develop -c pytest -q` が通る。新しいテストは本体のテストと同じく、`site` フィクスチャで生成器を別プロセスで呼び、偽の `ccreel` を PATH かオプションで渡して確かめる(実物の ccreel・実際の履歴は使わない) +人が見るもの: なし + +--- + +### 保証 +- 新たに宣言する保証(例の拡張 `examples/plugins/sessions.py`): + - `[plugins.sessions]` の `config_dirs` ごとに `ccreel list --json --limit 0 --since d --config-dir ` を呼び、全部をまとめて新しい順に `public/sessions/index.html` へ一覧を書く。各セッションの本文を `ccreel show --json` から `public/sessions/.html` に書く + - 本文のページには発言と応答のテキストを出し、応答の Markdown は safe に描く(生の HTML・` +""" + +COPY_SCRIPT = r""" + +""" + +# 本文のページは public/ の外に置いて使い回す。本体は生成のたびに public/ を空にする +CACHE_INDEX = "index.json" + +# 1回の生成のあいだ、side と build と fingerprint で同じ一覧を使う +_cache: dict[str, list[dict]] = {} + + +def _opts(ctx) -> dict: + where = "config: [plugins.sessions]" + opts = {"ccreel": "ccreel", "config_dirs": ["~/.claude"], "days": 30, "every_hours": 3, **ctx.options} + + def fail(msg: str): + from codeview.config import ConfigError + + raise ConfigError(f"{where}: {msg}") + + if not isinstance(opts["ccreel"], str) or not opts["ccreel"]: + fail("'ccreel' must be a non-empty string") + dirs = opts["config_dirs"] + if not isinstance(dirs, list) or not dirs or not all(isinstance(d, str) and d for d in dirs): + fail("'config_dirs' must be a non-empty list of strings") + for key in ("days", "every_hours"): + if isinstance(opts[key], bool) or not isinstance(opts[key], int) or opts[key] <= 0: + fail(f"'{key}' must be a positive integer") + unknown = set(opts) - {"ccreel", "config_dirs", "days", "every_hours"} + if unknown: + fail(f"unknown key '{sorted(unknown)[0]}'") + return opts + + +def _ccreel(opts: dict, config_dir: str, *args: str) -> str: + cmd = [os.path.expanduser(opts["ccreel"]), *args, "--config-dir", os.path.expanduser(config_dir)] + return subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=120).stdout + + +def _ts(value: str) -> int: + return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()) + + +def _sessions(ctx) -> list[dict]: + """config dir をまとめた新しい順の一覧。ccreel が無い・失敗した config dir は警告して飛ばし、生成は止めない。""" + if "list" in _cache: + return _cache["list"] + opts = _opts(ctx) + rows: list[dict] = [] + for config_dir in opts["config_dirs"]: + found: list[dict] = [] + try: + out = _ccreel(opts, config_dir, "list", "--json", "--limit", "0", "--since", f"{opts['days']}d") + for line in out.splitlines(): + if line.strip(): + row = json.loads(line) + row["ts"] = _ts(row["updated"]) + row["started_ts"] = _ts(row["started"]) + row["config_dir"] = config_dir + found.append(row) + except (OSError, subprocess.SubprocessError, ValueError, KeyError) as e: + detail = getattr(e, "stderr", None) or str(e) + print(f"warning: sessions: ccreel list failed for {config_dir}, skip: {detail.strip()}", file=sys.stderr) + continue + rows += found + rows.sort(key=lambda r: r["ts"], reverse=True) + _cache["list"] = rows + return rows + + +def _dir_label(ctx, r: dict) -> str | None: + """config dir が複数のときだけ、どこのセッションかを示す名前を返す。""" + if len(_opts(ctx)["config_dirs"]) < 2: + return None + return Path(r["config_dir"]).name + + +def _turns(opts: dict, r: dict) -> list[dict] | None: + sid = r["id"] + try: + out = _ccreel(opts, r["config_dir"], "show", sid, "--json", "--max-chars", "20000", "--max-total", "0") + return [json.loads(line) for line in out.splitlines() if line.strip()] + except (OSError, subprocess.SubprocessError, ValueError) as e: + detail = getattr(e, "stderr", None) or str(e) + print(f"warning: sessions: ccreel show {sid} failed: {detail.strip()}", file=sys.stderr) + return None + + +def _time(ts: int) -> str: + # 本体の TIME_SCRIPT が開いた時点の相対時刻に書き換える。JS が無ければ日時のまま読める + stamp = f"{datetime.fromtimestamp(ts):%Y-%m-%d %H:%M}" + return f'' + + +def _issue_href(public: Path, repo: str, num: str) -> str | None: + """取り込み元の拡張が書いた Issue のページ。番号は先頭の 0 を無視して比べる。消化済みならページは無い。""" + for page in sorted(public.glob(f"issues/*/{repo}/*.html")): + if page.stem.isdigit() and int(page.stem) == int(num): + return "/" + page.relative_to(public).as_posix() + return None + + +def _repo_slug(ctx, name: str) -> str | None: + return next((r.slug for r in ctx.repos if r.path.name == name), None) + + +def css(ctx) -> str: + # 本体が最初に呼ぶ口なので、設定の誤りをここで ConfigError にして、public/ に触れる前に止める + _opts(ctx) + return CSS + + +def side(ctx) -> str: + rows = _sessions(ctx) + if not rows: + return "" + return ( + '

sessions

' + f'Claude Code' + f'{len(rows):,}' + ) + + +def _side(rows: list[dict]) -> str: + from codeview.layout import side_head + + esc = html.escape + counts: dict[str, int] = {} + for r in rows: + counts[r["repo"]] = counts.get(r["repo"], 0) + 1 + items = [ + 'All' + f'{len(rows):,}' + ] + # JS が無ければ絞り込まれず全件が出る + items += [ + f'' + f'{esc(repo)}{n:,}' + for repo, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) + ] + return side_head('') + '
' + "".join(items) + "
" + + +def _page_side() -> str: + """本文のページの左。件数を出すと1本増えるたびに全ページが変わり、使い回せなくなる。""" + from codeview.layout import side_head + + return ( + side_head('') + + '' + ) + + +def _row(r: dict, label: str | None) -> str: + esc = html.escape + title = r["title"] or r.get("first_prompt") or "(no title)" + title = " ".join(title.split()) + sub = [esc(r["repo"])] + if r.get("issue"): + sub.append(f"#{esc(r['issue'])}") + if label: + sub.append(esc(label)) + sub += [f"{r['prompts']} prompts", _time(r["ts"])] + q = " ".join((title, r.get("first_prompt", ""), r["repo"], label or "")).lower() + return ( + f'
' + f'' + f'
{" · ".join(sub)}
' + ) + + +def _list_main(ctx, rows: list[dict]) -> str: + return ( + '← codeview' + '

sessions

' + '' + f'{len(rows):,} sessions
' + f'
{"".join(_row(r, _dir_label(ctx, r)) for r in rows)}
' + '' + ) + + +def _turn_card(t: dict, script_collector: list[str]) -> str: + from codeview.render import render_markdown + + esc = html.escape + ts = _ts(t["time"]) if t.get("time") else None + when = _time(ts) if ts else "" + head = f'
turn {t["turn"]}{when}
' + prompt = f'
{esc(t["prompt"])}
' + + text = "\n\n".join(m["text"] for m in t.get("messages", []) if m.get("kind") == "text" and m.get("text")) + if text.strip(): + body, script = render_markdown(text.encode(), safe=True) + if 'class="mermaid"' in body: + script_collector.append(script) + reply = f'
{body}
' + else: + reply = "" + + return f'
{head}
{prompt}{reply}
' + + +def _session_main(r: dict, deps: dict, turns: list[dict] | None) -> tuple[str, str]: + esc = html.escape + title = r["title"] or r.get("first_prompt") or "(no title)" + title = " ".join(title.split()) + scripts: list[str] = [] + sub = [f"{esc(r['repo'])}"] + if r.get("issue"): + href = deps["issue_href"] + sub.append( + f'#{esc(r["issue"])}' + if href + else f"#{esc(r['issue'])}" + ) + label = deps["label"] + if label: + sub.append(f'{esc(label)}') + if r.get("branch"): + sub.append(f'{esc(r["branch"])}') + sub.append(f"{r['prompts']} prompts") + + links = [ + '
Resume' + f'ccreel resume {esc(r["id"])}' + '
' + ] + slug = deps["slug"] + if slug: + links.append(f'') + + if turns is None: + thread = '

Could not read this session

' + else: + cards = [_turn_card(t, scripts) for t in turns] + thread = f'
{"".join(cards)}
' + + main = ( + '← sessions' + f'

{esc(title)}

' + f'
{"".join(sub)}{_time(r["started_ts"])}→{_time(r["ts"])}
' + f'{thread}' + ) + return main, scripts[0] if scripts else "" + + +def _cache_dir() -> Path: + return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "codeview" / "sessions" + + +def _code_digest() -> str: + """この拡張と本体のソースの指紋。どちらかが変われば、作ったページを全部作り直す。""" + import codeview + + h = hashlib.sha256(Path(__file__).read_bytes()) + root = Path(codeview.__file__).resolve().parent + for path in sorted(root.rglob("*.py")): + if "__pycache__" not in path.parts: + h.update(str(path.relative_to(root)).encode() + b"\0" + path.read_bytes()) + return h.hexdigest() + + +def _key(r: dict, deps: dict, style: str, code: str) -> str: + """本文のページの中身を決めるものを全部入れる。同じ鍵なら同じページになる。""" + parts = [r, deps, style, code] + return hashlib.sha256(json.dumps(parts, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + + +def _load_index(cache: Path) -> dict: + try: + data = json.loads((cache / CACHE_INDEX).read_text()) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def _save_cache(cache: Path, index: dict, keep: set[str]) -> None: + """一覧から消えたセッションの分を消し、索引を書き換える。失敗しても生成は止めない。""" + try: + for f in cache.glob("*.html"): + if f.stem not in keep: + f.unlink() + tmp = cache / (CACHE_INDEX + ".tmp") + tmp.write_text(json.dumps({k: v for k, v in index.items() if k in keep})) + tmp.replace(cache / CACHE_INDEX) + except OSError as e: + print(f"warning: sessions: could not update the cache: {e}", file=sys.stderr) + + +def build(ctx) -> None: + from codeview.layout import TIME_SCRIPT, get_style_href, page + from codeview.style import write + + rows = _sessions(ctx) + if not rows: + return + opts = _opts(ctx) + write( + ctx.public / "sessions" / "index.html", + page( + "sessions", + _side(rows), + _list_main(ctx, rows), + detail=True, + extra_script=TIME_SCRIPT + FILTER_SCRIPT, + width="w-issue", + path="/sessions/index.html", + ), + ) + + cache = _cache_dir() + try: + cache.mkdir(parents=True, exist_ok=True) + except OSError as e: + print(f"warning: sessions: no cache, rebuild all: {e}", file=sys.stderr) + index = _load_index(cache) + style, code = get_style_href(), _code_digest() + plans = [] + for r in rows: + deps = { + "issue_href": _issue_href(ctx.public, r["repo"], r["issue"]) if r.get("issue") else None, + "slug": _repo_slug(ctx, r["repo"]), + "label": _dir_label(ctx, r), + } + key = _key(r, deps, style, code) + hit = index.get(r["id"]) == key and (cache / f"{r['id']}.html").is_file() + plans.append((r, deps, key, hit)) + + todo = [p for p in plans if not p[3]] + # 1本ずつ ccreel を起動するので、待ち時間を重ねる + with ThreadPoolExecutor(max_workers=8) as pool: + all_turns = list(pool.map(lambda p: _turns(opts, p[0]), todo)) + fresh = {p[0]["id"]: turns for p, turns in zip(todo, all_turns)} + + for r, deps, key, hit in plans: + path = f"/sessions/{r['id']}.html" + cached = cache / f"{r['id']}.html" + if hit: + try: + write(ctx.public / path.lstrip("/"), cached.read_text()) + continue + except OSError: + fresh[r["id"]] = _turns(opts, r) + turns = fresh[r["id"]] + main, script = _session_main(r, deps, turns) + doc = page( + f"{r['repo']} {r['title']}", + _page_side(), + main, + detail=True, + extra_script=script + TIME_SCRIPT + COPY_SCRIPT, + width="w-issue", + path=path, + ) + write(ctx.public / path.lstrip("/"), doc) + # 読めなかったセッションは使い回さず、次の生成で読み直す + if turns is not None: + try: + cached.write_text(doc) + index[r["id"]] = key + except OSError: + index.pop(r["id"], None) + else: + index.pop(r["id"], None) + _save_cache(cache, index, {r["id"] for r in rows}) + print(f"sessions: {len(rows)} pages ({len(todo)} rebuilt)", file=sys.stderr) + + +def fingerprint(ctx) -> str: + """最新の更新を every_hours ごとの枠に丸める。枠の中で何度話しても配信は増えない。""" + rows = _sessions(ctx) + if not rows: + return "sessions none" + span = _opts(ctx)["every_hours"] * 3600 + return f"sessions {max(r['ts'] for r in rows) // span}" diff --git a/tests/test_example_sessions.py b/tests/test_example_sessions.py new file mode 100644 index 0000000..d4a9901 --- /dev/null +++ b/tests/test_example_sessions.py @@ -0,0 +1,364 @@ +"""例の拡張 examples/plugins/sessions.py: ccreel の一覧と本文から、セッションのページを書く。""" + +from __future__ import annotations + +import importlib.util +import json +import posixpath +import re +import shutil +import stat +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from conftest import ROOT + +EXAMPLE = ROOT / "examples" / "plugins" / "sessions.py" + +# 関数だけを直接呼ぶテスト用。生成は site の別プロセスで確かめる +_spec = importlib.util.spec_from_file_location("sessions", EXAMPLE) +sessions = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(sessions) + +# 偽の ccreel。`list` は $FAKE_CCREEL_DIR/list-.jsonl(無ければ list.jsonl)を、 +# `show ` は .jsonl を返す。受けた引数を calls.log に残す +FAKE_CCREEL = """#!/usr/bin/env python3 +import os, sys +d = os.environ["FAKE_CCREEL_DIR"] +with open(os.path.join(d, "calls.log"), "a") as log: + log.write(" ".join(sys.argv[1:]) + "\\n") +config = os.path.basename(sys.argv[sys.argv.index("--config-dir") + 1]) +name = sys.argv[2] +if sys.argv[1] == "list": + name = "list-" + config if os.path.exists(os.path.join(d, "list-" + config + ".jsonl")) else "list" +path = os.path.join(d, name + ".jsonl") +if not os.path.exists(path): + sys.stderr.write("no session\\n") + sys.exit(1) +sys.stdout.write(open(path).read()) +""" + +# alpha の Issue を1本返す取り込み元。設定の隣に `closed` というファイルがあれば消化済みとして返さない +SOURCE = ''' +from pathlib import Path + +from codeview.issues import Issue + + +def issues(ctx, account, repos): + if (Path(__file__).parent / "closed").exists(): + return [] + return [ + Issue( + account=account.name, repo=repo.path.name, slug=repo.slug, ref="01", + title="first issue", kind="", state="Open", updated=1, + ) + for repo in repos + if repo.path.name == "alpha" + ] +''' + +SESSIONS = [ + { + "id": "aaaa1111-0000-0000-0000-000000000000", + "repo": "alpha", + "branch": "claude/1-first", + "issue": "1", + "title": "最初の作業", + "first_prompt": "main.py を直したい", + "prompts": 3, + "started": "2026-03-01T00:00:00Z", + "updated": "2026-03-01T01:00:00Z", + }, + { + "id": "bbbb2222-0000-0000-0000-000000000000", + "repo": "beta", + "title": "別の相談", + "first_prompt": "beta について", + "prompts": 2, + "started": "2026-02-01T00:00:00Z", + "updated": "2026-02-01T00:30:00Z", + }, +] + +TURNS = [ + { + "turn": 1, + "time": "2026-03-01T00:00:00Z", + "prompt": "main.py を直したい\n2行目", + "messages": [{"kind": "text", "text": "**直します** "}], + }, + {"turn": 2, "time": "2026-03-01T00:10:00Z", "prompt": "続けて", "messages": []}, +] + + +def _first(name: str = "id") -> str: + return SESSIONS[0][name] + + +@pytest.fixture +def ccreel(site, tmp_path: Path) -> Path: + d = tmp_path / "ccreel" + d.mkdir() + (d / "list.jsonl").write_text("".join(json.dumps(s) + "\n" for s in SESSIONS)) + # beta のセッションは show に失敗させる(本文のファイルを置かない) + (d / f"{_first()}.jsonl").write_text("".join(json.dumps(t) + "\n" for t in TURNS)) + exe = tmp_path / "bin" / "ccreel" + exe.write_text(FAKE_CCREEL) + exe.chmod(exe.stat().st_mode | stat.S_IEXEC) + site.env["FAKE_CCREEL_DIR"] = str(d) + return d + + +def _use_sessions(site, extra: str = "") -> None: + """例の拡張と Issue の取り込み元を設定の隣に置き、alpha を持つアカウントの取り込み元にする。""" + shutil.copy(EXAMPLE, site.config.parent / "sessions.py") + (site.config.parent / "issuesrc.py").write_text(SOURCE) + site.write_config( + site.config.read_text().replace('groups = ["own"]\n', 'groups = ["own"]\nissues = "issuesrc"\n') + + '\n[plugins]\nmodules = ["issuesrc", "sessions"]\n' + + extra + ) + + +def _page(public: Path, session: dict) -> str: + return (public / "sessions" / f"{session['id']}.html").read_text() + + +def test_list_and_session_pages(site, ccreel): + _use_sessions(site) + public, _ = site.build() + listing = (public / "sessions" / "index.html").read_text() + assert "最初の作業" in listing and "別の相談" in listing + assert 'data-repo="alpha"' in listing and 'data-issue="1"' in listing + # 新しい順 + assert listing.index("最初の作業") < listing.index("別の相談") + page = _page(public, SESSIONS[0]) + assert "main.py を直したい\n2行目" in page + assert "続けて" in page + assert f"ccreel resume {_first()}" in page + calls = (ccreel / "calls.log").read_text().splitlines() + assert f"list --json --limit 0 --since 30d --config-dir {site.home}/.claude" in calls + assert any(c.startswith(f"show {_first()} --json ") for c in calls) + + +def test_reply_markdown_is_safe(site, ccreel): + _use_sessions(site) + public, _ = site.build() + page = _page(public, SESSIONS[0]) + assert "直します" in page + # 応答の生の HTML は実行させない + assert "" not in page + + +def test_links_to_issue_and_repo(site, ccreel): + _use_sessions(site) + public, _ = site.build() + page = _page(public, SESSIONS[0]) + # Issue 01 は ccreel では 1 と出る。先頭の 0 を無視して結ぶ + assert 'href="../issues/me/alpha/01.html"' in page + assert 'href="../alpha/index.html"' in page + # Issue の無いセッションには Issue のリンクが出ない + other = _page(public, SESSIONS[1]) + assert "issues/" not in other.split('class="isub"')[1].split("")[0] + assert 'href="../beta/index.html"' in other + + +def test_unlinked_when_issue_or_repo_is_missing(site, ccreel): + # 一覧に無い Issue 番号は文字だけ。走査対象に無いリポはリンクにしない + rows = [dict(SESSIONS[0], issue="9"), dict(SESSIONS[1], repo="elsewhere", issue="2")] + (ccreel / "list.jsonl").write_text("".join(json.dumps(s) + "\n" for s in rows)) + _use_sessions(site) + public, _ = site.build() + page = _page(public, SESSIONS[0]) + assert "#9" in page and "issues/me/alpha/09.html" not in page + other = _page(public, SESSIONS[1]) + assert "#2" in other and "../elsewhere/" not in other and 'class="k">Repo<' not in other + + +def test_unreadable_session_still_gets_a_page(site, ccreel): + _use_sessions(site) + public, proc = site.build() + # 読めなかったセッションもページは作り、一覧のリンクを切らさない + assert "Could not read this session" in _page(public, SESSIONS[1]) + assert "Could not read this session" not in _page(public, SESSIONS[0]) + assert f"ccreel show {SESSIONS[1]['id']} failed" in proc.stderr + + +def test_top_menu_links_sessions_without_titles(site, ccreel): + _use_sessions(site) + public, _ = site.build() + top = (public / "index.html").read_text() + assert 'href="sessions/index.html"' in top + assert '2' in top + assert "最初の作業" not in top and "別の相談" not in top + + +def test_passes_config_dir_and_period(site, ccreel): + _use_sessions(site, '\n[plugins.sessions]\nconfig_dirs = ["~/.claude-x"]\ndays = 7\n') + public, _ = site.build() + calls = (ccreel / "calls.log").read_text().splitlines() + first = next(c for c in calls if c.startswith("list ")) + assert "--since 7d" in first + assert f"--config-dir {site.home}/.claude-x" in first + # config dir が1つなら、どこのセッションかは出さない + assert ".claude-x" not in (public / "sessions" / "index.html").read_text() + + +def test_merges_config_dirs(site, ccreel): + work = { + "id": "cccc3333-0000-0000-0000-000000000000", + "repo": "gamma", + "title": "仕事の相談", + "first_prompt": "gamma の件", + "prompts": 4, + "started": "2026-02-15T00:00:00Z", + "updated": "2026-02-15T00:30:00Z", + } + (ccreel / "list-.claude-work.jsonl").write_text(json.dumps(work) + "\n") + _use_sessions(site, '\n[plugins.sessions]\nconfig_dirs = ["~/.claude", "~/.claude-work"]\n') + public, _ = site.build() + listing = (public / "sessions" / "index.html").read_text() + # 更新の新しい順に混ぜる + order = [listing.index(t) for t in ("最初の作業", "仕事の相談", "別の相談")] + assert order == sorted(order) + assert ".claude-work" in listing + calls = (ccreel / "calls.log").read_text().splitlines() + assert f"show {work['id']} --json --max-chars 20000 --max-total 0 --config-dir {site.home}/.claude-work" in calls + assert (public / "sessions" / f"{work['id']}.html").is_file() + + +def test_build_survives_one_failing_config_dir(site, ccreel): + _use_sessions(site, '\n[plugins.sessions]\nconfig_dirs = ["~/.claude", "~/.claude-broken"]\n') + (ccreel / "list-.claude-broken.jsonl").write_text("not json\n") + public, proc = site.build() + assert "最初の作業" in (public / "sessions" / "index.html").read_text() + assert "warning: sessions: ccreel list failed for ~/.claude-broken" in proc.stderr + + +def test_build_survives_missing_ccreel(site, ccreel): + _use_sessions(site, '\n[plugins.sessions]\nccreel = "/nonexistent/ccreel"\n') + public, proc = site.build() + assert (public / "index.html").is_file() + assert not (public / "sessions").exists() + assert "warning: sessions" in proc.stderr + + +@pytest.mark.parametrize( + "extra, message", + [ + ("everyhours = 3", "unknown key 'everyhours'"), + ('days = "7"', "'days' must be a positive integer"), + ("every_hours = 0", "'every_hours' must be a positive integer"), + ("config_dirs = []", "'config_dirs' must be a non-empty list of strings"), + ('ccreel = ""', "'ccreel' must be a non-empty string"), + ], +) +def test_rejects_bad_options(site, ccreel, extra, message): + _use_sessions(site, f"\n[plugins.sessions]\n{extra}\n") + _, proc = site.build(check=False) + assert proc.returncode != 0 + assert "error:" in proc.stderr and message in proc.stderr + + +def test_fingerprint_moves_only_across_windows(): + def at(updated: str) -> str: + sessions._cache.clear() + sessions._cache["list"] = [{"ts": sessions._ts(updated)}] + return sessions.fingerprint(SimpleNamespace(options={"every_hours": 3})) + + assert at("2026-03-01T00:10:00Z") == at("2026-03-01T02:50:00Z") + assert at("2026-03-01T02:50:00Z") != at("2026-03-01T03:10:00Z") + sessions._cache.clear() + + +def _shows(ccreel: Path) -> list[str]: + log = ccreel / "calls.log" + lines = log.read_text().splitlines() if log.exists() else [] + log.unlink(missing_ok=True) + return [line.split()[1] for line in lines if line.startswith("show ")] + + +def _cache(site) -> Path: + return Path(site.env["XDG_CACHE_HOME"]) / "codeview" / "sessions" + + +def test_second_build_reuses_unchanged_pages(site, ccreel): + _use_sessions(site) + public, _ = site.build() + first = _page(public, SESSIONS[0]) + assert set(_shows(ccreel)) == {SESSIONS[0]["id"], SESSIONS[1]["id"]} + public, proc = site.build() + # 読めた方は読み直さない。読めなかった方は使い回さず、毎回読み直す + assert _shows(ccreel) == [SESSIONS[1]["id"]] + assert _page(public, SESSIONS[0]) == first + assert "sessions: 2 pages (1 rebuilt)" in proc.stderr + + +def test_updated_session_is_rebuilt(site, ccreel): + _use_sessions(site) + site.build() + _shows(ccreel) + rows = [dict(s) for s in SESSIONS] + rows[0]["updated"] = "2026-03-02T00:00:00Z" + turns = TURNS + [{"turn": 3, "time": "2026-03-02T00:00:00Z", "prompt": "追記した発言", "messages": []}] + (ccreel / "list.jsonl").write_text("".join(json.dumps(s) + "\n" for s in rows)) + (ccreel / f"{_first()}.jsonl").write_text("".join(json.dumps(t) + "\n" for t in turns)) + public, _ = site.build() + assert _first() in _shows(ccreel) + assert "追記した発言" in _page(public, SESSIONS[0]) + + +def test_issue_link_follows_issue_state(site, ccreel): + _use_sessions(site) + site.build() + _shows(ccreel) + # Issue 01 が消化済みになるとページが消えるので、使い回さずにリンクを外す + (site.config.parent / "closed").write_text("") + public, _ = site.build() + assert _first() in _shows(ccreel) + page = _page(public, SESSIONS[0]) + assert "issues/me/alpha/01.html" not in page + assert "#1" in page + + +def test_expired_sessions_leave_the_cache(site, ccreel): + _use_sessions(site) + site.build() + assert (_cache(site) / f"{_first()}.html").is_file() + (ccreel / "list.jsonl").write_text(json.dumps(SESSIONS[1]) + "\n") + public, _ = site.build() + assert not (_cache(site) / f"{_first()}.html").exists() + assert not (public / "sessions" / f"{_first()}.html").exists() + assert _first() not in json.loads((_cache(site) / "index.json").read_text()) + + +def test_session_page_side_has_no_counts(site, ccreel): + _use_sessions(site) + public, _ = site.build() + side = _page(public, SESSIONS[0]).split('")[0] + assert "All sessions" in side + assert 'class="m"' not in side + + +_LINK = re.compile(r"""<[^>]+?\s(?:href|src)="([^"]*)\"""") + + +def test_links_are_relative_and_resolve(site, ccreel): + _use_sessions(site) + public, _ = site.build() + pages = list((public / "sessions").glob("*.html")) + assert len(pages) == 3 + # 2回目は使い回したページを書き出す。どちらも同じ条件を満たす + site.build() + for page in pages: + here = posixpath.dirname(page.relative_to(public).as_posix()) + for target in _LINK.findall(page.read_text()): + if target.startswith(("http://", "https://", "#", "mailto:")): + continue + assert not target.startswith("/"), (page.name, target) + path = target.split("#")[0].split("?")[0] + resolved = posixpath.normpath(posixpath.join(here, path)) + assert (public / resolved).is_file(), (page.name, target) From 8c9c44415377c3cca6ccc9d1f242b299ca05a351 Mon Sep 17 00:00:00 2001 From: yktsnet Date: Fri, 23 Oct 2026 22:02:05 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20=E4=BE=8B=E3=81=AE=E6=8B=A1?= =?UTF-8?q?=E5=BC=B5=E3=81=AE=E6=8C=87=E7=B4=8B=E3=82=92=E3=80=81=E5=88=A5?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=BB=E3=82=B9=E3=81=A7=E6=9C=80=E6=96=B0?= =?UTF-8?q?=E3=81=AE=E6=9B=B4=E6=96=B0=E3=82=92=E5=8F=96=E3=82=8B=E3=81=93?= =?UTF-8?q?=E3=81=A8=E3=81=BE=E3=81=A7=E7=A2=BA=E3=81=8B=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 変更内容 確認者の指摘を直した。`fingerprint()` のテストが、モジュールを直接読んで `_cache` を書き換えていたため、`site.fingerprint()` で別プロセスから呼ぶ形に替えた。セッションを2本にし、古い方が動いても最新を超えなければ変わらず、超えれば最新として拾うことを確かめる。`every_hours` を変えた枠も確かめる。 ## 保証 - fingerprint は最新の更新時刻を every_hours の枠に丸める → tests/test_example_sessions.py: test_fingerprint_moves_only_across_windows, test_fingerprint_follows_every_hours ## 静的確認結果 - `nix develop -c pytest -q`: 全件通過 --- docs/guarantees.md | 2 +- tests/test_example_sessions.py | 34 +++++++++++++++++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/guarantees.md b/docs/guarantees.md index b8ba8d4..cbc6033 100644 --- a/docs/guarantees.md +++ b/docs/guarantees.md @@ -162,7 +162,7 @@ | `list` の失敗・`ccreel` が無いときは警告して続ける | `test_build_survives_one_failing_config_dir`、`test_build_survives_missing_ccreel` | | トップの入口と件数 | `test_top_menu_links_sessions_without_titles` | | 設定の誤り | `test_rejects_bad_options` | -| 指紋は `every_hours` の枠で動く | `test_fingerprint_moves_only_across_windows` | +| 指紋は最新の更新時刻を `every_hours` の枠に丸めて動く | `test_fingerprint_moves_only_across_windows`、`test_fingerprint_follows_every_hours` | | 変わらないセッションはページを使い回す(件数を左に出さないのは、セッションが1本増えても、ほかのページの内容が変わらないようにするため) | `test_second_build_reuses_unchanged_pages`、`test_session_page_side_has_no_counts` | | 更新されたセッションは作り直す | `test_updated_session_is_rebuilt` | | Issue の状態が変わったら作り直す | `test_issue_link_follows_issue_state` | diff --git a/tests/test_example_sessions.py b/tests/test_example_sessions.py index d4a9901..61d0375 100644 --- a/tests/test_example_sessions.py +++ b/tests/test_example_sessions.py @@ -2,14 +2,12 @@ from __future__ import annotations -import importlib.util import json import posixpath import re import shutil import stat from pathlib import Path -from types import SimpleNamespace import pytest @@ -17,11 +15,6 @@ EXAMPLE = ROOT / "examples" / "plugins" / "sessions.py" -# 関数だけを直接呼ぶテスト用。生成は site の別プロセスで確かめる -_spec = importlib.util.spec_from_file_location("sessions", EXAMPLE) -sessions = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(sessions) - # 偽の ccreel。`list` は $FAKE_CCREEL_DIR/list-.jsonl(無ければ list.jsonl)を、 # `show ` は .jsonl を返す。受けた引数を calls.log に残す FAKE_CCREEL = """#!/usr/bin/env python3 @@ -263,15 +256,30 @@ def test_rejects_bad_options(site, ccreel, extra, message): assert "error:" in proc.stderr and message in proc.stderr -def test_fingerprint_moves_only_across_windows(): - def at(updated: str) -> str: - sessions._cache.clear() - sessions._cache["list"] = [{"ts": sessions._ts(updated)}] - return sessions.fingerprint(SimpleNamespace(options={"every_hours": 3})) +def test_fingerprint_moves_only_across_windows(site, ccreel): + _use_sessions(site) + + def at(newest: str, older: str = "2026-02-01T00:30:00Z") -> str: + rows = [dict(SESSIONS[0], updated=newest), dict(SESSIONS[1], updated=older)] + (ccreel / "list.jsonl").write_text("".join(json.dumps(r) + "\n" for r in rows)) + return site.fingerprint() + # 既定の every_hours は 3。最新の更新が同じ枠(00:00〜03:00)の中なら変わらない assert at("2026-03-01T00:10:00Z") == at("2026-03-01T02:50:00Z") assert at("2026-03-01T02:50:00Z") != at("2026-03-01T03:10:00Z") - sessions._cache.clear() + # 古い方のセッションが動いても、最新を超えなければ変わらない。超えれば最新として拾う + assert at("2026-03-01T01:00:00Z") == at("2026-03-01T01:00:00Z", older="2026-02-20T00:00:00Z") + assert at("2026-03-01T01:00:00Z") != at("2026-03-01T01:00:00Z", older="2026-03-05T00:00:00Z") + + +def test_fingerprint_follows_every_hours(site, ccreel): + _use_sessions(site, "\n[plugins.sessions]\nevery_hours = 24\n") + (ccreel / "list.jsonl").write_text(json.dumps(dict(SESSIONS[0], updated="2026-03-01T03:10:00Z")) + "\n") + inside = site.fingerprint() + (ccreel / "list.jsonl").write_text(json.dumps(dict(SESSIONS[0], updated="2026-03-01T23:50:00Z")) + "\n") + assert site.fingerprint() == inside + (ccreel / "list.jsonl").write_text(json.dumps(dict(SESSIONS[0], updated="2026-03-02T00:10:00Z")) + "\n") + assert site.fingerprint() != inside def _shows(ccreel: Path) -> list[str]: From 645ada3557518c974a5eae476e04f18bc95a44e9 Mon Sep 17 00:00:00 2001 From: yktsnet Date: Fri, 23 Oct 2026 22:04:25 +0900 Subject: [PATCH 4/5] chore(issues): close 07 --- issues/{ => done}/07_example-sessions-plugin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename issues/{ => done}/07_example-sessions-plugin.md (99%) diff --git a/issues/07_example-sessions-plugin.md b/issues/done/07_example-sessions-plugin.md similarity index 99% rename from issues/07_example-sessions-plugin.md rename to issues/done/07_example-sessions-plugin.md index 3018efd..2a9fafd 100644 --- a/issues/07_example-sessions-plugin.md +++ b/issues/done/07_example-sessions-plugin.md @@ -1,7 +1,7 @@ ## ccreel のセッションを載せる拡張を、例として同梱する id: 07 branch-slug: example-sessions-plugin -status: open +status: close type: feat 対象: examples/plugins/sessions.py (新規), tests/test_example_sessions.py (新規), README.md, README.en.md, docs/guarantees.md, context/structure.md 内容: 拡張の口の実例として、Claude Code のセッションを [ccreel](https://github.com/yktsnet/ccreel) の JSON 出力から読み、一覧と本文のページを足す拡張を `examples/plugins/` に置く。README の Extensions 節から1行で案内し、読者が自分の設定の隣へ写して試せるようにする。 From 6d3a469de37e4c38d4b17367a9f7a1f35e1c0250 Mon Sep 17 00:00:00 2001 From: yktsnet Date: Fri, 23 Oct 2026 22:04:48 +0900 Subject: [PATCH 5/5] chore(issues): record PR for 07 --- issues/done/07_example-sessions-plugin.md | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/issues/done/07_example-sessions-plugin.md b/issues/done/07_example-sessions-plugin.md index 2a9fafd..9738241 100644 --- a/issues/done/07_example-sessions-plugin.md +++ b/issues/done/07_example-sessions-plugin.md @@ -1,3 +1,55 @@ +## PR記録: feat: ccreel のセッションを載せる拡張を、例として同梱する +issue: 07 (07_example-sessions-plugin.md) +PR: https://github.com/yktsnet/codeview/pull/24 + +## 変更内容 +- `examples/plugins/sessions.py` を追加した。[ccreel](https://github.com/yktsnet/ccreel) の `list --json` / `show --json` から Claude Code のセッションを読み、一覧(`sessions/index.html`)と本文のページを足す拡張の例である。本体のパッケージには入れず、利用者が `codeview.toml` の隣へコピーして使う +- 元にした利用側の実装から `CODEVIEW_LOCAL` による無効化を外し、docstring に使い方と ccreel が要ることを足した +- 設定の誤りは、本体が最初に呼ぶ `css()` の口で検証する。生成を始める前に `error:` で止めるため +- `tests/test_example_sessions.py` を追加した。偽の `ccreel` と、Issue を返す小さな取り込み元をテストの中に置き、`site` フィクスチャで生成器を別プロセスで呼ぶ +- README.md / README.en.md: 冒頭の特長リストに拡張の1行を足した。Extensions 節には「例: Claude Code のセッションを載せる」の小節を足し、設定例を載せた(Issue の指示より広いが、user の依頼で採った) +- docs/guarantees.md に節 5 を足し、「About」の対象に例の拡張を足した。context/structure.md の構成の木に2ファイルを足した +- 本体(`codeview/`)は変えていない + +## 保証 +| 保証 | テスト(tests/test_example_sessions.py) | +|---|---| +| list の呼び出しと一覧、本文を show から書く | test_list_and_session_pages, test_passes_config_dir_and_period, test_merges_config_dirs | +| 応答の Markdown は生の HTML を出さずに描く / resume の行 | test_reply_markdown_is_safe, test_list_and_session_pages | +| Issue(先頭の 0 を無視)とリポへのリンク、先が無ければリンクにしない | test_links_to_issue_and_repo, test_unlinked_when_issue_or_repo_is_missing | +| show に失敗したセッションのページ | test_unreadable_session_still_gets_a_page | +| ccreel が無い・list の失敗は警告して続ける | test_build_survives_one_failing_config_dir, test_build_survives_missing_ccreel | +| トップの入口と件数、題は出さない | test_top_menu_links_sessions_without_titles | +| 設定の誤り | test_rejects_bad_options | +| 指紋は最新の更新時刻を every_hours の枠に丸める | test_fingerprint_moves_only_across_windows, test_fingerprint_follows_every_hours | +| ページの使い回し・更新・Issue の状態・一覧から消えた分 | test_second_build_reuses_unchanged_pages, test_session_page_side_has_no_counts, test_updated_session_is_rebuilt, test_issue_link_follows_issue_state, test_expired_sessions_leave_the_cache | +| href / src は相対パスで実在する | test_links_are_relative_and_resolve | + +維持する保証(台帳 §1)は本体に触れないため、既存のテストがそのまま通る。 + +## 静的確認結果 +- `nix develop -c pytest -q`: 72 passed +- 変更したファイル: README.en.md, README.md, context/structure.md, docs/guarantees.md, examples/plugins/sessions.py, tests/test_example_sessions.py, issues/done/07_example-sessions-plugin.md + +## 確認者の判定 +| 確認・保証 | 判定 | +|---|---| +| `nix develop -c pytest -q` が通る(72 passed) | 合 | +| 新しいテストは `site` から生成器を別プロセスで呼び、偽の ccreel を使う | 合 | +| list の呼び出し・新しい順・show から本文 | 合 | +| 発言と応答・safe に描く・resume の行 | 合 | +| Issue とリポのリンク・無ければリンクにしない | 合 | +| show の失敗・ccreel が無い・list の失敗で止まらない | 合 | +| トップの入口と件数・題を出さない | 合 | +| 未知のキーと型の誤りで `error:` を出し非0 | 合 | +| fingerprint は最新の更新時刻を every_hours の枠に丸める | 合 | +| 使い回し・作り直し・キャッシュの削除 | 合 | +| href / src が相対で実在する先を指す | 合 | +| 本体の台帳 §1 の維持 | 合 | +| `docs/guarantees.md` の更新 | 合 | + +--- + ## ccreel のセッションを載せる拡張を、例として同梱する id: 07 branch-slug: example-sessions-plugin