From 7ea4287e24c3d2628126378e3ab86cdd4150f96b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 25 Jun 2026 23:09:02 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=E8=AA=A4=E6=B7=B7=E5=85=A5=E3=81=97?= =?UTF-8?q?=E3=81=9F=E3=82=AF=E3=83=AD=E3=82=B9=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E4=BD=9C=E6=A5=AD=E7=94=9F=E6=88=90=E7=89=A9=E3=82=92?= =?UTF-8?q?=E9=99=A4=E5=8E=BB=E3=81=97=20.gitignore=20=E3=81=AB=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #75 のクロスレビュー時、fix サブエージェントが worktree 内で広範に git add した結果、.cross_review/ 配下の内部生成物と gh-payload.json が PR に混入し main にマージされていた。これらを除去し、再発防止のため .gitignore に .cross_review/ と gh-payload.json を追加する。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .cross_review/codex-review-pr75-err.log | 2028 ----------------- .../codex-review-pr75-gh-response.json | 1 - .cross_review/codex-review-pr75-prompt.md | 55 - .cross_review/codex-review-pr75-result.json | 12 - .../codex-review-pr75-round1-payload.json | 10 - .../codex-review-pr75-round2-api-payload.json | 13 - ...dex-review-pr75-round2-comment-update.json | 1 - .../codex-review-pr75-round2-err.log | 0 .../codex-review-pr75-round2-payload.json | 10 - .../codex-review-pr75-round2-response.json | 1 - .../codex-review-pr75-round3-body.md | 3 - .../codex-review-pr75-round3-payload.json | 3 - .../codex-review-pr75-round3-response.json | 1 - .cross_review/codex-review-pr75-stdout.log | 10 - .cross_review/codex-review-pr75.pid | 1 - .../cross-review-pr75-existing-comments.txt | 0 .cross_review/cross-review-pr75-state.json | 107 - .cross_review/err.log | 0 .cross_review/fix-pr75-result.json | 14 - .cross_review/gemini-review-api-payload.json | 12 - .cross_review/gemini-review-api-response.json | 1 - .cross_review/gemini-review-pr75-err.log | 4 - .cross_review/gemini-review-pr75-prompt.md | 64 - .cross_review/gemini-review-pr75-result.json | 12 - .../gemini-review-pr75-round1-payload.json | 16 - .../gemini-review-pr75-round2-payload.json | 16 - .../gemini-review-pr75-round3-payload.json | 10 - .cross_review/gemini-review-pr75-stdout.log | 9 - .cross_review/gemini-review-pr75.pid | 1 - .gitignore | 4 + gh-payload.json | 17 - 31 files changed, 4 insertions(+), 2432 deletions(-) delete mode 100644 .cross_review/codex-review-pr75-err.log delete mode 100644 .cross_review/codex-review-pr75-gh-response.json delete mode 100644 .cross_review/codex-review-pr75-prompt.md delete mode 100644 .cross_review/codex-review-pr75-result.json delete mode 100644 .cross_review/codex-review-pr75-round1-payload.json delete mode 100644 .cross_review/codex-review-pr75-round2-api-payload.json delete mode 100644 .cross_review/codex-review-pr75-round2-comment-update.json delete mode 100644 .cross_review/codex-review-pr75-round2-err.log delete mode 100644 .cross_review/codex-review-pr75-round2-payload.json delete mode 100644 .cross_review/codex-review-pr75-round2-response.json delete mode 100644 .cross_review/codex-review-pr75-round3-body.md delete mode 100644 .cross_review/codex-review-pr75-round3-payload.json delete mode 100644 .cross_review/codex-review-pr75-round3-response.json delete mode 100644 .cross_review/codex-review-pr75-stdout.log delete mode 100644 .cross_review/codex-review-pr75.pid delete mode 100644 .cross_review/cross-review-pr75-existing-comments.txt delete mode 100644 .cross_review/cross-review-pr75-state.json delete mode 100644 .cross_review/err.log delete mode 100644 .cross_review/fix-pr75-result.json delete mode 100644 .cross_review/gemini-review-api-payload.json delete mode 100644 .cross_review/gemini-review-api-response.json delete mode 100644 .cross_review/gemini-review-pr75-err.log delete mode 100644 .cross_review/gemini-review-pr75-prompt.md delete mode 100644 .cross_review/gemini-review-pr75-result.json delete mode 100644 .cross_review/gemini-review-pr75-round1-payload.json delete mode 100644 .cross_review/gemini-review-pr75-round2-payload.json delete mode 100644 .cross_review/gemini-review-pr75-round3-payload.json delete mode 100644 .cross_review/gemini-review-pr75-stdout.log delete mode 100644 .cross_review/gemini-review-pr75.pid delete mode 100644 gh-payload.json diff --git a/.cross_review/codex-review-pr75-err.log b/.cross_review/codex-review-pr75-err.log deleted file mode 100644 index 4adfb68..0000000 --- a/.cross_review/codex-review-pr75-err.log +++ /dev/null @@ -1,2028 +0,0 @@ -Reading prompt from stdin... -OpenAI Codex v0.139.0 --------- -workdir: /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 -model: gpt-5.5 -provider: openai -approval: never -sandbox: danger-full-access -reasoning effort: none -reasoning summaries: none -session id: 019eff0f-e26d-7af0-a57e-55679939e96f --------- -user -# /ndf:review 実行 (cross-review codex / round 3) - -PR #75 を **codex の観点でレビューし、gh api で直接 PR に投稿** してください。 - -## 必須コンテキスト -- repo: devbasex/devbase -- PR: #75 -- commit_id (headRefOid): f8231341fb4dfa044e1b27ee4e698058b7ff28b2 -- worktree: /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 (**ファイル読み取りは必ず此処の絶対パスを使う**) -- event_downgrade: true - - true の場合: payload の `event` は `COMMENT` にすること。 - ただし body 先頭 prefix の `` には **本来の intent** を書く。 -- 既存コメントスナップショット: /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review/cross-review-pr75-existing-comments.txt (重複指摘禁止) - -## 出力契約 -- review body の **先頭行** に必ず以下を入れる(fence 不要、Markdown 見出しとして): - ``` - ## 🤖 cross-review | round 3 | codex | - ``` - 例: `## 🤖 cross-review | round 3 | codex | REQUEST_CHANGES` - - `` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) - -### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) -- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない -- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 -- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) -- ❌ **コード引用 (``` ... ```) だけで指摘内容が無いコメント** -- ❌ **`event=COMMENT` での雑感投稿** — 直すべき点が無ければ `APPROVE` にする - -### インラインコメントの書式 -- `[重要度 / カテゴリ]` プレフィックス必須 (例: `[major / 正確性]`) -- 重要度は `critical` / `major` / `minor` のみ使う (nit はインライン化しない) -- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く - -### body (総評) の書き方 -- 設計レベル・PR 横断の **修正提案のみ** 書く -- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) - -- 投稿後、サマリを **/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review/codex-review-pr75-result.json** に書く: - ```json - { - "event": "REQUEST_CHANGES", - "posted_as": "COMMENT", - "comments_count": 5, - "review_url": "https://github.com/.../pull/75#pullrequestreview-...", - "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0} - } - ``` -- payload(全コメント詳細)は **/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review/codex-review-pr75-round3-payload.json** に保存 - (振動検知用、`{ "comments": [{path, line, body, severity}, ...] }` 形式) - -## 守るべきこと -- リポジトリ編集は行わない(コード修正は別ステップ) -- worktree 外のパスは触らない -- gh api 失敗時は err.log にエラー詳細を残して即時終了 - -codex -GitHub レビューとして進めます。まず既存コメントと PR 差分を確認し、重複を避けて actionable な指摘だけに絞ります。 -exec -/bin/bash -lc 'pwd && git status --short && rg --files .cross_review | sort' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 -?? .cross_review/ -?? gh-payload.json -.cross_review/codex-review-pr75-err.log -.cross_review/codex-review-pr75-gh-response.json -.cross_review/codex-review-pr75-prompt.md -.cross_review/codex-review-pr75-result.json -.cross_review/codex-review-pr75-round1-payload.json -.cross_review/codex-review-pr75-round2-api-payload.json -.cross_review/codex-review-pr75-round2-comment-update.json -.cross_review/codex-review-pr75-round2-err.log -.cross_review/codex-review-pr75-round2-payload.json -.cross_review/codex-review-pr75-round2-response.json -.cross_review/codex-review-pr75-stdout.log -.cross_review/codex-review-pr75.pid -.cross_review/cross-review-pr75-existing-comments.txt -.cross_review/cross-review-pr75-state.json -.cross_review/err.log -.cross_review/fix-pr75-result.json -.cross_review/gemini-review-api-payload.json -.cross_review/gemini-review-api-response.json -.cross_review/gemini-review-pr75-err.log -.cross_review/gemini-review-pr75-prompt.md -.cross_review/gemini-review-pr75-result.json -.cross_review/gemini-review-pr75-round1-payload.json -.cross_review/gemini-review-pr75-round2-payload.json -.cross_review/gemini-review-pr75-stdout.log -.cross_review/gemini-review-pr75.pid - -codex -`github:github` の手順を使います。PR 情報とコメント取得に GitHub 連携、差分とローカル確認に worktree を使い分けます。 -exec -/bin/bash -lc "sed -n '1,240p' /Users/takemi_ohama/.codex/plugins/cache/openai-curated-remote/github/0.1.5/skills/github/SKILL.md" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: ---- -name: github -description: Triage and orient GitHub repository, pull request, and issue work through the connected GitHub app. Use when the user asks for general GitHub help, wants PR or issue summaries, or needs repository context before choosing a more specific GitHub workflow. ---- - -# GitHub - -## Overview - -Use this skill as the umbrella entrypoint for general GitHub work in this plugin. It should decide whether the task stays in repo and PR triage or should be handed off to a more specific review, CI, or publish workflow. - -This plugin is intentionally hybrid: - -- Prefer the GitHub app from this plugin for repository, issue, pull request, comment, label, reaction, and PR creation workflows. -- Use local `git` and `gh` only when the connector does not cover the job well, especially for current-branch PR discovery, branch creation, commit and push, `gh auth status`, and GitHub Actions log inspection. -- Keep connector state and local checkout context aligned. If the request is about the current branch, resolve the local repo and branch before acting. - -Once the intent is clear, route to the specialist skill immediately and do not keep broad GitHub triage in scope longer than needed. - -## Connector-First Responsibilities - -Handle these directly in this skill when the request does not need a narrower specialist workflow: - -- repository orientation once the repo, PR, issue, or local checkout is identified -- recent PR or issue triage -- PR metadata summaries -- PR patch inspection -- PR comments, labels, and reactions -- issue lookup and summarization -- PR creation after a branch is already pushed - -Prefer the GitHub app from this plugin for those flows because it provides structured PR, issue, and review-adjacent data without depending on a local checkout. If the repository is not already identifiable from the user request or local git context, ask for the repo instead of pretending there is a repo-search flow that may not exist. - -## Routing Rules - -1. Resolve the operating context first: - - If the user provides a repository, PR number, issue number, or URL, use that. - - If the request is about "this branch" or "the current PR", resolve local git context and use `gh` only as needed to discover the branch PR. - - If the repository is still ambiguous after local inspection, ask for the repo identifier. -2. Classify the request before taking action: - - `repo or PR triage`: summarize PRs, issues, patches, comments, labels, reactions, or repository state - - `review follow-up`: unresolved review threads, requested changes, or inline review feedback - - `CI debugging`: failing checks, Actions logs, or CI root-cause analysis - - `publish changes`: create or switch branches, stage changes, commit, push, and open a draft PR -3. Route to the specialist skill as soon as the category is clear: - - Review comments and requested changes: `../gh-address-comments/SKILL.md` - - Failing GitHub Actions checks: `../gh-fix-ci/SKILL.md` - - Commit, push, and open PR: `../yeet/SKILL.md` -4. Keep the hybrid model consistent after routing: - - connector first for PR and issue data - - local `git` and `gh` only for the specific gaps the connector does not cover - -## Default Workflow - -1. Resolve repository and item scope. -2. Gather structured PR or issue context through the GitHub app from this plugin. -3. Decide whether the task stays in connector-backed triage or needs a specialist skill. -4. Route immediately when the work becomes review follow-up, CI debugging, or publish workflow. -5. End with a clear summary of what was inspected, what changed, and what remains. - -## Output Expectations - -- For triage requests, return a concise summary of the repository, PR, or issue state and the next likely action. -- For mixed requests, tell the user which specialist path you are taking and why. -- For connector-backed write actions, restate the exact PR, issue, label, or reaction target before applying the change. -- Never imply that GitHub Actions logs are available through the connector alone. That remains a `gh` workflow. - -## Examples - -- "Use GitHub to summarize the open PRs in this repo and tell me what needs attention." -- "Help with this PR." -- "Review the latest comments on PR 482 and tell me what is actionable." -- "Debug the failing checks on this branch." -- "Commit these changes, push them, and open a draft PR." - -codex -これから読む対象は `.cross_review` の既存コメントスナップショット、PR のパッチ、変更ファイル一覧です。既に round 1/2 の投稿があるので、同じ内容を再投稿しない前提で照合します。 -exec -/bin/bash -lc "sed -n '1,260p' .cross_review/cross-review-pr75-existing-comments.txt" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -exec -/bin/bash -lc 'git diff --stat origin/main...HEAD || git diff --stat HEAD~1...HEAD' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: - docs/user/cli-reference.md | 2 + - lib/devbase/commands/container.py | 42 ++++++++++++ - lib/devbase/snapshot/manager.py | 38 +++++++++++ - tests/snapshot/__init__.py | 0 - tests/snapshot/test_auto_snapshot.py | 128 +++++++++++++++++++++++++++++++++++ - 5 files changed, 210 insertions(+) - -exec -/bin/bash -lc 'git diff --name-only origin/main...HEAD || git diff --name-only HEAD~1...HEAD' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -docs/user/cli-reference.md -lib/devbase/commands/container.py -lib/devbase/snapshot/manager.py -tests/snapshot/__init__.py -tests/snapshot/test_auto_snapshot.py - -exec -/bin/bash -lc 'git rev-parse HEAD && git branch --show-current && git remote -v' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -f8231341fb4dfa044e1b27ee4e698058b7ff28b2 -origin https://github.com/devbasex/devbase.git (fetch) -origin https://github.com/devbasex/devbase.git (push) - -codex -差分は自動 snapshot 作成まわりに限定されています。次に実装とテストを読んで、実際の動作条件と CLI からの呼び出し方を確認します。 -exec -/bin/bash -lc 'git diff --unified=80 origin/main...HEAD -- lib/devbase/commands/container.py' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py -index 8711cdf..49fbae7 100644 ---- a/lib/devbase/commands/container.py -+++ b/lib/devbase/commands/container.py -@@ -294,171 +294,213 @@ def _resolve_project_name(project_name: str) -> bool: - # wrapper の `source ./env` と同等に project env を os.environ へ反映する。 - # wrapper 経由なら既に同じ値が載っているため冪等。 - _load_project_env(Path('env')) - - # COMPOSE_PROJECT_NAME を name で上書き (wrapper が設定済みでも冪等)。 - # env 由来の COMPOSE_PROJECT_NAME より name 指定を優先するため env 反映後に行う。 - os.environ['COMPOSE_PROJECT_NAME'] = project_name - return True - - - def _dispatch_lifecycle(args) -> int: - """`project` / `container` 共有のサブコマンドディスパッチャ。 - - `project [name]` の `name` を解決して project_name へ畳み込む。 - `container` 経路には `name` 属性が無いため従来通り None になる。 - - name 指定時は handler 呼び出し前に一括で `$DEVBASE_ROOT/projects/` へ - chdir する (PLAN06 方針 A の Python 側フォールバック)。chdir を各 handler に - 散らさずここで実施するのは、`cmd_down()` / `cmd_login()` / `cmd_logs()` 等が - project_name 引数を取らず、per-handler 実装では down/login/logs で名前解決が - 効かなくなるため。build は wrapper の shell 実装で CWD 実行されるため、この - Python フォールバックの対象外 (name 属性も持たない)。 - """ - subcmd = getattr(args, 'subcommand', None) - project_name = getattr(args, 'name', None) or getattr(args, 'project_name', None) - - # name 指定時はディレクトリを解決して chdir する。解決失敗 (DEVBASE_ROOT 未設定 - # / 存在しない name) は候補提示の上でエラー終了する。 - if project_name: - if not _resolve_project_name(project_name): - return 1 - - handlers = { - 'up': lambda: cmd_up(project_name=project_name, - scale=getattr(args, 'scale', None), - open_editor=getattr(args, 'open_editor', None), - open_index=getattr(args, 'open_index', None)), - 'down': lambda: cmd_down(), - 'login': lambda: cmd_login(index=getattr(args, 'index', '1')), - 'ps': lambda: cmd_ps(all_containers=getattr(args, 'all', False)), - 'logs': lambda: cmd_logs(follow=getattr(args, 'follow', False), - tail=getattr(args, 'tail', None)), - 'scale': lambda: cmd_scale(new_scale=getattr(args, 'new_scale', None), - project_name=project_name), - 'build': lambda: cmd_build(image=getattr(args, 'image', None), - no_cache=getattr(args, 'no_cache', False), - expires=getattr(args, 'expires', None)), - 'rebuild': lambda: cmd_rebuild(), - } - - handler = handlers.get(subcmd) - if handler: - return handler() - - logger.error("サブコマンドを指定してください: %s", ', '.join(handlers)) - return 1 - - - def cmd_project(args) -> int: - """`devbase project [name]` ディスパッチャ (推奨エントリ)。""" - return _dispatch_lifecycle(args) - - - def cmd_container(args) -> int: - """`devbase container ` ディスパッチャ。 - - 非推奨: `devbase project` に移行してください (移行期間後に削除予定)。 - 挙動は `cmd_project` と同一で、警告のみ追加する。 - """ - logger.warning( - "`devbase container` は非推奨です。`devbase project` を使用してください " - "(将来のリリースで削除されます)。" - ) - return _dispatch_lifecycle(args) - - - # --------------------------------------------------------------------------- - # cmd_up (deploy.py の cmd_deploy を移植) - # --------------------------------------------------------------------------- - -+_SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT = 60 -+ -+ -+def _snapshot_min_interval_minutes() -> int: -+ """自動スナップショットをスキップする最小間隔 (分)。 -+ -+ 直近のスナップショット取得からこの分数未満なら ``_auto_snapshot`` はスキップする。 -+ DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES で上書き可能 (0 で無効化=毎回取得)。 -+ 値が不正な場合は既定値にフォールバックする。 -+ """ -+ raw = os.environ.get('DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES') -+ if not raw: -+ return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT -+ try: -+ value = int(raw) -+ if value < 0: -+ raise ValueError -+ return value -+ except ValueError: -+ logger.warning( -+ "Invalid DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=%r, using default %d", -+ raw, _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT -+ ) -+ return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT -+ -+ - def _auto_snapshot() -> None: - """デプロイ前の自動スナップショット (差分世代数ベース世代管理)。 - - 失敗してもデプロイは続行する (warning のみ)。DEVBASE_ROOT 未設定なら no-op。 - """ - devbase_root = os.environ.get('DEVBASE_ROOT') - if not devbase_root: - return - try: -+ from datetime import datetime, timedelta -+ - from devbase.snapshot.manager import SnapshotManager - mgr = SnapshotManager(Path(devbase_root)) -+ min_interval = _snapshot_min_interval_minutes() -+ last = mgr.last_snapshot_time() -+ if min_interval > 0 and last is not None: -+ # 経過時間が負 (last が未来) の場合はスキップしない。システム時計の -+ # ズレや他環境からのリストアで last が未来になると delta が負になり、 -+ # 常に閾値未満と判定されて無期限にスキップされてしまうため、 -+ # timedelta(0) <= delta の下限ガードを設ける。 -+ delta = datetime.now() - last -+ if timedelta(0) <= delta < timedelta(minutes=min_interval): -+ logger.info( -+ "[0/6] 直近のスナップショット (%s) から%d分以内のためスキップします", -+ last.strftime('%Y-%m-%d %H:%M:%S'), min_interval, -+ ) -+ return - if mgr.should_start_new_generation(): - logger.info("[0/6] 新しいスナップショット世代を作成中...") - mgr.create() - else: - latest = mgr.list()[-1]['name'] - logger.info("[0/6] スナップショットを差分更新中: %s", latest) - mgr.create(name=latest, full=False) - mgr.rotate() - except Exception as e: - logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) - - - def _resolve_open_index(open_index: Optional[int], scale: int) -> int: - """開く dev インスタンス番号を解決する (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。 - - ``1..scale`` の範囲外 (0・負数・``scale`` 超過) は存在しないコンテナを指し原因不明な - 起動失敗を招くため、警告を出して 1 へフォールバックする。:func:`_maybe_open_editor` - で env フォールバック・範囲チェックを共有する。 - """ - if open_index is None: - raw = os.environ.get('DEVBASE_OPEN_INDEX') - try: - open_index = int(raw) if raw else 1 - except ValueError: - open_index = 1 - if not (1 <= open_index <= scale): - logger.warning( - "open index %d is out of range (1..%d); falling back to 1", - open_index, scale, - ) - open_index = 1 - return open_index - - - def _maybe_open_editor(project_name: str, open_flag: Optional[bool], - open_index: Optional[int], scale: int, - compose_file=None) -> None: - """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 - - 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら env - ``DEVBASE_OPEN_EDITOR``。エディタ起動の成否は ``up`` の戻り値に影響させない。 - - ``open_index`` は起動済みインスタンス範囲 ``1..scale`` 内である必要がある。 - 0・負数・``scale`` 超過は存在しないコンテナ URI になり原因不明な起動失敗を招くため、 - 警告を出して既定 (1) へフォールバックする。 - - ``compose_file`` は実コンテナ名問い合わせ用の override compose。``up`` 起動時と - 同じファイルを渡さないと ``{dev}-{index}`` サービスが見えず実名取得に失敗する。 - 未指定なら ``.docker-compose.scale.yml`` が存在すればそれ、無ければ None。 - """ - from devbase.editor import opener - - enabled = open_flag if open_flag is not None else opener.is_open_enabled() - if not enabled: - return - - open_index = _resolve_open_index(open_index, scale) - - # 実コンテナ名問い合わせ用の compose file: 明示指定がなければ override が - # 存在すればそれを使う (起動時と同じ file を docker compose ps へ渡す)。 - if compose_file is None and _SCALE_COMPOSE_FILE.exists(): - compose_file = _SCALE_COMPOSE_FILE - - dev_service_name = get_dev_service_name() - workdir = opener.resolve_workdir(os.environ, project_name) - logger.info("[6/6] Opening editor attached to the dev container...") - try: - opener.open_editor( - project_name=project_name, - dev_service_name=dev_service_name, - workdir=workdir, - index=open_index, - compose_file=compose_file, - ) - except Exception as e: # noqa: BLE001 - エディタ起動で up を倒さない - logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) - - - def cmd_up(project_name: str = None, scale: int = None, - open_editor: Optional[bool] = None, - -exec -/bin/bash -lc 'git diff --unified=80 origin/main...HEAD -- lib/devbase/snapshot/manager.py' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -diff --git a/lib/devbase/snapshot/manager.py b/lib/devbase/snapshot/manager.py -index 1ea89f4..7ac3b5b 100644 ---- a/lib/devbase/snapshot/manager.py -+++ b/lib/devbase/snapshot/manager.py -@@ -18,160 +18,198 @@ VOLUME_NAME = 'devbase_home_ubuntu' - SNAPSHOT_IMAGE = 'devbase-snapshot:latest' - DEFAULT_MAX_GENERATIONS = 3 - DEFAULT_MAX_INCREMENTALS = 10 - METADATA_FILE = 'snapshot.yml' - _VALID_NAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]*$') - - - class SnapshotManager: - """Docker volumeのスナップショット管理""" - - def __init__(self, devbase_root: Path): - self.devbase_root = devbase_root - self.backups_dir = devbase_root / 'backups' - self.backups_dir.mkdir(exist_ok=True) - self._metadata_path = self.backups_dir / METADATA_FILE - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - @staticmethod - def _validate_name(name: str) -> None: - """スナップショット名のバリデーション(パストラバーサル防止)""" - if not name or not _VALID_NAME_RE.match(name): - raise SnapshotError( - f"無効なスナップショット名: '{name}' " - "(英数字・ハイフン・アンダースコア・ドットのみ使用可能、先頭は英数字)" - ) - - def _safe_snap_dir(self, name: str) -> Path: - """名前からスナップショットディレクトリを安全に解決する""" - self._validate_name(name) - snap_dir = (self.backups_dir / name).resolve() - if not str(snap_dir).startswith(str(self.backups_dir.resolve())): - raise SnapshotError(f"無効なスナップショットパス: '{name}'") - return snap_dir - - def create(self, name: Optional[str] = None, full: bool = False) -> str: - """スナップショットを作成する。 - - Args: - name: スナップショット名(省略時はタイムスタンプ) - full: Trueならフルバックアップを強制 - - Returns: - 作成されたスナップショット名 - """ - if name is None: - name = datetime.now().strftime('%Y%m%d-%H%M%S') - - snap_dir = self._safe_snap_dir(name) - is_new = not snap_dir.exists() - - if is_new: - snap_dir.mkdir(parents=True) - full = True # 初回は常にフル - - if full: - self._create_full(name, snap_dir) - else: - self._create_incremental(name, snap_dir) - - self._update_global_metadata(name, snap_dir) - return name - - def list(self) -> list[dict]: - """スナップショット一覧を返す""" - meta = self._load_metadata() - snapshots = meta.get('snapshots', []) - # ディレクトリの実サイズも取得 - for snap in snapshots: - snap_dir = self.backups_dir / snap['name'] - if snap_dir.exists(): - snap['size_bytes'] = sum( - f.stat().st_size for f in snap_dir.iterdir() if f.is_file() - ) - else: - snap['size_bytes'] = 0 - return snapshots - -+ def last_snapshot_time(self) -> Optional[datetime]: -+ """直近のスナップショット取得 (フル/差分) 日時を返す。 -+ -+ 各スナップショットディレクトリ内のアーカイブ実体 -+ (``full.tar.zst`` / ``incr-*.tar.zst``) の mtime のうち最新のものを採用する。 -+ 差分更新は既存ディレクトリ名を再利用するため (ディレクトリ名の日付は世代 -+ 作成時のまま) ファイルの mtime を実測する方が正確で、メタデータの整合性にも -+ 依存しない。 -+ -+ ``meta.yml`` / ``snapshot.snar`` (listed-incremental 状態ファイル) や -+ ``.bak`` 等の付随ファイルは集計対象から除外する。これらはバックアップ本体の -+ 作成に失敗 (コピーや差分作成失敗) しても残りうるため、これらの mtime を採用 -+ すると「成功したバックアップ本体が無いのに up がスキップされる」状態を招く。 -+ -+ スナップショットが存在しない場合は None。 -+ """ -+ if not self.backups_dir.exists(): -+ return None -+ latest: Optional[float] = None -+ for snap_dir in self.backups_dir.iterdir(): -+ if not snap_dir.is_dir(): -+ continue -+ for f in snap_dir.iterdir(): -+ if not f.is_file(): -+ continue -+ # アーカイブ実体 (full.tar.zst / incr-NNN.tar.zst) のみを対象とし、 -+ # meta.yml / snapshot.snar / *.bak 等は除外する。 -+ if f.name != 'full.tar.zst' and not ( -+ f.name.startswith('incr-') and f.name.endswith('.tar.zst') -+ ): -+ continue -+ mtime = f.stat().st_mtime -+ if latest is None or mtime > latest: -+ latest = mtime -+ if latest is None: -+ return None -+ return datetime.fromtimestamp(latest) -+ - def restore(self, name: str, point: int | None = None) -> None: - """スナップショットから復元する。 - - Args: - name: スナップショット名 - point: 差分の適用上限(例: 3なら incr-003 まで適用)。 - Noneなら全差分を適用。 - """ - if point is not None and point <= 0: - raise SnapshotError(f"--point は正の整数である必要があります: {point}") - snap_dir = self._safe_snap_dir(name) - if not snap_dir.exists(): - raise SnapshotError(f"スナップショット '{name}' が見つかりません") - - full_archive = snap_dir / 'full.tar.zst' - if not full_archive.exists(): - raise SnapshotError(f"フルバックアップが見つかりません: {full_archive}") - - # 復元前に現在の状態を自動バックアップ - pre_restore_name = f"pre-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}" - logger.info("復元前に現在の状態をバックアップします: %s", pre_restore_name) - try: - self.create(name=pre_restore_name, full=True) - except Exception as e: - logger.warning("復元前バックアップに失敗しましたが続行します: %s", e) - - # フルバックアップの復元 - logger.info("フルバックアップを復元中...") - self._run_docker_tar( - snap_dir, 'restore', - "cd /target && find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + 2>/dev/null; " - "zstd -d /backup/full.tar.zst -c | tar --listed-incremental=/dev/null -xf -" - ) - - # 差分バックアップを順番に適用(pointが指定されていればそこまで) - incr_re = re.compile(r'^incr-(\d+)\.tar\.zst$') - incr_files = sorted(snap_dir.glob('incr-*.tar.zst')) - for incr in incr_files: - if point is not None: - m = incr_re.match(incr.name) - if not m: - continue - if int(m.group(1)) > point: - break - logger.info("差分バックアップを適用中: %s", incr.name) - self._run_docker_tar( - snap_dir, 'restore', - f"cd /target && zstd -d /backup/{incr.name} -c | tar --listed-incremental=/dev/null -xf -" - ) - - if point is not None: - logger.info("復元完了: %s (incr-%03d まで)", name, point) - else: - logger.info("復元完了: %s", name) - - def copy(self, name: str, new_name: str) -> None: - """スナップショットをコピーする""" - src = self._safe_snap_dir(name) - dst = self._safe_snap_dir(new_name) - if not src.exists(): - raise SnapshotError(f"スナップショット '{name}' が見つかりません") - if dst.exists(): - raise SnapshotError(f"スナップショット '{new_name}' は既に存在します") - - shutil.copytree(src, dst) - - # メタデータを更新 - meta = self._load_metadata() - # 元のスナップショットのメタデータを探してコピー - for snap in meta.get('snapshots', []): - if snap['name'] == name: - new_snap = dict(snap) - new_snap['name'] = new_name - new_snap['created_at'] = datetime.now().isoformat() - meta['snapshots'].append(new_snap) - break - self._save_metadata(meta) - logger.info("コピー完了: %s -> %s", name, new_name) - - def delete(self, name: str) -> None: - -exec -/bin/bash -lc 'git diff --unified=40 origin/main...HEAD -- docs/user/cli-reference.md' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -diff --git a/docs/user/cli-reference.md b/docs/user/cli-reference.md -index 43bb562..4752bf1 100644 ---- a/docs/user/cli-reference.md -+++ b/docs/user/cli-reference.md -@@ -128,80 +128,82 @@ devbase status - ```bash - # 任意のディレクトリから adminer プロジェクトを起動 - devbase project up adminer - - # 省略時は従来どおりカレントディレクトリのプロジェクトを対象にする - cd $DEVBASE_ROOT/projects/adminer && devbase project up - ``` - - - `` は `$DEVBASE_ROOT/projects/` 配下のプロジェクト名(`devbase project list` で確認可能) - - 存在しない名前を指定するとエラーになり、利用可能なプロジェクト候補が表示されます - - 名前解決はラッパー (`bin/devbase`) が対象ディレクトリへ `cd` してから実行します。 - これにより `build`(シェル実装)を含む全操作が名前指定で成立します - - `devbase` は PATH 上の実行ファイルとして子プロセスで起動されるため、この `cd` が - **呼び出し元シェルの作業ディレクトリを変えることはありません** - - > **`project login` / `project build` は `[name]` を取りません。** これらの単一引数はそれぞれ - > `index` / `image` であり、`[name]` を許すと `project login 2` / `project build web` が誤解釈される - > ため除外しています。一方、トップレベルシノニム `devbase build ` / `devbase login ` は - > ラッパー (`bin/devbase`) の存在性判定(`$DEVBASE_ROOT/projects/` が実在すれば cd)で - > 名前解決されます(実在しない場合は従来どおり `index` / `image` として下流へ渡されます)。 - - > **⚠ 衝突注意(footgun):** トップレベルシノニムの名前解決は「存在性ベース」のため、本来 - > positional 引数として渡したい値が実在プロジェクト名と一致すると、その引数が名前解決の対象と - > なり project への `cd` が優先されて引数の意味が変わります。例えば `projects/2` が存在する状態の - > `devbase login 2` は index=2 ではなく project `2` への操作に、`projects/web` が存在する状態の - > `devbase build web` は image=web ではなく project `web` のビルドに化けます(`scale` の service 引数 - > も同様)。これは「`build carmo` / `login carmo` でそのプロジェクトを操作する」意図的設計の - > トレードオフです。**回避策:** 衝突する場合は対象プロジェクトのディレクトリ内で実行するか、 - > 明示的にそのプロジェクトへ切り替えてから(`cd` 済みの状態で)コマンドを実行してください。 - - ### `devbase project up` - - コンテナを起動します。 - - ``` - devbase project up [name] - devbase up [name] - ``` - - - 起動時にスナップショットを自動作成(新世代 or 差分追加) -+ - 直近のスナップショット取得から既定 60 分以内のときはスキップします -+ - 間隔は `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` 環境変数で上書き可能(既定 60、`0` で無効化=毎回取得、不正値は警告して既定値) - - `CONTAINER_SCALE` の値に基づいてコンテナ数を決定 - - イメージの自動準備(`devbase up` は `devbase rebuild`=`devbase build --expires=7` 相当を実行): - - `build:` 定義あり、イメージ未存在 → `devbase build` を自動実行 - - `build:` 定義あり、イメージ存在 → プロジェクトイメージの作成日で再ビルドの要否を判定: - - 7日未満 → 再ビルドしない(既存イメージをそのまま使用) - - 7日以上 + ベースが閾値内=新しい → プロジェクトのみ no-cache(ベースはキャッシュ) - - 7日以上 + ベースが古い/判定不能 → ベースも含めて no-cache - - ベースイメージ `FROM devbase-*` の作成日はプロジェクトと独立して判定します - - `image:` のみ(公開イメージ)、未存在 → `docker pull` を自動実行 - - `image:` のみ、前回 pull から7日以上経過 → `docker pull` で再取得 - (前回 pull 日時は `${DEVBASE_ROOT}/.cache/pulls/` の touch-file mtime で判定) - - 閾値は `DEVBASE_IMAGE_MAX_AGE_DAYS` 環境変数で上書き可能(既定 7、不正値は警告して既定値) - - ### `devbase project down` - - コンテナを停止・削除します。 - - ``` - devbase project down [name] - devbase down [name] - ``` - - - 停止時にスナップショットのローテーションを自動実行 - - ### `devbase project login` - - コンテナにログインします。 - - ``` - devbase project login [index] - devbase login [index] - ``` - - | パラメータ | 必須 | デフォルト | 説明 | - |-----------|------|-----------|------| - | `index` | いいえ | `1` | ログインするコンテナの番号 | - - ```bash - # 1番目のコンテナにログイン - devbase login - -exec -/bin/bash -lc 'git diff --unified=80 origin/main...HEAD -- tests/snapshot/test_auto_snapshot.py' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -diff --git a/tests/snapshot/test_auto_snapshot.py b/tests/snapshot/test_auto_snapshot.py -new file mode 100644 -index 0000000..fa8985c ---- /dev/null -+++ b/tests/snapshot/test_auto_snapshot.py -@@ -0,0 +1,128 @@ -+"""自動スナップショットの最小間隔判定まわりの単体テスト。 -+ -+カバー対象: -+ - ``_snapshot_min_interval_minutes`` (commands/container.py): 環境変数の -+ パースと不正値フォールバック。 -+ - ``SnapshotManager.last_snapshot_time`` (snapshot/manager.py): アーカイブ実体 -+ (full.tar.zst / incr-*.tar.zst) の mtime のみを集計し、meta.yml / -+ snapshot.snar 等は除外することの確認。 -+""" -+ -+from __future__ import annotations -+ -+import os -+from datetime import datetime -+ -+import pytest -+ -+from devbase.commands.container import ( -+ _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT, -+ _snapshot_min_interval_minutes, -+) -+from devbase.snapshot.manager import SnapshotManager -+ -+ -+# --------------------------------------------------------------------------- -+# _snapshot_min_interval_minutes -+# --------------------------------------------------------------------------- -+ -+_ENV = 'DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES' -+ -+ -+def test_min_interval_unset_returns_default(monkeypatch): -+ monkeypatch.delenv(_ENV, raising=False) -+ assert _snapshot_min_interval_minutes() == _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT -+ assert _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT == 60 -+ -+ -+def test_min_interval_valid_value(monkeypatch): -+ monkeypatch.setenv(_ENV, '30') -+ assert _snapshot_min_interval_minutes() == 30 -+ -+ -+def test_min_interval_zero_disables(monkeypatch): -+ monkeypatch.setenv(_ENV, '0') -+ assert _snapshot_min_interval_minutes() == 0 -+ -+ -+def test_min_interval_negative_falls_back_with_warning(monkeypatch, caplog): -+ monkeypatch.setenv(_ENV, '-5') -+ with caplog.at_level('WARNING'): -+ result = _snapshot_min_interval_minutes() -+ assert result == _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT -+ assert any('Invalid' in r.getMessage() for r in caplog.records) -+ -+ -+def test_min_interval_non_numeric_falls_back_with_warning(monkeypatch, caplog): -+ monkeypatch.setenv(_ENV, 'abc') -+ with caplog.at_level('WARNING'): -+ result = _snapshot_min_interval_minutes() -+ assert result == _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT -+ assert any('Invalid' in r.getMessage() for r in caplog.records) -+ -+ -+# --------------------------------------------------------------------------- -+# SnapshotManager.last_snapshot_time -+# --------------------------------------------------------------------------- -+ -+def _touch(path, mtime: float) -> None: -+ path.write_bytes(b'x') -+ os.utime(path, (mtime, mtime)) -+ -+ -+def test_last_snapshot_time_empty_backups(tmp_path): -+ mgr = SnapshotManager(tmp_path) -+ # __init__ で backups ディレクトリは作成されるが中身は空。 -+ assert mgr.last_snapshot_time() is None -+ -+ -+def test_last_snapshot_time_missing_backups_dir(tmp_path): -+ mgr = SnapshotManager(tmp_path) -+ # backups ディレクトリ自体を消した場合も None。 -+ import shutil -+ shutil.rmtree(mgr.backups_dir) -+ assert mgr.last_snapshot_time() is None -+ -+ -+def test_last_snapshot_time_uses_newest_archive(tmp_path): -+ mgr = SnapshotManager(tmp_path) -+ snap_dir = mgr.backups_dir / '20240101-000000' -+ snap_dir.mkdir() -+ -+ old = 1_700_000_000.0 -+ new = 1_700_086_400.0 # +1 day -+ _touch(snap_dir / 'full.tar.zst', old) -+ _touch(snap_dir / 'incr-001.tar.zst', new) -+ -+ result = mgr.last_snapshot_time() -+ assert result == datetime.fromtimestamp(new) -+ -+ -+def test_last_snapshot_time_ignores_meta_and_snar(tmp_path): -+ """meta.yml / snapshot.snar が新しくても、アーカイブ実体の mtime を返す。""" -+ mgr = SnapshotManager(tmp_path) -+ snap_dir = mgr.backups_dir / '20240101-000000' -+ snap_dir.mkdir() -+ -+ archive_old = 1_700_000_000.0 -+ noise_new = 1_700_200_000.0 # アーカイブより新しい付随ファイル -+ -+ _touch(snap_dir / 'full.tar.zst', archive_old) -+ _touch(snap_dir / 'meta.yml', noise_new) -+ _touch(snap_dir / 'snapshot.snar', noise_new) -+ _touch(snap_dir / 'snapshot.snar.bak', noise_new) -+ -+ result = mgr.last_snapshot_time() -+ # 付随ファイルは除外され、アーカイブ実体の古い mtime が返るはず。 -+ assert result == datetime.fromtimestamp(archive_old) -+ -+ -+def test_last_snapshot_time_only_noise_returns_none(tmp_path): -+ """アーカイブ実体が一切無く付随ファイルだけの場合は None。""" -+ mgr = SnapshotManager(tmp_path) -+ snap_dir = mgr.backups_dir / '20240101-000000' -+ snap_dir.mkdir() -+ _touch(snap_dir / 'meta.yml', 1_700_200_000.0) -+ _touch(snap_dir / 'snapshot.snar', 1_700_200_000.0) -+ -+ assert mgr.last_snapshot_time() is None - -exec -/bin/bash -lc 'wc -l .cross_review/cross-review-pr75-existing-comments.txt' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: - 0 .cross_review/cross-review-pr75-existing-comments.txt - -exec -/bin/bash -lc "sed -n '1,220p' .cross_review/cross-review-pr75-existing-comments.txt" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -exec -/bin/bash -lc "sed -n '320,470p' lib/devbase/commands/container.py" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: - # name 指定時はディレクトリを解決して chdir する。解決失敗 (DEVBASE_ROOT 未設定 - # / 存在しない name) は候補提示の上でエラー終了する。 - if project_name: - if not _resolve_project_name(project_name): - return 1 - - handlers = { - 'up': lambda: cmd_up(project_name=project_name, - scale=getattr(args, 'scale', None), - open_editor=getattr(args, 'open_editor', None), - open_index=getattr(args, 'open_index', None)), - 'down': lambda: cmd_down(), - 'login': lambda: cmd_login(index=getattr(args, 'index', '1')), - 'ps': lambda: cmd_ps(all_containers=getattr(args, 'all', False)), - 'logs': lambda: cmd_logs(follow=getattr(args, 'follow', False), - tail=getattr(args, 'tail', None)), - 'scale': lambda: cmd_scale(new_scale=getattr(args, 'new_scale', None), - project_name=project_name), - 'build': lambda: cmd_build(image=getattr(args, 'image', None), - no_cache=getattr(args, 'no_cache', False), - expires=getattr(args, 'expires', None)), - 'rebuild': lambda: cmd_rebuild(), - } - - handler = handlers.get(subcmd) - if handler: - return handler() - - logger.error("サブコマンドを指定してください: %s", ', '.join(handlers)) - return 1 - - -def cmd_project(args) -> int: - """`devbase project [name]` ディスパッチャ (推奨エントリ)。""" - return _dispatch_lifecycle(args) - - -def cmd_container(args) -> int: - """`devbase container ` ディスパッチャ。 - - 非推奨: `devbase project` に移行してください (移行期間後に削除予定)。 - 挙動は `cmd_project` と同一で、警告のみ追加する。 - """ - logger.warning( - "`devbase container` は非推奨です。`devbase project` を使用してください " - "(将来のリリースで削除されます)。" - ) - return _dispatch_lifecycle(args) - - -# --------------------------------------------------------------------------- -# cmd_up (deploy.py の cmd_deploy を移植) -# --------------------------------------------------------------------------- - -_SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT = 60 - - -def _snapshot_min_interval_minutes() -> int: - """自動スナップショットをスキップする最小間隔 (分)。 - - 直近のスナップショット取得からこの分数未満なら ``_auto_snapshot`` はスキップする。 - DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES で上書き可能 (0 で無効化=毎回取得)。 - 値が不正な場合は既定値にフォールバックする。 - """ - raw = os.environ.get('DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES') - if not raw: - return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT - try: - value = int(raw) - if value < 0: - raise ValueError - return value - except ValueError: - logger.warning( - "Invalid DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=%r, using default %d", - raw, _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT - ) - return _SNAPSHOT_MIN_INTERVAL_MINUTES_DEFAULT - - -def _auto_snapshot() -> None: - """デプロイ前の自動スナップショット (差分世代数ベース世代管理)。 - - 失敗してもデプロイは続行する (warning のみ)。DEVBASE_ROOT 未設定なら no-op。 - """ - devbase_root = os.environ.get('DEVBASE_ROOT') - if not devbase_root: - return - try: - from datetime import datetime, timedelta - - from devbase.snapshot.manager import SnapshotManager - mgr = SnapshotManager(Path(devbase_root)) - min_interval = _snapshot_min_interval_minutes() - last = mgr.last_snapshot_time() - if min_interval > 0 and last is not None: - # 経過時間が負 (last が未来) の場合はスキップしない。システム時計の - # ズレや他環境からのリストアで last が未来になると delta が負になり、 - # 常に閾値未満と判定されて無期限にスキップされてしまうため、 - # timedelta(0) <= delta の下限ガードを設ける。 - delta = datetime.now() - last - if timedelta(0) <= delta < timedelta(minutes=min_interval): - logger.info( - "[0/6] 直近のスナップショット (%s) から%d分以内のためスキップします", - last.strftime('%Y-%m-%d %H:%M:%S'), min_interval, - ) - return - if mgr.should_start_new_generation(): - logger.info("[0/6] 新しいスナップショット世代を作成中...") - mgr.create() - else: - latest = mgr.list()[-1]['name'] - logger.info("[0/6] スナップショットを差分更新中: %s", latest) - mgr.create(name=latest, full=False) - mgr.rotate() - except Exception as e: - logger.warning("スナップショットの自動作成に失敗しましたがデプロイは続行します: %s", e) - - -def _resolve_open_index(open_index: Optional[int], scale: int) -> int: - """開く dev インスタンス番号を解決する (CLI 引数 → env ``DEVBASE_OPEN_INDEX`` → 既定 1)。 - - ``1..scale`` の範囲外 (0・負数・``scale`` 超過) は存在しないコンテナを指し原因不明な - 起動失敗を招くため、警告を出して 1 へフォールバックする。:func:`_maybe_open_editor` - で env フォールバック・範囲チェックを共有する。 - """ - if open_index is None: - raw = os.environ.get('DEVBASE_OPEN_INDEX') - try: - open_index = int(raw) if raw else 1 - except ValueError: - open_index = 1 - if not (1 <= open_index <= scale): - logger.warning( - "open index %d is out of range (1..%d); falling back to 1", - open_index, scale, - ) - open_index = 1 - return open_index - - -def _maybe_open_editor(project_name: str, open_flag: Optional[bool], - open_index: Optional[int], scale: int, - compose_file=None) -> None: - """`up` 完了後に dev コンテナへ接続したエディタを開く ([6/6])。 - - 有効判定は ``open_flag`` (CLI ``--open``/``--no-open``) が優先、None なら env - ``DEVBASE_OPEN_EDITOR``。エディタ起動の成否は ``up`` の戻り値に影響させない。 - - ``open_index`` は起動済みインスタンス範囲 ``1..scale`` 内である必要がある。 - 0・負数・``scale`` 超過は存在しないコンテナ URI になり原因不明な起動失敗を招くため、 - -exec -/bin/bash -lc "sed -n '1,220p' lib/devbase/snapshot/manager.py" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -"""スナップショット管理のコアロジック""" - -import re -import shutil -import subprocess -from datetime import datetime -from pathlib import Path -from typing import Optional - -import yaml - -from devbase.errors import SnapshotError -from devbase.log import get_logger - -logger = get_logger(__name__) - -VOLUME_NAME = 'devbase_home_ubuntu' -SNAPSHOT_IMAGE = 'devbase-snapshot:latest' -DEFAULT_MAX_GENERATIONS = 3 -DEFAULT_MAX_INCREMENTALS = 10 -METADATA_FILE = 'snapshot.yml' -_VALID_NAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]*$') - - -class SnapshotManager: - """Docker volumeのスナップショット管理""" - - def __init__(self, devbase_root: Path): - self.devbase_root = devbase_root - self.backups_dir = devbase_root / 'backups' - self.backups_dir.mkdir(exist_ok=True) - self._metadata_path = self.backups_dir / METADATA_FILE - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - @staticmethod - def _validate_name(name: str) -> None: - """スナップショット名のバリデーション(パストラバーサル防止)""" - if not name or not _VALID_NAME_RE.match(name): - raise SnapshotError( - f"無効なスナップショット名: '{name}' " - "(英数字・ハイフン・アンダースコア・ドットのみ使用可能、先頭は英数字)" - ) - - def _safe_snap_dir(self, name: str) -> Path: - """名前からスナップショットディレクトリを安全に解決する""" - self._validate_name(name) - snap_dir = (self.backups_dir / name).resolve() - if not str(snap_dir).startswith(str(self.backups_dir.resolve())): - raise SnapshotError(f"無効なスナップショットパス: '{name}'") - return snap_dir - - def create(self, name: Optional[str] = None, full: bool = False) -> str: - """スナップショットを作成する。 - - Args: - name: スナップショット名(省略時はタイムスタンプ) - full: Trueならフルバックアップを強制 - - Returns: - 作成されたスナップショット名 - """ - if name is None: - name = datetime.now().strftime('%Y%m%d-%H%M%S') - - snap_dir = self._safe_snap_dir(name) - is_new = not snap_dir.exists() - - if is_new: - snap_dir.mkdir(parents=True) - full = True # 初回は常にフル - - if full: - self._create_full(name, snap_dir) - else: - self._create_incremental(name, snap_dir) - - self._update_global_metadata(name, snap_dir) - return name - - def list(self) -> list[dict]: - """スナップショット一覧を返す""" - meta = self._load_metadata() - snapshots = meta.get('snapshots', []) - # ディレクトリの実サイズも取得 - for snap in snapshots: - snap_dir = self.backups_dir / snap['name'] - if snap_dir.exists(): - snap['size_bytes'] = sum( - f.stat().st_size for f in snap_dir.iterdir() if f.is_file() - ) - else: - snap['size_bytes'] = 0 - return snapshots - - def last_snapshot_time(self) -> Optional[datetime]: - """直近のスナップショット取得 (フル/差分) 日時を返す。 - - 各スナップショットディレクトリ内のアーカイブ実体 - (``full.tar.zst`` / ``incr-*.tar.zst``) の mtime のうち最新のものを採用する。 - 差分更新は既存ディレクトリ名を再利用するため (ディレクトリ名の日付は世代 - 作成時のまま) ファイルの mtime を実測する方が正確で、メタデータの整合性にも - 依存しない。 - - ``meta.yml`` / ``snapshot.snar`` (listed-incremental 状態ファイル) や - ``.bak`` 等の付随ファイルは集計対象から除外する。これらはバックアップ本体の - 作成に失敗 (コピーや差分作成失敗) しても残りうるため、これらの mtime を採用 - すると「成功したバックアップ本体が無いのに up がスキップされる」状態を招く。 - - スナップショットが存在しない場合は None。 - """ - if not self.backups_dir.exists(): - return None - latest: Optional[float] = None - for snap_dir in self.backups_dir.iterdir(): - if not snap_dir.is_dir(): - continue - for f in snap_dir.iterdir(): - if not f.is_file(): - continue - # アーカイブ実体 (full.tar.zst / incr-NNN.tar.zst) のみを対象とし、 - # meta.yml / snapshot.snar / *.bak 等は除外する。 - if f.name != 'full.tar.zst' and not ( - f.name.startswith('incr-') and f.name.endswith('.tar.zst') - ): - continue - mtime = f.stat().st_mtime - if latest is None or mtime > latest: - latest = mtime - if latest is None: - return None - return datetime.fromtimestamp(latest) - - def restore(self, name: str, point: int | None = None) -> None: - """スナップショットから復元する。 - - Args: - name: スナップショット名 - point: 差分の適用上限(例: 3なら incr-003 まで適用)。 - Noneなら全差分を適用。 - """ - if point is not None and point <= 0: - raise SnapshotError(f"--point は正の整数である必要があります: {point}") - snap_dir = self._safe_snap_dir(name) - if not snap_dir.exists(): - raise SnapshotError(f"スナップショット '{name}' が見つかりません") - - full_archive = snap_dir / 'full.tar.zst' - if not full_archive.exists(): - raise SnapshotError(f"フルバックアップが見つかりません: {full_archive}") - - # 復元前に現在の状態を自動バックアップ - pre_restore_name = f"pre-restore-{datetime.now().strftime('%Y%m%d-%H%M%S')}" - logger.info("復元前に現在の状態をバックアップします: %s", pre_restore_name) - try: - self.create(name=pre_restore_name, full=True) - except Exception as e: - logger.warning("復元前バックアップに失敗しましたが続行します: %s", e) - - # フルバックアップの復元 - logger.info("フルバックアップを復元中...") - self._run_docker_tar( - snap_dir, 'restore', - "cd /target && find . -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + 2>/dev/null; " - "zstd -d /backup/full.tar.zst -c | tar --listed-incremental=/dev/null -xf -" - ) - - # 差分バックアップを順番に適用(pointが指定されていればそこまで) - incr_re = re.compile(r'^incr-(\d+)\.tar\.zst$') - incr_files = sorted(snap_dir.glob('incr-*.tar.zst')) - for incr in incr_files: - if point is not None: - m = incr_re.match(incr.name) - if not m: - continue - if int(m.group(1)) > point: - break - logger.info("差分バックアップを適用中: %s", incr.name) - self._run_docker_tar( - snap_dir, 'restore', - f"cd /target && zstd -d /backup/{incr.name} -c | tar --listed-incremental=/dev/null -xf -" - ) - - if point is not None: - logger.info("復元完了: %s (incr-%03d まで)", name, point) - else: - logger.info("復元完了: %s", name) - - def copy(self, name: str, new_name: str) -> None: - """スナップショットをコピーする""" - src = self._safe_snap_dir(name) - dst = self._safe_snap_dir(new_name) - if not src.exists(): - raise SnapshotError(f"スナップショット '{name}' が見つかりません") - if dst.exists(): - raise SnapshotError(f"スナップショット '{new_name}' は既に存在します") - - shutil.copytree(src, dst) - - # メタデータを更新 - meta = self._load_metadata() - # 元のスナップショットのメタデータを探してコピー - for snap in meta.get('snapshots', []): - if snap['name'] == name: - new_snap = dict(snap) - new_snap['name'] = new_name - new_snap['created_at'] = datetime.now().isoformat() - meta['snapshots'].append(new_snap) - break - self._save_metadata(meta) - logger.info("コピー完了: %s -> %s", name, new_name) - - def delete(self, name: str) -> None: - """スナップショットを削除する""" - snap_dir = self._safe_snap_dir(name) - if not snap_dir.exists(): - raise SnapshotError(f"スナップショット '{name}' が見つかりません") - - -codex -既存コメントスナップショットは空でした。差分単体では小さいですが、snapshot 管理の既存メタデータ仕様と新しい mtime 判定が衝突しないかを中心に見ています。 -exec -/bin/bash -lc 'rg -n "should_start_new_generation|_update_global_metadata|_create_incremental|_create_full|created_at|incrementals|meta.yml|snapshot.snar" lib/devbase/snapshot/manager.py' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -76: self._create_full(name, snap_dir) -78: self._create_incremental(name, snap_dir) -80: self._update_global_metadata(name, snap_dir) -107: ``meta.yml`` / ``snapshot.snar`` (listed-incremental 状態ファイル) や -124: # meta.yml / snapshot.snar / *.bak 等は除外する。 -209: new_snap['created_at'] = datetime.now().isoformat() -243: # 古い順にソート(created_atベース) -244: snapshots.sort(key=lambda s: s.get('created_at', '')) -262: def should_start_new_generation( -263: self, max_incrementals: int = DEFAULT_MAX_INCREMENTALS, -268: max_incrementals: 1世代あたりの最大差分バックアップ数 -279: return latest.get('incremental_count', 0) >= max_incrementals -355: def _create_full(self, name: str, snap_dir: Path) -> None: -360: "tar --listed-incremental=/backup/snapshot.snar " -364: # meta.yml を作成 -367: 'created_at': datetime.now().isoformat(), -375: def _create_incremental(self, name: str, snap_dir: Path) -> None: -377: snar_file = snap_dir / 'snapshot.snar' -381: self._create_full(name, snap_dir) -393: f"cp /backup/snapshot.snar /backup/snapshot.snar.bak && " -394: f"tar --listed-incremental=/backup/snapshot.snar " -398: # meta.yml を更新 -405: def _update_global_metadata(self, name: str, snap_dir: Path) -> None: -424: 'created_at': now, -444: """個別スナップショットのmeta.ymlを読み込む""" -445: meta_path = snap_dir / 'meta.yml' -452: """個別スナップショットのmeta.ymlを保存する""" -453: meta_path = snap_dir / 'meta.yml' - -exec -/bin/bash -lc "sed -n '220,520p' lib/devbase/snapshot/manager.py" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: - - shutil.rmtree(snap_dir) - - # メタデータから削除 - meta = self._load_metadata() - meta['snapshots'] = [ - s for s in meta.get('snapshots', []) if s['name'] != name - ] - self._save_metadata(meta) - logger.info("削除完了: %s", name) - - def rotate(self, keep: int = DEFAULT_MAX_GENERATIONS) -> int: - """古い世代を削除する。 - - Returns: - 削除された世代数 - """ - meta = self._load_metadata() - snapshots = meta.get('snapshots', []) - - if len(snapshots) <= keep: - return 0 - - # 古い順にソート(created_atベース) - snapshots.sort(key=lambda s: s.get('created_at', '')) - to_delete = snapshots[:-keep] - - deleted = 0 - for snap in to_delete: - snap_dir = self.backups_dir / snap['name'] - if snap_dir.exists(): - shutil.rmtree(snap_dir) - deleted += 1 - - meta['snapshots'] = snapshots[-keep:] - meta['max_generations'] = keep - self._save_metadata(meta) - - if deleted: - logger.info("ローテーション: %d 世代を削除しました(%d 世代保持)", deleted, keep) - return deleted - - def should_start_new_generation( - self, max_incrementals: int = DEFAULT_MAX_INCREMENTALS, - ) -> bool: - """最新世代の差分バックアップ数が上限に達しているか判定する。 - - Args: - max_incrementals: 1世代あたりの最大差分バックアップ数 - - Returns: - True: 新世代を作成すべき(スナップショットなし or 差分数が上限以上) - False: 既存世代に差分を追加すべき - """ - meta = self._load_metadata() - snapshots = meta.get('snapshots', []) - if not snapshots: - return True - latest = snapshots[-1] - return latest.get('incremental_count', 0) >= max_incrementals - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - def _ensure_snapshot_image(self) -> str: - """スナップショット専用イメージを確保する(なければ自動ビルド)""" - try: - subprocess.run( - ['docker', 'image', 'inspect', SNAPSHOT_IMAGE], - capture_output=True, check=True - ) - return SNAPSHOT_IMAGE - except subprocess.CalledProcessError: - dockerfile_dir = self.devbase_root / 'containers' / 'snapshot' - if not dockerfile_dir.exists(): - raise SnapshotError( - f"スナップショット用Dockerfileが見つかりません: {dockerfile_dir}" - ) - logger.info("devbase-snapshotイメージをビルド中...") - build_cmds = [ - ['docker', 'buildx', 'build', '--load', - '-t', SNAPSHOT_IMAGE, str(dockerfile_dir)], - ['docker', 'build', - '-t', SNAPSHOT_IMAGE, str(dockerfile_dir)], - ] - last_err = None - for cmd in build_cmds: - try: - subprocess.run( - cmd, check=True, capture_output=True, text=True - ) - last_err = None - break - except (subprocess.CalledProcessError, FileNotFoundError) as e: - last_err = e - if last_err is not None: - stderr = getattr(last_err, 'stderr', str(last_err)) - raise SnapshotError( - f"devbase-snapshotのビルドに失敗: {stderr}" - ) from last_err - logger.info("devbase-snapshotイメージのビルド完了") - return SNAPSHOT_IMAGE - - def _run_docker_tar(self, snap_dir: Path, mode: str, command: str) -> None: - """Docker経由でtar操作を実行する。 - - Args: - snap_dir: スナップショットディレクトリ - mode: 'backup' or 'restore' - command: コンテナ内で実行するコマンド - """ - image = self._ensure_snapshot_image() - - abs_snap_dir = snap_dir.resolve() - volume_mount = f'{VOLUME_NAME}:/source:ro' if mode == 'backup' else f'{VOLUME_NAME}:/target' - backup_mount = f'{abs_snap_dir}:/backup:ro' if mode == 'restore' else f'{abs_snap_dir}:/backup' - - cmd = [ - 'docker', 'run', '--rm', - '-v', volume_mount, - '-v', backup_mount, - image, - 'bash', '-c', command, - ] - - try: - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - if result.stdout.strip(): - logger.debug(result.stdout.strip()) - except subprocess.CalledProcessError as e: - raise SnapshotError( - f"Dockerでのtar操作に失敗しました: {e.stderr}" - ) from e - - def _create_full(self, name: str, snap_dir: Path) -> None: - """フルバックアップを作成""" - logger.info("フルバックアップを作成中: %s", name) - self._run_docker_tar( - snap_dir, 'backup', - "tar --listed-incremental=/backup/snapshot.snar " - "-cf - -C /source . | zstd -1 -T0 -o /backup/full.tar.zst" - ) - - # meta.yml を作成 - meta = { - 'name': name, - 'created_at': datetime.now().isoformat(), - 'type': 'full', - 'volume': VOLUME_NAME, - 'files': ['full.tar.zst'], - 'incremental_count': 0, - } - self._save_snap_meta(snap_dir, meta) - - def _create_incremental(self, name: str, snap_dir: Path) -> None: - """差分バックアップを作成""" - snar_file = snap_dir / 'snapshot.snar' - if not snar_file.exists(): - # snarファイルがなければフルバックアップにフォールバック - logger.info("snarファイルが見つかりません、フルバックアップに切り替えます") - self._create_full(name, snap_dir) - return - - # 差分番号を決定 - existing = sorted(snap_dir.glob('incr-*.tar.zst')) - next_num = len(existing) + 1 - incr_name = f'incr-{next_num:03d}.tar.zst' - - logger.info("差分バックアップを作成中: %s/%s", name, incr_name) - - self._run_docker_tar( - snap_dir, 'backup', - f"cp /backup/snapshot.snar /backup/snapshot.snar.bak && " - f"tar --listed-incremental=/backup/snapshot.snar " - f"-cf - -C /source . | zstd -1 -T0 -o /backup/{incr_name}" - ) - - # meta.yml を更新 - snap_meta = self._load_snap_meta(snap_dir) - snap_meta['type'] = 'incremental' - snap_meta['files'].append(incr_name) - snap_meta['incremental_count'] = next_num - self._save_snap_meta(snap_dir, snap_meta) - - def _update_global_metadata(self, name: str, snap_dir: Path) -> None: - """グローバルメタデータ(snapshot.yml)を更新""" - meta = self._load_metadata() - now = datetime.now().isoformat() - - snap_meta = self._load_snap_meta(snap_dir) - - # 既存エントリを探す - found = False - for snap in meta.get('snapshots', []): - if snap['name'] == name: - snap['updated_at'] = now - snap['incremental_count'] = snap_meta.get('incremental_count', 0) - found = True - break - - if not found: - meta.setdefault('snapshots', []).append({ - 'name': name, - 'created_at': now, - 'updated_at': now, - 'incremental_count': snap_meta.get('incremental_count', 0), - }) - - self._save_metadata(meta) - - def _load_metadata(self) -> dict: - """グローバルメタデータを読み込む""" - if self._metadata_path.exists(): - with open(self._metadata_path) as f: - return yaml.safe_load(f) or {} - return {'max_generations': DEFAULT_MAX_GENERATIONS, 'snapshots': []} - - def _save_metadata(self, meta: dict) -> None: - """グローバルメタデータを保存する""" - with open(self._metadata_path, 'w') as f: - yaml.dump(meta, f, default_flow_style=False, allow_unicode=True) - - def _load_snap_meta(self, snap_dir: Path) -> dict: - """個別スナップショットのmeta.ymlを読み込む""" - meta_path = snap_dir / 'meta.yml' - if meta_path.exists(): - with open(meta_path) as f: - return yaml.safe_load(f) or {} - return {} - - def _save_snap_meta(self, snap_dir: Path, meta: dict) -> None: - """個別スナップショットのmeta.ymlを保存する""" - meta_path = snap_dir / 'meta.yml' - with open(meta_path, 'w') as f: - yaml.dump(meta, f, default_flow_style=False, allow_unicode=True) - -exec -/bin/bash -lc "find . -maxdepth 3 -type f \\( -name 'pyproject.toml' -o -name 'pytest.ini' -o -name 'tox.ini' -o -name 'setup.cfg' \\) -print" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -./pyproject.toml - -exec -/bin/bash -lc "rg -n \"_auto_snapshot|cmd_up\\(|cmd_down\\(|rotate\\(\" lib/devbase/commands lib/devbase tests" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -lib/devbase/commands/snapshot.py:39: 'rotate': lambda: _snapshot_rotate(mgr, keep=getattr(args, 'keep', 3)), -lib/devbase/commands/snapshot.py:101:def _snapshot_rotate(mgr, keep=3) -> int: -lib/devbase/commands/snapshot.py:102: deleted = mgr.rotate(keep=keep) -lib/devbase/commands/container.py:312: 散らさずここで実施するのは、`cmd_down()` / `cmd_login()` / `cmd_logs()` 等が -lib/devbase/commands/container.py:327: 'up': lambda: cmd_up(project_name=project_name, -lib/devbase/commands/container.py:331: 'down': lambda: cmd_down(), -lib/devbase/commands/container.py:380: 直近のスナップショット取得からこの分数未満なら ``_auto_snapshot`` はスキップする。 -lib/devbase/commands/container.py:400:def _auto_snapshot() -> None: -lib/devbase/commands/container.py:434: mgr.rotate() -lib/devbase/commands/container.py:505:def cmd_up(project_name: str = None, scale: int = None, -lib/devbase/commands/container.py:539: _auto_snapshot() -lib/devbase/commands/container.py:592:def cmd_down() -> int: -lib/devbase/commands/container.py:602: mgr.rotate() -lib/devbase/tui/actions_snapshot.py:145:def _op_rotate(devbase_root: Path): -lib/devbase/commands/snapshot.py:39: 'rotate': lambda: _snapshot_rotate(mgr, keep=getattr(args, 'keep', 3)), -lib/devbase/commands/snapshot.py:101:def _snapshot_rotate(mgr, keep=3) -> int: -lib/devbase/commands/snapshot.py:102: deleted = mgr.rotate(keep=keep) -lib/devbase/commands/container.py:312: 散らさずここで実施するのは、`cmd_down()` / `cmd_login()` / `cmd_logs()` 等が -lib/devbase/commands/container.py:327: 'up': lambda: cmd_up(project_name=project_name, -lib/devbase/commands/container.py:331: 'down': lambda: cmd_down(), -lib/devbase/commands/container.py:380: 直近のスナップショット取得からこの分数未満なら ``_auto_snapshot`` はスキップする。 -lib/devbase/commands/container.py:400:def _auto_snapshot() -> None: -lib/devbase/commands/container.py:434: mgr.rotate() -lib/devbase/commands/container.py:505:def cmd_up(project_name: str = None, scale: int = None, -lib/devbase/commands/container.py:539: _auto_snapshot() -lib/devbase/commands/container.py:592:def cmd_down() -> int: -lib/devbase/commands/container.py:602: mgr.rotate() -lib/devbase/snapshot/manager.py:231: def rotate(self, keep: int = DEFAULT_MAX_GENERATIONS) -> int: -tests/cli/test_project_dispatch.py:119:def test_lifecycle_passes_name_to_cmd_up(monkeypatch): -tests/cli/test_project_dispatch.py:342:def test_lifecycle_propagates_open_args_to_cmd_up(monkeypatch): - -codex -`last_snapshot_time()` は snapshot.yml ではなくバックアップ実体の mtime を見ています。ここが「管理対象外ディレクトリ」やコピー済みバックアップを interval 判定に含めるため、期待仕様として成立するかを検証しています。 -exec -/bin/bash -lc "sed -n '1,180p' lib/devbase/commands/snapshot.py" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -"""Snapshot command implementation""" - -import sys -from pathlib import Path - -from devbase.errors import SnapshotError -from devbase.log import get_logger -from devbase.snapshot.manager import SnapshotManager - -logger = get_logger(__name__) - - -def _format_size(size_bytes: int) -> str: - """バイト数を人間が読みやすい形式に変換""" - for unit in ('B', 'KB', 'MB', 'GB'): - if size_bytes < 1024: - return f"{size_bytes:.1f}{unit}" - size_bytes /= 1024 - return f"{size_bytes:.1f}TB" - - -def cmd_snapshot(devbase_root: Path, args) -> int: - """snapshotサブコマンドの振り分け""" - mgr = SnapshotManager(devbase_root) - subcmd = getattr(args, 'subcommand', None) - - handlers = { - 'create': lambda: _snapshot_create(mgr, - name=getattr(args, 'name', None), - full=getattr(args, 'full', False)), - 'list': lambda: _snapshot_list(mgr), - 'restore': lambda: _snapshot_restore(mgr, - name=getattr(args, 'name', ''), - point=getattr(args, 'point', None)), - 'copy': lambda: _snapshot_copy(mgr, - name=getattr(args, 'name', ''), - new_name=getattr(args, 'new_name', '')), - 'delete': lambda: _snapshot_delete(mgr, name=getattr(args, 'name', '')), - 'rotate': lambda: _snapshot_rotate(mgr, keep=getattr(args, 'keep', 3)), - } - - handler = handlers.get(subcmd) - if not handler: - logger.error("サブコマンドを指定してください: %s", ', '.join(handlers)) - return 1 - - try: - return handler() - except SnapshotError as e: - logger.error("スナップショット操作に失敗: %s", e) - return 1 - - -def _snapshot_create(mgr, name=None, full=False) -> int: - name = mgr.create(name=name, full=full) - logger.info("スナップショットを作成しました: %s", name) - return 0 - - -def _snapshot_list(mgr) -> int: - snapshots = mgr.list() - if not snapshots: - print("スナップショットはありません") - return 0 - print(f"{'名前':<24} {'作成日時':<24} {'差分数':>6} {'サイズ':>10}") - print("-" * 68) - for s in snapshots: - print( - f"{s['name']:<24} " - f"{s.get('created_at', 'N/A')[:19]:<24} " - f"{s.get('incremental_count', 0):>6} " - f"{_format_size(s.get('size_bytes', 0)):>10}" - ) - return 0 - - -def _snapshot_restore(mgr, name='', point=None) -> int: - point_msg = f" (incr-{point:03d} まで)" if point is not None else "" - if sys.stdin.isatty(): - answer = input( - f"'{name}'{point_msg} から復元します。現在のボリュームデータは上書きされます。\n" - f"続行しますか? [y/N]: " - ) - if answer.lower() not in ('y', 'yes'): - print("復元をキャンセルしました") - return 0 - mgr.restore(name, point=point) - return 0 - - -def _snapshot_copy(mgr, name='', new_name='') -> int: - mgr.copy(name, new_name) - return 0 - - -def _snapshot_delete(mgr, name='') -> int: - mgr.delete(name) - return 0 - - -def _snapshot_rotate(mgr, keep=3) -> int: - deleted = mgr.rotate(keep=keep) - if deleted == 0: - logger.info("ローテーション不要です") - return 0 - -exec -/bin/bash -lc "sed -n '500,625p' lib/devbase/commands/container.py" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: - ) - except Exception as e: # noqa: BLE001 - エディタ起動で up を倒さない - logger.warning("エディタの自動オープンに失敗しましたがデプロイは成功しています: %s", e) - - -def cmd_up(project_name: str = None, scale: int = None, - open_editor: Optional[bool] = None, - open_index: Optional[int] = None) -> int: - """Deploy containers with specified scale""" - if project_name is None: - project_name = get_project_name() - - if scale is None: - scale = get_container_scale() - - dev_service_name = get_dev_service_name() - - logger.info("Deploying project '%s' with scale=%d (dev service: %s)", - project_name, scale, dev_service_name) - - # Pre-check 1: Ensure .env file exists with content - if not _ensure_env_files(): - logger.error("Failed to create .env file. Please run 'devbase env init' manually.") - return 1 - - # Pre-step: Run ./pre-up hook (e.g. clone source repos used as build contexts) - if not _run_pre_up_hook(): - return 1 - - # Pre-check 2: Ensure container images exist - if not _ensure_images(): - logger.error( - "Failed to ensure container images. " - "Run 'devbase container build' for build-based services, " - "or 'docker pull ' for image-only services." - ) - return 1 - - # Pre-step: Auto snapshot(差分世代数ベース世代管理) - _auto_snapshot() - - try: - logger.info("[1/6] Ensuring volumes exist...") - ensure_volumes(scale, project_name) - - logger.info("[1.5/6] Ensuring network exists...") - ensure_network('devbase_net') - - logger.info("[2/6] Stopping existing containers...") - if _SCALE_COMPOSE_FILE.exists(): - docker_compose_down(compose_file=_SCALE_COMPOSE_FILE) - else: - docker_compose_down() - - logger.info("[3/6] Generating scaled compose file...") - override_file = generate_scaled_compose(scale, project_name) - logger.info("Generated: %s", override_file) - - logger.info("[4/6] Starting containers...") - docker_compose_up(compose_file=override_file, detach=True) - - logger.info("[5/6] Waiting for containers to be ready...") - wait_for_containers_ready( - container_prefix=dev_service_name, - scale=scale, - compose_file=override_file, - timeout=60 - ) - - # Run project-specific deploy script for each scaled instance - deploy_script = Path('./deploy') - if deploy_script.exists() and deploy_script.is_file(): - _run_deploy_script_for_instances(deploy_script, range(1, scale + 1)) - - _maybe_open_editor(project_name, open_editor, open_index, scale, - compose_file=override_file) - - logger.info("=== Deploy completed successfully ===") - return 0 - - except DevbaseError as e: - logger.error("Deploy failed: %s", e) - return 1 - except subprocess.CalledProcessError as e: - logger.error("Deploy failed: %s", e) - return 1 - - -# --------------------------------------------------------------------------- -# cmd_down -# --------------------------------------------------------------------------- - -def cmd_down() -> int: - """Stop and remove containers""" - compose_file = _SCALE_COMPOSE_FILE if _SCALE_COMPOSE_FILE.exists() else None - docker_compose_down(compose_file=compose_file) - - devbase_root = os.environ.get('DEVBASE_ROOT') - if devbase_root: - try: - from devbase.snapshot.manager import SnapshotManager - mgr = SnapshotManager(Path(devbase_root)) - mgr.rotate() - except Exception as e: - logger.warning("スナップショットのローテーションに失敗: %s", e) - - return 0 - - -# --------------------------------------------------------------------------- -# cmd_login -# --------------------------------------------------------------------------- - -def cmd_login(index: str = '1') -> int: - """Login to container""" - dev_service = get_dev_service_name() - - if _SCALE_COMPOSE_FILE.exists(): - cmd = ['docker', 'compose', '-f', str(_SCALE_COMPOSE_FILE), - 'exec', f'{dev_service}-{index}', 'bash'] - else: - cmd = ['docker', 'compose', 'exec', f'--index={index}', - dev_service, 'bash'] - - return subprocess.run(cmd).returncode - - -exec -/bin/bash -lc "sed -n '1,220p' pyproject.toml" in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -[project] -name = "devbase" -version = "2.2.0" -description = "Docker-based Development Environment Manager" -requires-python = ">=3.10" -dependencies = [ - "pyyaml>=6.0", - "pyrage>=1.2", - "boto3>=1.34", - "questionary>=2.1", -] - -[dependency-groups] -dev = [ - "pyte>=0.8.2", - "pytest>=8.0", -] - -[tool.uv] -package = false - -[tool.pytest.ini_options] -testpaths = ["tests"] -pythonpath = ["lib"] - -[tool.uv.sources] - -exec -/bin/bash -lc "rg -n \"snapshot\\.yml|last_snapshot|incremental_count|DEVBASE_SNAPSHOT_MIN_INTERVAL\" -S ." in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -./docs/user/cli-reference.md:169: - 間隔は `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` 環境変数で上書き可能(既定 60、`0` で無効化=毎回取得、不正値は警告して既定値) -./docs/user/snapshot-guide.md:105:├── snapshot.yml # スナップショット全体のメタデータ -./docs/user/snapshot-guide.md:120:| `snapshot.yml` | 全世代のインデックス情報 | -./gh-payload.json:4: "body": "## 🤖 cross-review | round 2 | gemini | REQUEST_CHANGES\n\n- 新機能 `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` に関するテストコードが追加されていません。設定値のパースロジック(`0`、負の値、不正な文字列)および `last_snapshot_time()` の挙動について、単体テストの追加をお願いします。", -./gh-payload.json:9: "body": "[major / テスト]\n環境変数 `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` のパース処理について、`tests/cli/test_...` 等に単体テストがありません。負の値やパース不能な文字列のフォールバック挙動が期待通り動作することを保証するテストを追加してください。" -./gh-payload.json:14: "body": "[minor / テスト]\n`last_snapshot_time()` メソッドについて、スナップショットディレクトリが空の場合や存在しない場合の挙動を確認するテストケースの追加を検討してください。" -./tests/snapshot/test_auto_snapshot.py:6: - ``SnapshotManager.last_snapshot_time`` (snapshot/manager.py): アーカイブ実体 -./tests/snapshot/test_auto_snapshot.py:29:_ENV = 'DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES' -./tests/snapshot/test_auto_snapshot.py:65:# SnapshotManager.last_snapshot_time -./tests/snapshot/test_auto_snapshot.py:73:def test_last_snapshot_time_empty_backups(tmp_path): -./tests/snapshot/test_auto_snapshot.py:76: assert mgr.last_snapshot_time() is None -./tests/snapshot/test_auto_snapshot.py:79:def test_last_snapshot_time_missing_backups_dir(tmp_path): -./tests/snapshot/test_auto_snapshot.py:84: assert mgr.last_snapshot_time() is None -./tests/snapshot/test_auto_snapshot.py:87:def test_last_snapshot_time_uses_newest_archive(tmp_path): -./tests/snapshot/test_auto_snapshot.py:97: result = mgr.last_snapshot_time() -./tests/snapshot/test_auto_snapshot.py:101:def test_last_snapshot_time_ignores_meta_and_snar(tmp_path): -./tests/snapshot/test_auto_snapshot.py:115: result = mgr.last_snapshot_time() -./tests/snapshot/test_auto_snapshot.py:120:def test_last_snapshot_time_only_noise_returns_none(tmp_path): -./tests/snapshot/test_auto_snapshot.py:128: assert mgr.last_snapshot_time() is None -./lib/devbase/commands/snapshot.py:71: f"{s.get('incremental_count', 0):>6} " -./lib/devbase/commands/container.py:381: DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES で上書き可能 (0 で無効化=毎回取得)。 -./lib/devbase/commands/container.py:384: raw = os.environ.get('DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES') -./lib/devbase/commands/container.py:394: "Invalid DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES=%r, using default %d", -./lib/devbase/commands/container.py:414: last = mgr.last_snapshot_time() -./lib/devbase/snapshot/manager.py:21:METADATA_FILE = 'snapshot.yml' -./lib/devbase/snapshot/manager.py:98: def last_snapshot_time(self) -> Optional[datetime]: -./lib/devbase/snapshot/manager.py:279: return latest.get('incremental_count', 0) >= max_incrementals -./lib/devbase/snapshot/manager.py:371: 'incremental_count': 0, -./lib/devbase/snapshot/manager.py:402: snap_meta['incremental_count'] = next_num -./lib/devbase/snapshot/manager.py:406: """グローバルメタデータ(snapshot.yml)を更新""" -./lib/devbase/snapshot/manager.py:417: snap['incremental_count'] = snap_meta.get('incremental_count', 0) -./lib/devbase/snapshot/manager.py:426: 'incremental_count': snap_meta.get('incremental_count', 0), - -codex -過去の round 2 payload ではテスト不足が指摘され、その後この差分でテストが追加されています。新しい実装に対する追加の不具合がないか確認するため、関連テストを実行します。 -exec -/bin/bash -lc 'uv run pytest tests/snapshot/test_auto_snapshot.py tests/cli/test_project_dispatch.py' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 578ms: -============================= test session starts ============================== -platform darwin -- Python 3.13.11, pytest-9.0.3, pluggy-1.6.0 -rootdir: /private/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 -configfile: pyproject.toml -collected 54 items - -tests/snapshot/test_auto_snapshot.py .......... [ 18%] -tests/cli/test_project_dispatch.py ..................................... [ 87%] -....... [100%] - -============================== 54 passed in 0.08s ============================== - -codex -関連テストは 54 件通りました。修正要求に相当する新規指摘は見つからないため、本来の intent は `APPROVE`、ただし指定どおり GitHub API の `event` は `COMMENT` で投稿します。 -apply patch -patch: completed -/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review/codex-review-pr75-round3-payload.json -diff --git a/.cross_review/codex-review-pr75-round3-payload.json b/.cross_review/codex-review-pr75-round3-payload.json -new file mode 100644 -index 0000000000000000000000000000000000000000..a0d0268f862344dd18eb8dc09e99ee163572dda2 ---- /dev/null -+++ b/.cross_review/codex-review-pr75-round3-payload.json -@@ -0,0 +1,3 @@ -+{ -+ "comments": [] -+} - -diff --git a/.cross_review/codex-review-pr75-round3-payload.json b/.cross_review/codex-review-pr75-round3-payload.json -new file mode 100644 -index 0000000000000000000000000000000000000000..a0d0268f862344dd18eb8dc09e99ee163572dda2 ---- /dev/null -+++ b/.cross_review/codex-review-pr75-round3-payload.json -@@ -0,0 +1,3 @@ -+{ -+ "comments": [] -+} - -codex -投稿 payload はコメント 0 件で準備しました。次に `gh api` で PR review を作成し、返却 URL を結果 JSON に反映します。 -exec -/bin/bash -lc "set -euo pipefail -body_file=\".cross_review/codex-review-pr75-round3-body.md\" -response_file=\".cross_review/codex-review-pr75-round3-response.json\" -err_file=\".cross_review/err.log\" -printf '## 🤖 cross-review | round 3 | codex | APPROVE\\n\\n修正要求に相当する新規指摘はありません。\\n' > \""'$body_file" -if ! gh api repos/devbasex/devbase/pulls/75/reviews '"\\ - --method POST \\ - -f commit_id='f8231341fb4dfa044e1b27ee4e698058b7ff28b2' \\ - -f event='COMMENT' \\ - -F body=@\""'$body_file" '"\\ - > \""'$response_file" 2> "$err_file"; then - exit 1 -fi -jq '"'{event:\"APPROVE\", posted_as:\"COMMENT\", comments_count:0, review_url:(.html_url // .pull_request_url // \"\"), by_severity:{critical:0, major:0, minor:0, nit:0}}' \""'$response_file" > .cross_review/codex-review-pr75-result.json -jq '"'{id, state, html_url, submitted_at, commit_id, body}' \""'$response_file"' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 -diff --git a/.cross_review/codex-review-pr75-round3-payload.json b/.cross_review/codex-review-pr75-round3-payload.json -new file mode 100644 -index 0000000000000000000000000000000000000000..a0d0268f862344dd18eb8dc09e99ee163572dda2 ---- /dev/null -+++ b/.cross_review/codex-review-pr75-round3-payload.json -@@ -0,0 +1,3 @@ -+{ -+ "comments": [] -+} - - succeeded in 909ms: -{ - "id": 4571614169, - "state": "COMMENTED", - "html_url": "https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571614169", - "submitted_at": "2026-06-25T13:56:48Z", - "commit_id": "f8231341fb4dfa044e1b27ee4e698058b7ff28b2", - "body": "## 🤖 cross-review | round 3 | codex | APPROVE\n\n修正要求に相当する新規指摘はありません。\n" -} - -codex -PR review は投稿済みです。最後に保存済み result/payload の内容を確認して、契約どおりになっているか見ます。 -exec -/bin/bash -lc 'cat .cross_review/codex-review-pr75-result.json' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -{ - "event": "APPROVE", - "posted_as": "COMMENT", - "comments_count": 0, - "review_url": "https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571614169", - "by_severity": { - "critical": 0, - "major": 0, - "minor": 0, - "nit": 0 - } -} - -exec -/bin/bash -lc 'cat .cross_review/codex-review-pr75-round3-payload.json' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -{ - "comments": [] -} - -exec -/bin/bash -lc 'git status --short .cross_review/codex-review-pr75-result.json .cross_review/codex-review-pr75-round3-payload.json .cross_review/codex-review-pr75-round3-response.json .cross_review/codex-review-pr75-round3-body.md .cross_review/err.log' in /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 - succeeded in 0ms: -?? .cross_review/codex-review-pr75-result.json -?? .cross_review/codex-review-pr75-round3-body.md -?? .cross_review/codex-review-pr75-round3-payload.json -?? .cross_review/codex-review-pr75-round3-response.json -?? .cross_review/err.log - -diff --git a/.cross_review/codex-review-pr75-round3-payload.json b/.cross_review/codex-review-pr75-round3-payload.json -new file mode 100644 -index 0000000000000000000000000000000000000000..a0d0268f862344dd18eb8dc09e99ee163572dda2 ---- /dev/null -+++ b/.cross_review/codex-review-pr75-round3-payload.json -@@ -0,0 +1,3 @@ -+{ -+ "comments": [] -+} - -codex -PR #75 に `gh api` で review 投稿しました。 - -- intent: `APPROVE` -- posted_as: `COMMENT` -- inline comments: `0` -- review: https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571614169 -- result: `.cross_review/codex-review-pr75-result.json` -- payload: `.cross_review/codex-review-pr75-round3-payload.json` - -確認として `uv run pytest tests/snapshot/test_auto_snapshot.py tests/cli/test_project_dispatch.py` を実行し、54 件すべて通過しました。 -diff --git a/.cross_review/codex-review-pr75-round3-payload.json b/.cross_review/codex-review-pr75-round3-payload.json -new file mode 100644 -index 0000000000000000000000000000000000000000..a0d0268f862344dd18eb8dc09e99ee163572dda2 ---- /dev/null -+++ b/.cross_review/codex-review-pr75-round3-payload.json -@@ -0,0 +1,3 @@ -+{ -+ "comments": [] -+} - -tokens used -36,005 diff --git a/.cross_review/codex-review-pr75-gh-response.json b/.cross_review/codex-review-pr75-gh-response.json deleted file mode 100644 index 24dbde2..0000000 --- a/.cross_review/codex-review-pr75-gh-response.json +++ /dev/null @@ -1 +0,0 @@ -{"id":4571476541,"node_id":"PRR_kwDOSHX-a88AAAABEHsyPQ","user":{"login":"takemi-ohama","id":10234200,"node_id":"MDQ6VXNlcjEwMjM0MjAw","avatar_url":"https://avatars.githubusercontent.com/u/10234200?u=5234c3bda85b1b6bdd98e5a4f2dfa04541b37ebb&v=4","gravatar_id":"","url":"https://api.github.com/users/takemi-ohama","html_url":"https://github.com/takemi-ohama","followers_url":"https://api.github.com/users/takemi-ohama/followers","following_url":"https://api.github.com/users/takemi-ohama/following{/other_user}","gists_url":"https://api.github.com/users/takemi-ohama/gists{/gist_id}","starred_url":"https://api.github.com/users/takemi-ohama/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/takemi-ohama/subscriptions","organizations_url":"https://api.github.com/users/takemi-ohama/orgs","repos_url":"https://api.github.com/users/takemi-ohama/repos","events_url":"https://api.github.com/users/takemi-ohama/events{/privacy}","received_events_url":"https://api.github.com/users/takemi-ohama/received_events","type":"User","user_view_type":"public","site_admin":false},"body":"## 🤖 cross-review | round 1 | codex | COMMENT\n\n時刻比較の正規化漏れについて 1 件コメントしました。","state":"COMMENTED","html_url":"https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571476541","pull_request_url":"https://api.github.com/repos/devbasex/devbase/pulls/75","author_association":"MEMBER","_links":{"html":{"href":"https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571476541"},"pull_request":{"href":"https://api.github.com/repos/devbasex/devbase/pulls/75"}},"submitted_at":"2026-06-25T13:42:09Z","commit_id":"5ddd4981eef09374487a77c98380d2d041335a1e"} \ No newline at end of file diff --git a/.cross_review/codex-review-pr75-prompt.md b/.cross_review/codex-review-pr75-prompt.md deleted file mode 100644 index 3b180e2..0000000 --- a/.cross_review/codex-review-pr75-prompt.md +++ /dev/null @@ -1,55 +0,0 @@ -# /ndf:review 実行 (cross-review codex / round 3) - -PR #75 を **codex の観点でレビューし、gh api で直接 PR に投稿** してください。 - -## 必須コンテキスト -- repo: devbasex/devbase -- PR: #75 -- commit_id (headRefOid): f8231341fb4dfa044e1b27ee4e698058b7ff28b2 -- worktree: /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 (**ファイル読み取りは必ず此処の絶対パスを使う**) -- event_downgrade: true - - true の場合: payload の `event` は `COMMENT` にすること。 - ただし body 先頭 prefix の `` には **本来の intent** を書く。 -- 既存コメントスナップショット: /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review/cross-review-pr75-existing-comments.txt (重複指摘禁止) - -## 出力契約 -- review body の **先頭行** に必ず以下を入れる(fence 不要、Markdown 見出しとして): - ``` - ## 🤖 cross-review | round 3 | codex | - ``` - 例: `## 🤖 cross-review | round 3 | codex | REQUEST_CHANGES` - - `` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) - -### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) -- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない -- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 -- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) -- ❌ **コード引用 (``` ... ```) だけで指摘内容が無いコメント** -- ❌ **`event=COMMENT` での雑感投稿** — 直すべき点が無ければ `APPROVE` にする - -### インラインコメントの書式 -- `[重要度 / カテゴリ]` プレフィックス必須 (例: `[major / 正確性]`) -- 重要度は `critical` / `major` / `minor` のみ使う (nit はインライン化しない) -- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く - -### body (総評) の書き方 -- 設計レベル・PR 横断の **修正提案のみ** 書く -- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) - -- 投稿後、サマリを **/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review/codex-review-pr75-result.json** に書く: - ```json - { - "event": "REQUEST_CHANGES", - "posted_as": "COMMENT", - "comments_count": 5, - "review_url": "https://github.com/.../pull/75#pullrequestreview-...", - "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0} - } - ``` -- payload(全コメント詳細)は **/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review/codex-review-pr75-round3-payload.json** に保存 - (振動検知用、`{ "comments": [{path, line, body, severity}, ...] }` 形式) - -## 守るべきこと -- リポジトリ編集は行わない(コード修正は別ステップ) -- worktree 外のパスは触らない -- gh api 失敗時は err.log にエラー詳細を残して即時終了 diff --git a/.cross_review/codex-review-pr75-result.json b/.cross_review/codex-review-pr75-result.json deleted file mode 100644 index bb579a0..0000000 --- a/.cross_review/codex-review-pr75-result.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "event": "APPROVE", - "posted_as": "COMMENT", - "comments_count": 0, - "review_url": "https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571614169", - "by_severity": { - "critical": 0, - "major": 0, - "minor": 0, - "nit": 0 - } -} diff --git a/.cross_review/codex-review-pr75-round1-payload.json b/.cross_review/codex-review-pr75-round1-payload.json deleted file mode 100644 index c7bddf3..0000000 --- a/.cross_review/codex-review-pr75-round1-payload.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "comments": [ - { - "path": "lib/devbase/snapshot/manager.py", - "line": 110, - "body": "[minor / 正確性] `fromisoformat()` は `2026-06-25T12:00:00+09:00` のような offset-aware な日時も返すため、既存の naive な `created_at` と混在すると `dt > latest` や `_auto_snapshot()` 側の `datetime.now() - last` が `TypeError` になります。比較前に naive/aware を同じ基準へ正規化するか、offset-aware な日時を明示的に無視する処理にしてください。", - "severity": "minor" - } - ] -} diff --git a/.cross_review/codex-review-pr75-round2-api-payload.json b/.cross_review/codex-review-pr75-round2-api-payload.json deleted file mode 100644 index a0ccd47..0000000 --- a/.cross_review/codex-review-pr75-round2-api-payload.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "commit_id": "f99f050fb2cb1d1d1d2609be1cab5b73a4ff2f7d", - "event": "COMMENT", - "body": "## 🤖 cross-review | round 2 | codex | REQUEST_CHANGES\n\n成功したスナップショット本体だけを最小間隔の判定対象にする修正が必要です。", - "comments": [ - { - "path": "lib/devbase/snapshot/manager.py", - "line": 114, - "side": "RIGHT", - "body": "[major / 正確性] 判定対象を と に限定してください。現状は や / の mtime でも直近スナップショット扱いになるため、コピーや失敗した差分作成後に成功したバックアップが無いまま がスキップされます。" - } - ] -} diff --git a/.cross_review/codex-review-pr75-round2-comment-update.json b/.cross_review/codex-review-pr75-round2-comment-update.json deleted file mode 100644 index fabf349..0000000 --- a/.cross_review/codex-review-pr75-round2-comment-update.json +++ /dev/null @@ -1 +0,0 @@ -{"url":"https://api.github.com/repos/devbasex/devbase/pulls/comments/3474878988","pull_request_review_id":4571528229,"id":3474878988,"node_id":"PRRC_kwDOSHX-a87PHnIM","diff_hunk":"@@ -95,6 +95,32 @@ def list(self) -> list[dict]:\n snap['size_bytes'] = 0\n return snapshots\n \n+ def last_snapshot_time(self) -> Optional[datetime]:\n+ \"\"\"直近のスナップショット取得 (フル/差分) 日時を返す。\n+\n+ 各スナップショットディレクトリ内のアーカイブファイルの mtime のうち\n+ 最新のものを採用する。差分更新は既存ディレクトリ名を再利用するため\n+ (ディレクトリ名の日付は世代作成時のまま) ファイルの mtime を実測する方が\n+ 正確で、メタデータの整合性にも依存しない。\n+\n+ スナップショットが存在しない場合は None。\n+ \"\"\"\n+ if not self.backups_dir.exists():\n+ return None\n+ latest: Optional[float] = None\n+ for snap_dir in self.backups_dir.iterdir():\n+ if not snap_dir.is_dir():\n+ continue\n+ for f in snap_dir.iterdir():","path":"lib/devbase/snapshot/manager.py","commit_id":"f99f050fb2cb1d1d1d2609be1cab5b73a4ff2f7d","original_commit_id":"f99f050fb2cb1d1d1d2609be1cab5b73a4ff2f7d","user":{"login":"takemi-ohama","id":10234200,"node_id":"MDQ6VXNlcjEwMjM0MjAw","avatar_url":"https://avatars.githubusercontent.com/u/10234200?v=4","gravatar_id":"","url":"https://api.github.com/users/takemi-ohama","html_url":"https://github.com/takemi-ohama","followers_url":"https://api.github.com/users/takemi-ohama/followers","following_url":"https://api.github.com/users/takemi-ohama/following{/other_user}","gists_url":"https://api.github.com/users/takemi-ohama/gists{/gist_id}","starred_url":"https://api.github.com/users/takemi-ohama/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/takemi-ohama/subscriptions","organizations_url":"https://api.github.com/users/takemi-ohama/orgs","repos_url":"https://api.github.com/users/takemi-ohama/repos","events_url":"https://api.github.com/users/takemi-ohama/events{/privacy}","received_events_url":"https://api.github.com/users/takemi-ohama/received_events","type":"User","user_view_type":"public","site_admin":false},"body":"[major / 正確性] 判定対象を `full.tar.zst` と `incr-*.tar.zst` に限定してください。現状は `meta.yml` や `snapshot.snar`/`.bak` の mtime でも直近スナップショット扱いになるため、コピーや失敗した差分作成後に成功したバックアップが無いまま `up` がスキップされます。","created_at":"2026-06-25T13:47:29Z","updated_at":"2026-06-25T13:48:08Z","html_url":"https://github.com/devbasex/devbase/pull/75#discussion_r3474878988","pull_request_url":"https://api.github.com/repos/devbasex/devbase/pulls/75","_links":{"self":{"href":"https://api.github.com/repos/devbasex/devbase/pulls/comments/3474878988"},"html":{"href":"https://github.com/devbasex/devbase/pull/75#discussion_r3474878988"},"pull_request":{"href":"https://api.github.com/repos/devbasex/devbase/pulls/75"}},"reactions":{"url":"https://api.github.com/repos/devbasex/devbase/pulls/comments/3474878988/reactions","total_count":0,"+1":0,"-1":0,"laugh":0,"hooray":0,"confused":0,"heart":0,"rocket":0,"eyes":0},"start_line":null,"original_start_line":null,"start_side":null,"line":114,"original_line":114,"side":"RIGHT","author_association":"MEMBER","original_position":20,"position":20,"subject_type":"line"} \ No newline at end of file diff --git a/.cross_review/codex-review-pr75-round2-err.log b/.cross_review/codex-review-pr75-round2-err.log deleted file mode 100644 index e69de29..0000000 diff --git a/.cross_review/codex-review-pr75-round2-payload.json b/.cross_review/codex-review-pr75-round2-payload.json deleted file mode 100644 index 8b02975..0000000 --- a/.cross_review/codex-review-pr75-round2-payload.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "comments": [ - { - "path": "lib/devbase/snapshot/manager.py", - "line": 114, - "body": "[major / 正確性] 判定対象を `full.tar.zst` と `incr-*.tar.zst` に限定してください。現状は `meta.yml` や `snapshot.snar`/`.bak` の mtime でも直近スナップショット扱いになるため、コピーや失敗した差分作成後に成功したバックアップが無いまま `up` がスキップされます。", - "severity": "major" - } - ] -} diff --git a/.cross_review/codex-review-pr75-round2-response.json b/.cross_review/codex-review-pr75-round2-response.json deleted file mode 100644 index 6d5acde..0000000 --- a/.cross_review/codex-review-pr75-round2-response.json +++ /dev/null @@ -1 +0,0 @@ -{"id":4571528229,"node_id":"PRR_kwDOSHX-a88AAAABEHv8JQ","user":{"login":"takemi-ohama","id":10234200,"node_id":"MDQ6VXNlcjEwMjM0MjAw","avatar_url":"https://avatars.githubusercontent.com/u/10234200?u=5234c3bda85b1b6bdd98e5a4f2dfa04541b37ebb&v=4","gravatar_id":"","url":"https://api.github.com/users/takemi-ohama","html_url":"https://github.com/takemi-ohama","followers_url":"https://api.github.com/users/takemi-ohama/followers","following_url":"https://api.github.com/users/takemi-ohama/following{/other_user}","gists_url":"https://api.github.com/users/takemi-ohama/gists{/gist_id}","starred_url":"https://api.github.com/users/takemi-ohama/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/takemi-ohama/subscriptions","organizations_url":"https://api.github.com/users/takemi-ohama/orgs","repos_url":"https://api.github.com/users/takemi-ohama/repos","events_url":"https://api.github.com/users/takemi-ohama/events{/privacy}","received_events_url":"https://api.github.com/users/takemi-ohama/received_events","type":"User","user_view_type":"public","site_admin":false},"body":"## 🤖 cross-review | round 2 | codex | REQUEST_CHANGES\n\n成功したスナップショット本体だけを最小間隔の判定対象にする修正が必要です。","state":"COMMENTED","html_url":"https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571528229","pull_request_url":"https://api.github.com/repos/devbasex/devbase/pulls/75","author_association":"MEMBER","_links":{"html":{"href":"https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571528229"},"pull_request":{"href":"https://api.github.com/repos/devbasex/devbase/pulls/75"}},"submitted_at":"2026-06-25T13:47:29Z","commit_id":"f99f050fb2cb1d1d1d2609be1cab5b73a4ff2f7d"} \ No newline at end of file diff --git a/.cross_review/codex-review-pr75-round3-body.md b/.cross_review/codex-review-pr75-round3-body.md deleted file mode 100644 index 3184018..0000000 --- a/.cross_review/codex-review-pr75-round3-body.md +++ /dev/null @@ -1,3 +0,0 @@ -## 🤖 cross-review | round 3 | codex | APPROVE - -修正要求に相当する新規指摘はありません。 diff --git a/.cross_review/codex-review-pr75-round3-payload.json b/.cross_review/codex-review-pr75-round3-payload.json deleted file mode 100644 index a0d0268..0000000 --- a/.cross_review/codex-review-pr75-round3-payload.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "comments": [] -} diff --git a/.cross_review/codex-review-pr75-round3-response.json b/.cross_review/codex-review-pr75-round3-response.json deleted file mode 100644 index 7533ca5..0000000 --- a/.cross_review/codex-review-pr75-round3-response.json +++ /dev/null @@ -1 +0,0 @@ -{"id":4571614169,"node_id":"PRR_kwDOSHX-a88AAAABEH1L2Q","user":{"login":"takemi-ohama","id":10234200,"node_id":"MDQ6VXNlcjEwMjM0MjAw","avatar_url":"https://avatars.githubusercontent.com/u/10234200?u=5234c3bda85b1b6bdd98e5a4f2dfa04541b37ebb&v=4","gravatar_id":"","url":"https://api.github.com/users/takemi-ohama","html_url":"https://github.com/takemi-ohama","followers_url":"https://api.github.com/users/takemi-ohama/followers","following_url":"https://api.github.com/users/takemi-ohama/following{/other_user}","gists_url":"https://api.github.com/users/takemi-ohama/gists{/gist_id}","starred_url":"https://api.github.com/users/takemi-ohama/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/takemi-ohama/subscriptions","organizations_url":"https://api.github.com/users/takemi-ohama/orgs","repos_url":"https://api.github.com/users/takemi-ohama/repos","events_url":"https://api.github.com/users/takemi-ohama/events{/privacy}","received_events_url":"https://api.github.com/users/takemi-ohama/received_events","type":"User","user_view_type":"public","site_admin":false},"body":"## 🤖 cross-review | round 3 | codex | APPROVE\n\n修正要求に相当する新規指摘はありません。\n","state":"COMMENTED","html_url":"https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571614169","pull_request_url":"https://api.github.com/repos/devbasex/devbase/pulls/75","author_association":"MEMBER","_links":{"html":{"href":"https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571614169"},"pull_request":{"href":"https://api.github.com/repos/devbasex/devbase/pulls/75"}},"submitted_at":"2026-06-25T13:56:48Z","commit_id":"f8231341fb4dfa044e1b27ee4e698058b7ff28b2"} \ No newline at end of file diff --git a/.cross_review/codex-review-pr75-stdout.log b/.cross_review/codex-review-pr75-stdout.log deleted file mode 100644 index 6bff85d..0000000 --- a/.cross_review/codex-review-pr75-stdout.log +++ /dev/null @@ -1,10 +0,0 @@ -PR #75 に `gh api` で review 投稿しました。 - -- intent: `APPROVE` -- posted_as: `COMMENT` -- inline comments: `0` -- review: https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571614169 -- result: `.cross_review/codex-review-pr75-result.json` -- payload: `.cross_review/codex-review-pr75-round3-payload.json` - -確認として `uv run pytest tests/snapshot/test_auto_snapshot.py tests/cli/test_project_dispatch.py` を実行し、54 件すべて通過しました。 diff --git a/.cross_review/codex-review-pr75.pid b/.cross_review/codex-review-pr75.pid deleted file mode 100644 index b4b23ad..0000000 --- a/.cross_review/codex-review-pr75.pid +++ /dev/null @@ -1 +0,0 @@ -30402 diff --git a/.cross_review/cross-review-pr75-existing-comments.txt b/.cross_review/cross-review-pr75-existing-comments.txt deleted file mode 100644 index e69de29..0000000 diff --git a/.cross_review/cross-review-pr75-state.json b/.cross_review/cross-review-pr75-state.json deleted file mode 100644 index 86274de..0000000 --- a/.cross_review/cross-review-pr75-state.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "started_at": "2026-06-25T22:39:23+09:00", - "max_rounds": 12, - "rotate_after": 8, - "only": null, - "current_pr": 75, - "worktree_path": "/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75", - "tmp_dir": "/private/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review", - "repo": "devbasex/devbase", - "head_branch": "feat/snapshot-min-interval", - "base_branch": "main", - "pr_author": "takemi-ohama", - "is_own_pr": true, - "event_downgrade": true, - "pr_history": [ - { - "pr": 75, - "opened_at": "2026-06-25T22:39:23+09:00", - "closed_at": null, - "rounds": 0 - } - ], - "rounds": [ - { - "round": 1, - "pr": 75, - "started_at": "2026-06-25T22:39:27+09:00" - }, - { - "round": 2, - "pr": 75, - "started_at": "2026-06-25T22:45:54+09:00", - "codex": { - "intent": "REQUEST_CHANGES", - "posted_as": "COMMENT", - "comments": 1, - "review_url": "https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571528229", - "by_severity": { - "critical": 0, - "major": 1, - "minor": 0, - "nit": 0 - } - }, - "gemini": { - "intent": "REQUEST_CHANGES", - "posted_as": "COMMENT", - "comments": 2, - "review_url": "https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571531359", - "by_severity": { - "critical": 0, - "major": 1, - "minor": 1, - "nit": 0 - } - }, - "fix": { - "commit": "f8231341fb4dfa044e1b27ee4e698058b7ff28b2", - "fixed": [ - "修正1: last_snapshot_time() の mtime 集計対象を full.tar.zst / incr-*.tar.zst に限定 (meta.yml / snapshot.snar / *.bak 等を除外。docstring 更新済み)", - "修正2: _auto_snapshot() で delta = datetime.now() - last を変数化し timedelta(0) <= delta < timedelta(minutes=min_interval) の未来日時ガードを追加", - "修正3: テスト追加 (tests/snapshot/test_auto_snapshot.py, tests/snapshot/__init__.py 新規)" - ], - "deferred": 0, - "rejected": 0, - "resolved_threads": 0, - "ci": null, - "ci_failed_checks": [], - "ci_note": null, - "by_severity": {} - }, - "ended_at": "2026-06-25T22:54:28+09:00" - }, - { - "round": 3, - "pr": 75, - "started_at": "2026-06-25T22:54:49+09:00", - "codex": { - "intent": "APPROVE", - "posted_as": "COMMENT", - "comments": 0, - "review_url": "https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571614169", - "by_severity": { - "critical": 0, - "major": 0, - "minor": 0, - "nit": 0 - } - }, - "gemini": { - "intent": "APPROVE", - "posted_as": "COMMENT", - "comments": 1, - "review_url": "https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571615192", - "by_severity": { - "critical": 0, - "major": 0, - "minor": 1, - "nit": 0 - } - } - } - ], - "deferred_nits": [], - "final": "approved", - "ended_at": "2026-06-25T22:57:31+09:00" -} \ No newline at end of file diff --git a/.cross_review/err.log b/.cross_review/err.log deleted file mode 100644 index e69de29..0000000 diff --git a/.cross_review/fix-pr75-result.json b/.cross_review/fix-pr75-result.json deleted file mode 100644 index 5c08059..0000000 --- a/.cross_review/fix-pr75-result.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "commit_sha": "f8231341fb4dfa044e1b27ee4e698058b7ff28b2", - "fixed": [ - "修正1: last_snapshot_time() の mtime 集計対象を full.tar.zst / incr-*.tar.zst に限定 (meta.yml / snapshot.snar / *.bak 等を除外。docstring 更新済み)", - "修正2: _auto_snapshot() で delta = datetime.now() - last を変数化し timedelta(0) <= delta < timedelta(minutes=min_interval) の未来日時ガードを追加", - "修正3: テスト追加 (tests/snapshot/test_auto_snapshot.py, tests/snapshot/__init__.py 新規)" - ], - "stale_resolved": ["3474835202", "3474838653"], - "tests_passed": true, - "test_output_tail": "837 passed in 34.72s (新規 tests/snapshot/ は 10 passed)", - "threads_resolved": 6, - "remaining_open_threads": 0, - "notes": "pytest はシステム python に無いため uv run pytest で実行。tests/snapshot/ ディレクトリは新規作成し __init__.py も追加 (既存 tests/cli 等の構成に倣う)。各 review comment に返信は replies エンドポイントで成功 (重複なし=各1件)、6スレッド全て resolveReviewThread で解決済み。" -} diff --git a/.cross_review/gemini-review-api-payload.json b/.cross_review/gemini-review-api-payload.json deleted file mode 100644 index 1a426ad..0000000 --- a/.cross_review/gemini-review-api-payload.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "commit_id": "f8231341fb4dfa044e1b27ee4e698058b7ff28b2", - "event": "COMMENT", - "body": "## 🤖 cross-review | round 3 | gemini | APPROVE\n\n自動スナップショットのスキップ判定について、テストも含めて適切に実装されています。\n一点、夏時間(DST)等の切り替えを考慮したタイムゾーンの扱いについてのみ、より堅牢にするための提案を残します。", - "comments": [ - { - "path": "lib/devbase/commands/container.py", - "line": 420, - "body": "[minor / 正確性]\n`datetime.now()` と `datetime.fromtimestamp()` による naive なローカル日時の計算では、DST(夏時間)の切り替えタイミングを跨いだ場合に `delta` が実時間とズレる可能性があります。より正確な時間差分を計算するため、UTC (`datetime.now(timezone.utc)` と `datetime.fromtimestamp(latest, timezone.utc)`) の使用を検討してください。" - } - ] -} diff --git a/.cross_review/gemini-review-api-response.json b/.cross_review/gemini-review-api-response.json deleted file mode 100644 index 8b816af..0000000 --- a/.cross_review/gemini-review-api-response.json +++ /dev/null @@ -1 +0,0 @@ -{"id":4571615192,"node_id":"PRR_kwDOSHX-a88AAAABEH1P2A","user":{"login":"takemi-ohama","id":10234200,"node_id":"MDQ6VXNlcjEwMjM0MjAw","avatar_url":"https://avatars.githubusercontent.com/u/10234200?u=5234c3bda85b1b6bdd98e5a4f2dfa04541b37ebb&v=4","gravatar_id":"","url":"https://api.github.com/users/takemi-ohama","html_url":"https://github.com/takemi-ohama","followers_url":"https://api.github.com/users/takemi-ohama/followers","following_url":"https://api.github.com/users/takemi-ohama/following{/other_user}","gists_url":"https://api.github.com/users/takemi-ohama/gists{/gist_id}","starred_url":"https://api.github.com/users/takemi-ohama/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/takemi-ohama/subscriptions","organizations_url":"https://api.github.com/users/takemi-ohama/orgs","repos_url":"https://api.github.com/users/takemi-ohama/repos","events_url":"https://api.github.com/users/takemi-ohama/events{/privacy}","received_events_url":"https://api.github.com/users/takemi-ohama/received_events","type":"User","user_view_type":"public","site_admin":false},"body":"## 🤖 cross-review | round 3 | gemini | APPROVE\n\n自動スナップショットのスキップ判定について、テストも含めて適切に実装されています。\n一点、夏時間(DST)等の切り替えを考慮したタイムゾーンの扱いについてのみ、より堅牢にするための提案を残します。","state":"COMMENTED","html_url":"https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571615192","pull_request_url":"https://api.github.com/repos/devbasex/devbase/pulls/75","author_association":"MEMBER","_links":{"html":{"href":"https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571615192"},"pull_request":{"href":"https://api.github.com/repos/devbasex/devbase/pulls/75"}},"submitted_at":"2026-06-25T13:56:56Z","commit_id":"f8231341fb4dfa044e1b27ee4e698058b7ff28b2"} \ No newline at end of file diff --git a/.cross_review/gemini-review-pr75-err.log b/.cross_review/gemini-review-pr75-err.log deleted file mode 100644 index 0f40760..0000000 --- a/.cross_review/gemini-review-pr75-err.log +++ /dev/null @@ -1,4 +0,0 @@ -YOLO mode is enabled. All tool calls will be automatically approved. -YOLO mode is enabled. All tool calls will be automatically approved. -Ripgrep is not available. Falling back to GrepTool. -[ERROR] [IDEClient] Failed to connect to IDE companion extension. Please ensure the extension is running. To install the extension, run /ide install. diff --git a/.cross_review/gemini-review-pr75-prompt.md b/.cross_review/gemini-review-pr75-prompt.md deleted file mode 100644 index aa8be06..0000000 --- a/.cross_review/gemini-review-pr75-prompt.md +++ /dev/null @@ -1,64 +0,0 @@ -# /ndf:review 実行 (cross-review gemini / round 3) - -PR #75 を **gemini の観点でレビューし、gh api で直接 PR に投稿** してください。 - -## 必須コンテキスト -- repo: devbasex/devbase -- PR: #75 -- commit_id (headRefOid): f8231341fb4dfa044e1b27ee4e698058b7ff28b2 -- worktree: /var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75 (**ファイル読み取りは必ず此処の絶対パスを使う**) -- event_downgrade: true - - true の場合: payload の `event` は `COMMENT` にすること。 - body 先頭 prefix の `` は本来の intent を書く。 - -## 既存コメントスナップショット(重複指摘禁止) -workspace 外を読まなくて済むよう、以下にインライン展開する: - -``` -(なし) -``` - -## 出力契約 -- review body の **先頭行** に必ず以下を入れる: - ``` - ## 🤖 cross-review | round 3 | gemini | - ``` - - `` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) - -### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) -- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない -- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 -- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) -- ❌ **コード引用 (``` ... ```) だけで指摘内容が無いコメント** -- ❌ **`event=COMMENT` での雑感投稿** — 直すべき点が無ければ `APPROVE` にする - -### インラインコメントの書式 -- `[重要度 / カテゴリ]` プレフィックス必須 (例: `[major / 正確性]`) -- 重要度は `critical` / `major` / `minor` のみ使う (nit はインライン化しない) -- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く - -### body (総評) の書き方 -- 設計レベル・PR 横断の **修正提案のみ** 書く -- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) - -- 投稿後、サマリを **/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review/gemini-review-pr75-result.json** に - **必ず以下のキーで** 書く: - ```json - { - "event": "APPROVE", - "posted_as": "COMMENT", - "comments_count": 3, - "review_url": "https://github.com/.../pull/75#pullrequestreview-...", - "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0} - } - ``` - - `intent` / `comment_count` 等の別名は使わないこと - - `event` の値は `APPROVE` / `REQUEST_CHANGES` / `COMMENT` のいずれか - - `event_downgrade=true` のとき `posted_as` は `COMMENT` にダウングレード可 -- payload は **/var/folders/qz/qxt0p_y15xv5jg4x77zmtxj80000gp/T/ndf-worktrees/devbasex--devbase/pr75/.cross_review/gemini-review-pr75-round3-payload.json** に保存 - (振動検知用、`{ "comments": [{path, line, body, severity}, ...] }` 形式) - -## 守るべきこと -- **リポジトリ編集禁止**。gh api での投稿のみ許可 -- worktree 外のパスは触らない -- gh api 失敗時は err.log にエラー詳細を残して即時終了 diff --git a/.cross_review/gemini-review-pr75-result.json b/.cross_review/gemini-review-pr75-result.json deleted file mode 100644 index a85803d..0000000 --- a/.cross_review/gemini-review-pr75-result.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "event": "APPROVE", - "posted_as": "COMMENT", - "comments_count": 1, - "review_url": "https://github.com/devbasex/devbase/pull/75#pullrequestreview-4571615192", - "by_severity": { - "critical": 0, - "major": 0, - "minor": 1, - "nit": 0 - } -} diff --git a/.cross_review/gemini-review-pr75-round1-payload.json b/.cross_review/gemini-review-pr75-round1-payload.json deleted file mode 100644 index 896ead9..0000000 --- a/.cross_review/gemini-review-pr75-round1-payload.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "comments": [ - { - "path": "lib/devbase/snapshot/manager.py", - "line": 110, - "body": "[major / 堅牢性]\n`snapshot.yml` 内にタイムゾーン付きの日時(`Z` や `+09:00` など)が混在していると、後続の `dt > latest` で `TypeError: can't compare offset-naive and offset-aware datetimes` が発生しエラーとなります。\n以下のように naive なローカルタイムに統一することで、呼び出し元での `datetime.now() - last` 計算も含めて安全に処理できます。\n\n```python\n dt = datetime.fromisoformat(ts)\n if dt.tzinfo is not None:\n dt = dt.astimezone().replace(tzinfo=None)\n```", - "severity": "major" - }, - { - "path": "lib/devbase/commands/container.py", - "line": 418, - "body": "[minor / 正確性]\nシステム時計のズレや、他環境からのリストアなどによって `last` が未来の日時になっていた場合、`datetime.now() - last` は負の `timedelta` となります。\n負の `timedelta` は常に正の `timedelta(minutes=min_interval)` より小さいため、意図せずスナップショットがスキップされ続けてしまいます。\n以下のように `timedelta(0) <=` の条件を追加(または `abs()` を使用)し、未来日時の場合はスキップしないよう防ぐのが安全です。\n\n```python\n and timedelta(0) <= datetime.now() - last < timedelta(minutes=min_interval)\n```", - "severity": "minor" - } - ] -} \ No newline at end of file diff --git a/.cross_review/gemini-review-pr75-round2-payload.json b/.cross_review/gemini-review-pr75-round2-payload.json deleted file mode 100644 index 222c2b6..0000000 --- a/.cross_review/gemini-review-pr75-round2-payload.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "comments": [ - { - "path": "lib/devbase/commands/container.py", - "line": 397, - "body": "[major / テスト]\n環境変数 `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` のパース処理について、`tests/cli/test_...` 等に単体テストがありません。負の値やパース不能な文字列のフォールバック挙動が期待通り動作することを保証するテストを追加してください。", - "severity": "major" - }, - { - "path": "lib/devbase/snapshot/manager.py", - "line": 122, - "body": "[minor / テスト]\n`last_snapshot_time()` メソッドについて、スナップショットディレクトリが空の場合や存在しない場合の挙動を確認するテストケースの追加を検討してください。", - "severity": "minor" - } - ] -} \ No newline at end of file diff --git a/.cross_review/gemini-review-pr75-round3-payload.json b/.cross_review/gemini-review-pr75-round3-payload.json deleted file mode 100644 index b3255af..0000000 --- a/.cross_review/gemini-review-pr75-round3-payload.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "comments": [ - { - "path": "lib/devbase/commands/container.py", - "line": 420, - "body": "[minor / 正確性]\n`datetime.now()` と `datetime.fromtimestamp()` による naive なローカル日時の計算では、DST(夏時間)の切り替えタイミングを跨いだ場合に `delta` が実時間とズレる可能性があります。より正確な時間差分を計算するため、UTC (`datetime.now(timezone.utc)` と `datetime.fromtimestamp(latest, timezone.utc)`) の使用を検討してください。", - "severity": "minor" - } - ] -} diff --git a/.cross_review/gemini-review-pr75-stdout.log b/.cross_review/gemini-review-pr75-stdout.log deleted file mode 100644 index 31e8ec4..0000000 --- a/.cross_review/gemini-review-pr75-stdout.log +++ /dev/null @@ -1,9 +0,0 @@ -PR #75 に対する Gemini 観点でのレビュー (round 3) を完了し、`gh api` で直接 PR にコメントを投稿しました。 - -### レビュー内容 -実装は全体的に堅牢でテストも十分に書かれていたため、`APPROVE` 相当の評価としました。一点のみ、DST (夏時間) 切り替えを考慮したタイムゾーンの扱いについて minor の改善提案をインラインコメントとして投稿しました。 - -### 成果物 -指定されたパスに結果とペイロードを保存しました。 -- `.cross_review/gemini-review-pr75-result.json` -- `.cross_review/gemini-review-pr75-round3-payload.json` diff --git a/.cross_review/gemini-review-pr75.pid b/.cross_review/gemini-review-pr75.pid deleted file mode 100644 index 3a43636..0000000 --- a/.cross_review/gemini-review-pr75.pid +++ /dev/null @@ -1 +0,0 @@ -30432 diff --git a/.gitignore b/.gitignore index a3390e2..8da54a5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ projects/* !projects/.gitkeep .env.sources.yml .cache/ + +# クロスレビュー (ndf:cross-review) の作業生成物 +.cross_review/ +gh-payload.json diff --git a/gh-payload.json b/gh-payload.json deleted file mode 100644 index c70e4fb..0000000 --- a/gh-payload.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "commit_id": "f99f050fb2cb1d1d1d2609be1cab5b73a4ff2f7d", - "event": "COMMENT", - "body": "## 🤖 cross-review | round 2 | gemini | REQUEST_CHANGES\n\n- 新機能 `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` に関するテストコードが追加されていません。設定値のパースロジック(`0`、負の値、不正な文字列)および `last_snapshot_time()` の挙動について、単体テストの追加をお願いします。", - "comments": [ - { - "path": "lib/devbase/commands/container.py", - "line": 397, - "body": "[major / テスト]\n環境変数 `DEVBASE_SNAPSHOT_MIN_INTERVAL_MINUTES` のパース処理について、`tests/cli/test_...` 等に単体テストがありません。負の値やパース不能な文字列のフォールバック挙動が期待通り動作することを保証するテストを追加してください。" - }, - { - "path": "lib/devbase/snapshot/manager.py", - "line": 122, - "body": "[minor / テスト]\n`last_snapshot_time()` メソッドについて、スナップショットディレクトリが空の場合や存在しない場合の挙動を確認するテストケースの追加を検討してください。" - } - ] -} \ No newline at end of file