diff --git a/README.en.md b/README.en.md index 365c387..ffaed69 100644 --- a/README.en.md +++ b/README.en.md @@ -10,6 +10,7 @@ Turns your local git repositories and their GitHub issues into static HTML, and - **All issues in one place too**: open issues from every repo in a single list - **Multiple GitHub accounts**: pull issues from separate accounts, such as personal and work, and keep them apart - **Light on a phone**: static HTML with no JavaScript, so it opens quickly while you're away +- **Extensible**: a single Python module can add your own pages or an issue source. A bundled example shows Claude Code session history ([Extensions](#extensions))

Issues: open issues listed with Open and Draft badges @@ -106,6 +107,20 @@ An extension has only the functions it needs from the following. `ctx` holds the config, the scanned repositories, the output directories, the `[plugins.]` table (`ctx.options`), and `ctx.read(repo, path)` to read a file at HEAD. +### Example: show Claude Code sessions + +[`examples/plugins/sessions.py`](examples/plugins/sessions.py) reads Claude Code sessions from [ccreel](https://github.com/yktsnet/ccreel)'s `list --json` / `show --json` and adds a list page and a page per session. It needs `ccreel` on your `PATH`. Copy it next to `codeview.toml` and list it in the config to try it. + +```toml +[plugins] +modules = ["sessions"] + +[plugins.sessions] +days = 14 # how far back to show (default 30) +``` + +A `sessions` entry appears in the left menu of the top page, and each session page links to its issue and repository. The available keys are described at the top of `sessions.py`. + ## Publishing The generated files contain the code of every repository. **Keep the repository you publish from (the one holding `codeview.toml`) private, and never put the generated files anywhere public.** diff --git a/README.md b/README.md index 8bc8f85..55321b3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - **Issue も1か所にまとまる**: 全リポの未消化の Issue を1つの一覧にする - **GitHub の複数アカウントに対応**: 個人用と仕事用のように、別々のアカウントの Issue を分けて取り込める - **スマホで軽い**: JavaScript なしの静的 HTML なので、外出先でもすぐ開ける +- **拡張でページを足せる**: Python のモジュール1つで、自前のページや Issue の取り込み元を足せる。実例として、Claude Code のセッション履歴を載せる拡張を同梱している([Extensions](#extensions))

Issue 一覧。未消化の Issue が Open と Draft のバッジ付きで並ぶ @@ -106,6 +107,20 @@ gh_config_dir = "~/.config/gh-work" # 仕事のアカウントで gh auth logi `ctx` は設定・走査したリポの一覧・出力先・`[plugins.<名前>]` の表(`ctx.options`)・HEAD のファイルを読む `ctx.read(repo, path)` を持つ。 +### 例: Claude Code のセッションを載せる + +[`examples/plugins/sessions.py`](examples/plugins/sessions.py) は、[ccreel](https://github.com/yktsnet/ccreel) の `list --json` / `show --json` から Claude Code のセッションを読み、一覧と本文のページを足す拡張である。ccreel が `PATH` に要る。`codeview.toml` の隣へコピーして、設定に挙げれば試せる。 + +```toml +[plugins] +modules = ["sessions"] + +[plugins.sessions] +days = 14 # 載せる期間(既定 30) +``` + +トップの左メニューに `sessions` が加わり、セッションごとのページから Issue とリポへ飛べる。設定できるキーは `sessions.py` の先頭の説明にある。 + ## Publishing 生成物には全リポのコードが入る。**配信するリポ(`codeview.toml` を置いたリポ)は private にし、生成物を公開の場所に置かない。** diff --git a/context/structure.md b/context/structure.md index e6fa56e..00c0d8e 100644 --- a/context/structure.md +++ b/context/structure.md @@ -15,9 +15,13 @@ codeview/ cloudflare.py Cloudflare API と資格情報 cf_setup.py Pages プロジェクト・カスタムドメイン・Access を作る cf_status.py Pages プロジェクトと直近のデプロイを表示する +examples/ + plugins/ + sessions.py 拡張の実例。ccreel のセッションを一覧と本文のページにする(本体のパッケージには入らない) tests/ conftest.py 見本のリポ群・設定・偽の gh を作るフィクスチャ test_*.py build・issues・plugins・publish の外から見た振る舞い + test_example_sessions.py 例の拡張 sessions.py の振る舞い。偽の ccreel で確かめる ``` ## データの流れ diff --git a/docs/guarantees.md b/docs/guarantees.md index b70a6ea..cbc6033 100644 --- a/docs/guarantees.md +++ b/docs/guarantees.md @@ -139,6 +139,36 @@ | 旧形式の状態 | `test_publish_if_changed_republishes_over_old_state` | | 間隔の値の検証 | `test_check_every_hours_must_be_positive_integer` | +### 5. `tests/test_example_sessions.py` — 例の拡張 `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 は生の 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'
Repo{esc(r["repo"])}
') + + 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/issues/done/07_example-sessions-plugin.md b/issues/done/07_example-sessions-plugin.md new file mode 100644 index 0000000..9738241 --- /dev/null +++ b/issues/done/07_example-sessions-plugin.md @@ -0,0 +1,98 @@ +## 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 +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行で案内し、読者が自分の設定の隣へ写して試せるようにする。 +対象外: 本体(`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・`"}], + }, + {"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(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") + # 古い方のセッションが動いても、最新を超えなければ変わらない。超えれば最新として拾う + 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]: + 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)