Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."<slug>"]` (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.<last part of the module name>]` |

Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ codeview serve # http://localhost:8787 で確認する
|---|---|
| `[[roots]]` | 走査する置き場。`group` と、`path`(そのディレクトリが1つのリポ)か `children`(直下の git リポすべて)のどちらか一方。並び順がトップの左メニューのグループの順になる。グループの中は、ファイル数の多いリポから並ぶ |
| `[scan]` | `skip_dirs`(走査しないディレクトリ名)・`exclude_prefixes`(出力しないパスの接頭辞)・`skip_exts`(出力しない拡張子)。リポごとに足すときは `[scan.repos."<slug>"]` に `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.<モジュール名の末尾>]` に置く |

Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion codeview/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -158,13 +162,18 @@ 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),
hosts=_strs(row, "hosts", where),
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]
Expand Down
35 changes: 3 additions & 32 deletions codeview/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from __future__ import annotations

import hashlib
import json
import os
import subprocess
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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()
126 changes: 95 additions & 31 deletions codeview/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -70,43 +73,97 @@ 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:
ctx = plugins.context()
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(
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion context/structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 がそのブランチを配る
15 changes: 11 additions & 4 deletions docs/guarantees.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading