diff --git a/README.en.md b/README.en.md index 9a33f68..365c387 100644 --- a/README.en.md +++ b/README.en.md @@ -61,7 +61,7 @@ The config is TOML, read from the `CODEVIEW_CONFIG` environment variable, or `co |---|---| | `[[roots]]` | Where to scan. A `group`, plus exactly one of `path` (that directory is one repository) or `children` (every git repository directly under it). The order is the order of the groups in the left menu on the top page. Within a group, repositories with more files come first | | `[scan]` | `skip_dirs` (directory names not to scan), `exclude_prefixes` (path prefixes not to output), `skip_exts` (extensions not to output). To add exclusions for one repository, put `skip_exts` and `exclude_prefixes` in `[scan.repos.""]` (added to the global values; the slug is the repository's path in the output) | -| `[[accounts]]` | The unit issues are grouped by; 1 or 2 of them. `name`, `hosts` (hosts of the `origin` remote), `groups` (the fallback when there is no remote), `issues` (the default `github`, or the module name of an extension that returns issues), `gh_config_dir` (the gh config dir used when `issues = "github"`) | +| `[[accounts]]` | The unit issues are grouped by; 1 or 2 of them. `name`, `hosts` (hosts of the `origin` remote), `groups` (the fallback when there is no remote), `issues` (the default `github`, or the module name of an extension that returns issues), `gh_config_dir` (the gh config dir used when `issues = "github"`), `check_every_hours` (how often, in whole hours, `publish --if-changed` looks for changes in this account's repos; checked on every run when omitted) | | `[publish]` | The `remote` (default `origin`) and `branch` (default `deploy`) to publish to | | `[plugins]` | Extensions to load (`modules`). Per-extension settings go in `[plugins.]` | @@ -134,6 +134,8 @@ codeview publish --if-changed # do nothing if nothing changed since the last ru `publish` creates a single parentless commit holding only `public/` and `functions/`, and force-pushes it to the `[publish]` branch. It does not touch the working tree or the index. Cloudflare Pages watches that branch, so the push triggers the deployment. Day-to-day publishing needs no Cloudflare API token. +`--if-changed` looks at commits in the scanned repos and in the publishing repo, the codeview version, and the extensions' `fingerprint()`. A change in GitHub issues alone does not publish; the issues go out with the next publish triggered by any of those changes. + ### One-time: Cloudflare setup ```sh diff --git a/README.md b/README.md index 492e81a..8bc8f85 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ codeview serve # http://localhost:8787 で確認する |---|---| | `[[roots]]` | 走査する置き場。`group` と、`path`(そのディレクトリが1つのリポ)か `children`(直下の git リポすべて)のどちらか一方。並び順がトップの左メニューのグループの順になる。グループの中は、ファイル数の多いリポから並ぶ | | `[scan]` | `skip_dirs`(走査しないディレクトリ名)・`exclude_prefixes`(出力しないパスの接頭辞)・`skip_exts`(出力しない拡張子)。リポごとに足すときは `[scan.repos.""]` に `skip_exts` と `exclude_prefixes` を書く(全体の値に足される。slug はページのパスと同じ) | -| `[[accounts]]` | Issue をまとめる単位で、1〜2個。`name`・`hosts`(`origin` の remote のホスト)・`groups`(remote が無いときの決め手)・`issues`(既定の `github` か、Issue を返す拡張のモジュール名)・`gh_config_dir`(`issues = "github"` のときに使う gh の config dir) | +| `[[accounts]]` | Issue をまとめる単位で、1〜2個。`name`・`hosts`(`origin` の remote のホスト)・`groups`(remote が無いときの決め手)・`issues`(既定の `github` か、Issue を返す拡張のモジュール名)・`gh_config_dir`(`issues = "github"` のときに使う gh の config dir)・`check_every_hours`(`publish --if-changed` がこのアカウントのリポの変化を見る間隔。時間単位の正の整数。省けば毎回見る) | | `[publish]` | 配信先の `remote`(既定 `origin`)と `branch`(既定 `deploy`) | | `[plugins]` | 読み込む拡張(`modules`)。拡張ごとの設定は `[plugins.<モジュール名の末尾>]` に置く | @@ -134,6 +134,8 @@ codeview publish --if-changed # 前回から変化が無ければ何もしな `publish` は `public/` と `functions/` だけを載せた親の無い単一コミットを作り、`[publish]` の branch へ force push する。作業ツリーと index には触らない。Cloudflare Pages はそのブランチを見ており、push が配信を起こす。日常の配信に Cloudflare の API トークンは要らない。 +`--if-changed` が見る変化は、走査対象のリポと配信するリポのコミット、本体の版、拡張の `fingerprint()` である。GitHub の Issue だけが動いても配信しない。Issue は、次にコミットなどの変化で配信するときに載る。 + ### 初回だけ: Cloudflare の設定 ```sh diff --git a/codeview/config.py b/codeview/config.py index 5400804..c221544 100644 --- a/codeview/config.py +++ b/codeview/config.py @@ -38,6 +38,8 @@ class Account: # "github" か、Issue を返す拡張のモジュール名 issues: str = "github" gh_config_dir: Path | None = None + # `publish --if-changed` がこのアカウントのリポの変化を見る間隔(時間)。None は毎回見る + check_every_hours: int | None = None @dataclass(frozen=True) @@ -149,7 +151,9 @@ def _accounts(data: dict, src: Path, plugins: tuple[str, ...]) -> tuple[Account, where = f"{src}: accounts[{i}]" if not isinstance(row, dict): raise ConfigError(f"{where}: must be a table") - _unknown(row, ("name", "hosts", "groups", "issues", "gh_config_dir"), where) + _unknown( + row, ("name", "hosts", "groups", "issues", "gh_config_dir", "check_every_hours"), where + ) issues = _str(row, "issues", where, required=False) or "github" if issues != "github" and issues not in plugins: raise ConfigError( @@ -158,6 +162,10 @@ def _accounts(data: dict, src: Path, plugins: tuple[str, ...]) -> tuple[Account, gh_dir = _str(row, "gh_config_dir", where, required=False) if gh_dir and issues != "github": raise ConfigError(f"{where}: 'gh_config_dir' needs issues = \"github\"") + every = row.get("check_every_hours") + # bool は int の派生なので、true を 1 と取り違えないよう先に弾く + if every is not None and (isinstance(every, bool) or not isinstance(every, int) or every < 1): + raise ConfigError(f"{where}: 'check_every_hours' must be a positive integer") accounts.append( Account( name=_str(row, "name", where), @@ -165,6 +173,7 @@ def _accounts(data: dict, src: Path, plugins: tuple[str, ...]) -> tuple[Account, groups=_strs(row, "groups", where), issues=issues, gh_config_dir=_expand(gh_dir) if gh_dir else None, + check_every_hours=every, ) ) names = [a.name for a in accounts] diff --git a/codeview/issues.py b/codeview/issues.py index 579e637..5155e9e 100644 --- a/codeview/issues.py +++ b/codeview/issues.py @@ -6,7 +6,6 @@ from __future__ import annotations -import hashlib import json import os import subprocess @@ -19,11 +18,8 @@ from .repo import account_of, owner_repo GH_TIMEOUT = 60 -# 指紋は毎時の判定で回るので、本文やコメントまでは取りに行かない -GH_FIELDS = "number,title,labels,updatedAt,url" -GH_FIELDS_FULL = "number,title,labels,updatedAt,body,author,createdAt,comments" +GH_FIELDS = "number,title,labels,updatedAt,body,author,createdAt,comments" -FAILED_MARK = "fetch-failed" @dataclass class Wait: @@ -95,11 +91,9 @@ def _gh(owner_repo: str, fields: str, config_dir: Path | None) -> list[dict]: return json.loads(proc.stdout) -def _fetch_github( - account: config.Account, repo: Path, fields: str = GH_FIELDS_FULL -) -> list[dict]: +def _fetch_github(account: config.Account, repo: Path) -> list[dict]: try: - return _gh(owner_repo(repo), fields, account.gh_config_dir) + return _gh(owner_repo(repo), GH_FIELDS, account.gh_config_dir) except (RuntimeError, OSError, subprocess.SubprocessError, ValueError) as e: raise RuntimeError(str(e)) from e @@ -215,26 +209,3 @@ def fetch(item): rows.sort(key=lambda i: (-i.updated, i.repo, i.ref)) return Board(issues, failed, by_file) - -def github_fingerprint(account: config.Account, found: list[tuple[str, Path]]) -> str: - """アカウントの open な Issue の number と updatedAt をまとめた指紋。 - - 取得に失敗したリポは固定の印にする。前回と同じ扱いにすると、復旧しても - 変化が見えず、失敗が続く間も無駄に配信し直してしまうため。 - """ - targets = [p for g, p in found if account_of(g, p) == account.name] - - def line(repo: Path) -> str: - try: - rows = _fetch_github(account, repo, GH_FIELDS) - except RuntimeError: - return f"{repo.name}\t{FAILED_MARK}" - body = ",".join( - f"{r['number']}:{r.get('updatedAt', '')}" - for r in sorted(rows, key=lambda r: r["number"]) - ) - return f"{repo.name}\t{body}" - - with ThreadPoolExecutor(max_workers=8) as pool: - lines = sorted(pool.map(line, targets)) - return hashlib.sha256("\n".join(lines).encode()).hexdigest() diff --git a/codeview/publish.py b/codeview/publish.py index aa44254..53ec290 100644 --- a/codeview/publish.py +++ b/codeview/publish.py @@ -11,23 +11,26 @@ import argparse import hashlib +import json import os import subprocess import sys import tempfile +import time from datetime import datetime, timezone from importlib import metadata from pathlib import Path from codeview import config, plugins -from codeview.issues import github_fingerprint -from codeview.repo import repos +from codeview.repo import account_of, repos # Pages が配るのは公開ディレクトリと Functions だけ。生成器は配信ブランチに載せない ASSETS = ("public", "functions") _cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) STATE = _cache / "codeview" / "publish-state" +# 定期実行の起動は数秒〜数十秒ずれる。間隔ちょうどの回を見送って一周遅れないよう、その分を差し引く +SLACK = 300 def _site() -> Path: @@ -70,33 +73,79 @@ def _tree_of_assets() -> str: return _git("write-tree", env=env) -def _fingerprint(ctx: plugins.Context) -> str: - """走査対象リポの HEAD・GitHub の open な Issue・配信するリポの HEAD・本体の版をまとめた指紋。 +def _digest(lines: list[str]) -> str: + return hashlib.sha256("\n".join(sorted(lines)).encode()).hexdigest() + + +def _head(path: Path) -> str: + try: + return subprocess.run( + ["git", "-C", str(path), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + except subprocess.CalledProcessError: + return "-" - 配信するリポの未コミット変更は指紋に出ない。定期実行が拾うのはコミット済みの状態だけで、 + +def _fingerprint(ctx: plugins.Context) -> dict: + """配信の指紋。`accounts` はアカウントごとの走査対象リポの HEAD、`global` はそれ以外 + (拡張の行・配信するリポの HEAD・本体の版)。 + + GitHub の Issue は見ない。Issue だけの動きでは配信せず、次にコミットで配信するときに載る。 + 配信するリポの未コミット変更も指紋に出ない。定期実行が拾うのはコミット済みの状態だけで、 手元の実験を配信させないため。手動 publish は指紋を見ないので確認はそちらで足りる。 """ - found = repos() - lines = [] - for group, path in found: - try: - head = subprocess.run( - ["git", "-C", str(path), "rev-parse", "HEAD"], - check=True, - capture_output=True, - text=True, - ).stdout.strip() - except subprocess.CalledProcessError: - head = "-" - lines.append(f"{group}/{path.name}\t{head}") - # GitHub の Issue は HEAD に現れないので、GitHub 側の動きを別に見る - for account in config.load().accounts: - if account.issues == "github": - lines.append(f"__{account.name}_issues__\t{github_fingerprint(account, found)}") - lines.extend(f"__plugin__\t{line}" for line in plugins.fingerprint(ctx)) - lines.append(f"__site__\t{_git('rev-parse', 'HEAD')}") - lines.append(f"__codeview__\t{_version()}") - return hashlib.sha256("\n".join(sorted(lines)).encode()).hexdigest() + accounts: dict[str, list[str]] = {} + for group, path in repos(): + # どのアカウントにも属さないリポは "" にまとめ、毎回見る + name = account_of(group, path) or "" + accounts.setdefault(name, []).append(f"{group}/{path.name}\t{_head(path)}") + rest = [f"__plugin__\t{line}" for line in plugins.fingerprint(ctx)] + rest.append(f"__site__\t{_git('rev-parse', 'HEAD')}") + rest.append(f"__codeview__\t{_version()}") + return { + "global": _digest(rest), + "accounts": {name: _digest(lines) for name, lines in accounts.items()}, + } + + +def _load_state() -> dict | None: + try: + state = json.loads(STATE.read_text()) + except (OSError, ValueError): + return None + # 指紋だけを書いていた旧形式は、状態なしとして一度配信し直す + if not isinstance(state, dict) or not isinstance(state.get("accounts"), dict): + return None + return state + + +def _due(prev: dict, now: int) -> set[str]: + """前回の状態のうち、今回変化を見るアカウント。`check_every_hours` が経っていないものは見ない。""" + every = {a.name: a.check_every_hours for a in config.load().accounts} + due = set() + for name, entry in prev["accounts"].items(): + hours = every.get(name) + if not hours or now - entry.get("checked", 0) >= hours * 3600 - SLACK: + due.add(name) + return due + + +def _changed(prev: dict, current: dict, due: set[str]) -> bool: + if prev.get("global") != current["global"]: + return True + old = prev["accounts"] + # アカウントが増えた・消えたときは、間隔を待たずに配信する + if set(old) != set(current["accounts"]): + return True + return any(old[name].get("digest") != current["accounts"][name] for name in due) + + +def _save(state: dict) -> None: + STATE.parent.mkdir(parents=True, exist_ok=True) + STATE.write_text(json.dumps(state, sort_keys=True) + "\n") def publish(remote: str, branch: str, dry_run: bool, if_changed: bool) -> int: @@ -104,9 +153,17 @@ def publish(remote: str, branch: str, dry_run: bool, if_changed: bool) -> int: plugins.before_publish(ctx) # build 前に採る。生成中に元リポが動いた分は次回に回るほうが、取り落とすより安全 fingerprint = _fingerprint(ctx) - if if_changed and STATE.exists() and STATE.read_text().strip() == fingerprint: - print("publish skipped: no change in the scan targets") - return 0 + now = int(time.time()) + prev = _load_state() if if_changed else None + if prev is not None: + due = _due(prev, now) + if not _changed(prev, fingerprint, due): + # 見た時刻だけ進める。digest は前回の配信のものを残し、まだ見ていない変化を取り落とさない + for name in due: + prev["accounts"][name]["checked"] = now + _save(prev) + print("publish skipped: no change in the scan targets") + return 0 site = _site() subprocess.run( @@ -133,8 +190,15 @@ def publish(remote: str, branch: str, dry_run: bool, if_changed: bool) -> int: return 0 _git("push", "--force", remote, f"{commit}:refs/heads/{branch}") - STATE.parent.mkdir(parents=True, exist_ok=True) - STATE.write_text(fingerprint + "\n") + _save( + { + "global": fingerprint["global"], + "accounts": { + name: {"digest": digest, "checked": now} + for name, digest in fingerprint["accounts"].items() + }, + } + ) print(f"pushed {commit[:8]} → {remote}/{branch} {message}") return 0 diff --git a/context/structure.md b/context/structure.md index a98b7b2..e6fa56e 100644 --- a/context/structure.md +++ b/context/structure.md @@ -27,4 +27,4 @@ tests/ 3. `issues.collect()` がアカウントごとに Issue を集め、ファイルから Issue への索引を作る 4. `build.build_repo()` がリポごとに HEAD のファイルをページにする 5. `layout.build_index()` と `issues_pages()` がトップと Issue のページを書き、拡張の `build()` が自前のページを足す -6. `publish` は指紋(走査対象の HEAD・GitHub の Issue・設定のリポの HEAD・本体の版)を取り、`public/` と `functions/` を一時 index に載せて配信ブランチへ force push する。Cloudflare Pages がそのブランチを配る +6. `publish` は指紋(アカウントごとの走査対象の HEAD・設定のリポの HEAD・本体の版・拡張の `fingerprint()`)を取り、`public/` と `functions/` を一時 index に載せて配信ブランチへ force push する。Cloudflare Pages がそのブランチを配る diff --git a/docs/guarantees.md b/docs/guarantees.md index 52c6927..b70a6ea 100644 --- a/docs/guarantees.md +++ b/docs/guarantees.md @@ -114,23 +114,30 @@ ### 4. `tests/test_publish.py` — codeview publish (変更の検出と出力先) -- 走査対象にも Issue にも動きが無ければ、変更なしと判定する +- 走査対象に動きが無ければ、変更なしと判定する - 走査対象のリポに新しいコミットがあれば、変更ありと判定する -- GitHub の Issue が更新されれば、変更ありと判定する +- GitHub の Issue だけが更新されても、変更なしと判定する。指紋を取るときに GitHub へ問い合わせない - 走査対象のリポの未コミットの変更は、変更として扱わない - `publish --dry-run` は、設定ファイルのあるリポの直下に `public/` と `functions/` を書き、push せずに終わる - `publish` は配信ブランチを親を持たない単一コミットとして push し、その木には `public/` と `functions/` だけを載せる -- `publish --if-changed` は、前回の配信から変更が無ければ push せずに終わる +- `publish --if-changed` は、前回の配信から変更が無ければ push せずに終わる。Issue だけの更新でも push しない +- `check_every_hours` を持つアカウントのリポの変化は、前回そのアカウントを見てから間隔が経つまで見ない。定期実行の起動のずれを見込み、間隔より5分短い時間が経っていれば見て、変化があれば配信する。間隔の無いアカウントの変化は、次の実行で配信する +- 旧形式の状態ファイルは無いものとして扱い、配信する +- `check_every_hours` は正の整数だけを受け付け、それ以外は設定の誤りとして止める | 保証(要約) | 対応テスト | |---|---| | 動きが無ければ変更なし | `test_fingerprint_stable_without_changes` | | 新しいコミット | `test_fingerprint_changes_with_head` | -| Issue の更新 | `test_fingerprint_changes_with_github_issue_update` | +| Issue の更新は無視 | `test_fingerprint_ignores_github_issue_update` | | 未コミットは無視 | `test_fingerprint_ignores_uncommitted` | | `--dry-run` の出力先 | `test_publish_writes_into_the_config_repo` | | 単一コミットに生成物だけ | `test_publish_pushes_single_commit_of_assets` | | `--if-changed` の見送り | `test_publish_if_changed_skips_without_changes` | +| Issue だけの更新は見送り | `test_publish_if_changed_skips_issue_only_change` | +| アカウントごとの間隔 | `test_publish_checks_account_at_its_interval` | +| 旧形式の状態 | `test_publish_if_changed_republishes_over_old_state` | +| 間隔の値の検証 | `test_check_every_hours_must_be_positive_integer` | ## Gaps diff --git a/tests/conftest.py b/tests/conftest.py index ac1dff7..b55e4a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,7 +153,10 @@ def build(self, check: bool = True) -> tuple[Path, subprocess.CompletedProcess]: return out / "public", proc def fingerprint(self) -> str: - code = "from codeview import publish, plugins as p; print(publish._fingerprint(p.context()))" + code = ( + "import json; from codeview import publish, plugins as p; " + "print(json.dumps(publish._fingerprint(p.context()), sort_keys=True))" + ) return self.run("-c", code).stdout.strip() diff --git a/tests/test_publish.py b/tests/test_publish.py index 0304d29..1ce2998 100644 --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -1,8 +1,11 @@ -"""配信の指紋が、走査対象の HEAD と GitHub の Issue の動きで変わることを確かめる。""" +"""配信の指紋が走査対象の HEAD で変わり、GitHub の Issue では変わらないことと、 +アカウントごとの間隔で変化を見ることを確かめる。""" from __future__ import annotations +import json import subprocess +import time from conftest import commit, gh_row, git @@ -17,10 +20,12 @@ def test_fingerprint_changes_with_head(site): assert site.fingerprint() != before -def test_fingerprint_changes_with_github_issue_update(site): +def test_fingerprint_ignores_github_issue_update(site): before = site.fingerprint() site.set_gh("beta", [gh_row(7, "beta の問い", "2026-03-01T00:00:00Z")]) - assert site.fingerprint() != before + assert site.fingerprint() == before + # 指紋を取るだけでは GitHub に問い合わせない + assert site.gh_calls() == [] def test_fingerprint_ignores_uncommitted(site): @@ -57,3 +62,77 @@ def test_publish_if_changed_skips_without_changes(site, tmp_path): proc = site.run("-m", "codeview", "publish", "--if-changed") assert "skipped" in proc.stdout assert git(remote, "rev-parse", "deploy") == first + + +def test_publish_if_changed_skips_issue_only_change(site, tmp_path): + remote = _with_remote(site, tmp_path) + site.run("-m", "codeview", "publish", "--if-changed") + first = git(remote, "rev-parse", "deploy") + site.set_gh("beta", [gh_row(7, "beta の問い", "2026-03-01T00:00:00Z")]) + proc = site.run("-m", "codeview", "publish", "--if-changed") + assert "skipped" in proc.stdout + assert git(remote, "rev-parse", "deploy") == first + + +def _state(site, tmp_path) -> Path: + return tmp_path / "cache" / "codeview" / "publish-state" + + +def test_publish_checks_account_at_its_interval(site, tmp_path): + site.write_config( + site.config.read_text().replace( + 'gh_config_dir = "', 'check_every_hours = 2\ngh_config_dir = "', 1 + ) + ) + remote = _with_remote(site, tmp_path) + site.run("-m", "codeview", "publish", "--if-changed") + first = git(remote, "rev-parse", "deploy") + + # 間隔の経っていないアカウントのコミットでは配信しない + commit(site.home / "work" / "beta", {"new.py": "n = 1\n"}) + proc = site.run("-m", "codeview", "publish", "--if-changed") + assert "skipped" in proc.stdout + assert git(remote, "rev-parse", "deploy") == first + + # 間隔の無いアカウントのコミットは、すぐ配信する + commit(site.home / "own" / "alpha", {"new.py": "n = 1\n"}) + proc = site.run("-m", "codeview", "publish", "--if-changed") + assert "pushed" in proc.stdout + second = git(remote, "rev-parse", "deploy") + assert second != first + + # 間隔が経てば、そのアカウントのコミットで配信する。起動のずれの分は早めに見る + commit(site.home / "work" / "beta", {"more.py": "m = 1\n"}) + path = _state(site, tmp_path) + state = json.loads(path.read_text()) + state["accounts"]["work"]["checked"] = int(time.time()) - 2 * 3600 + 60 + path.write_text(json.dumps(state)) + proc = site.run("-m", "codeview", "publish", "--if-changed") + assert "pushed" in proc.stdout + assert git(remote, "rev-parse", "deploy") != second + + +def test_publish_if_changed_republishes_over_old_state(site, tmp_path): + # 指紋だけを書いていた旧形式の状態は、無いものとして配信する + remote = _with_remote(site, tmp_path) + path = _state(site, tmp_path) + path.parent.mkdir(parents=True) + path.write_text("0" * 64 + "\n") + proc = site.run("-m", "codeview", "publish", "--if-changed") + assert "pushed" in proc.stdout + assert git(remote, "rev-parse", "deploy") + + +def test_check_every_hours_must_be_positive_integer(site): + for bad in ("0", "1.5", "true", '"2"'): + site.write_config( + site.config.read_text().replace( + 'gh_config_dir = "', f'check_every_hours = {bad}\ngh_config_dir = "', 1 + ) + ) + proc = site.run("-m", "codeview", "publish", "--dry-run", check=False) + assert proc.returncode != 0 + assert "check_every_hours" in proc.stderr + site.write_config( + site.config.read_text().replace(f"check_every_hours = {bad}\n", "", 1) + )