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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 84 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -219,15 +219,82 @@ jobs:
working-directory: backend
run: uv run pytest -q tests

# OpenAPI → TypeScript 型のドリフト検知 (ADR-0007)。
# backend schema を変更したのに frontend/src/api/generated.ts を再生成して
# いない状態をビルドで落とす。エラーコードの型縛りと同じ思想。
codegen-drift:
runs-on: ubuntu-latest
needs: detect-changes
timeout-minutes: 10
if: needs.detect-changes.outputs.app == 'true'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

steps:
- name: Checkout
# サプライチェーン保護のためコミット SHA で pin する(ADR-0007 codegen-drift を先行対応)。
# 型生成のドリフト検知のみで push はしないため認証情報は保持しない。
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
with:
version: "latest"
enable-cache: true
cache-dependency-glob: "backend/requirements.txt"

- name: Setup Python
run: uv python install 3.13

- name: WeasyPrint 用システムライブラリのインストール
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangoft2-1.0-0 libpangocairo-1.0-0 \
libglib2.0-0 libgobject-2.0-0 libffi-dev libcairo2

- name: Create virtualenv
working-directory: backend
run: uv venv

- name: Install backend dependencies
working-directory: backend
run: uv pip install -r requirements.txt

- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install frontend dependencies
run: npm ci --prefix frontend

- name: Export OpenAPI schema
working-directory: backend
run: uv run python scripts/export_openapi.py

- name: Generate TypeScript types
run: node frontend/scripts/gen-types.mjs

- name: Check generated types drift
run: |
if ! git diff --exit-code frontend/src/api/generated.ts; then
echo "::error::frontend/src/api/generated.ts が最新ではありません。'make codegen-types' を実行して再生成し、コミットしてください (ADR-0007)。"
exit 1
fi

deploy-frontend:
runs-on: ubuntu-latest
needs: [detect-changes, test-frontend, test-backend]
needs: [detect-changes, test-frontend, test-backend, codegen-drift]
if: |
github.ref == 'refs/heads/dev' &&
github.event_name == 'push' &&
needs.detect-changes.outputs.app == 'true' &&
(needs.test-frontend.result == 'success' || needs.test-frontend.result == 'skipped') &&
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped')
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped') &&
(needs.codegen-drift.result == 'success' || needs.codegen-drift.result == 'skipped')
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -265,13 +332,14 @@ jobs:

deploy-backend:
runs-on: ubuntu-latest
needs: [detect-changes, test-frontend, test-backend]
needs: [detect-changes, test-frontend, test-backend, codegen-drift]
if: |
github.ref == 'refs/heads/dev' &&
github.event_name == 'push' &&
needs.detect-changes.outputs.app == 'true' &&
(needs.test-frontend.result == 'success' || needs.test-frontend.result == 'skipped') &&
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped')
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped') &&
(needs.codegen-drift.result == 'success' || needs.codegen-drift.result == 'skipped')
timeout-minutes: 20
env:
# 値は GitHub Repo Variables / Secrets で上書き可能。未設定時は既存値にフォールバック。
Expand Down Expand Up @@ -321,13 +389,14 @@ jobs:
# stg フロントエンドデプロイ(stg ブランチ push 時)
deploy-frontend-stg:
runs-on: ubuntu-latest
needs: [detect-changes, test-frontend, test-backend]
needs: [detect-changes, test-frontend, test-backend, codegen-drift]
if: |
github.ref == 'refs/heads/stg' &&
github.event_name == 'push' &&
needs.detect-changes.outputs.app == 'true' &&
(needs.test-frontend.result == 'success' || needs.test-frontend.result == 'skipped') &&
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped')
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped') &&
(needs.codegen-drift.result == 'success' || needs.codegen-drift.result == 'skipped')
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -366,13 +435,14 @@ jobs:
# stg バックエンドデプロイ(stg ブランチ push 時)
deploy-backend-stg:
runs-on: ubuntu-latest
needs: [detect-changes, test-frontend, test-backend]
needs: [detect-changes, test-frontend, test-backend, codegen-drift]
if: |
github.ref == 'refs/heads/stg' &&
github.event_name == 'push' &&
needs.detect-changes.outputs.app == 'true' &&
(needs.test-frontend.result == 'success' || needs.test-frontend.result == 'skipped') &&
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped')
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped') &&
(needs.codegen-drift.result == 'success' || needs.codegen-drift.result == 'skipped')
timeout-minutes: 20
env:
# 値は GitHub Repo Variables / Secrets で上書き可能。未設定時は既存値にフォールバック。
Expand Down Expand Up @@ -422,13 +492,14 @@ jobs:
# prod フロントエンドデプロイ(main ブランチ push 時)
deploy-frontend-prod:
runs-on: ubuntu-latest
needs: [detect-changes, test-frontend, test-backend]
needs: [detect-changes, test-frontend, test-backend, codegen-drift]
if: |
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
needs.detect-changes.outputs.app == 'true' &&
(needs.test-frontend.result == 'success' || needs.test-frontend.result == 'skipped') &&
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped')
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped') &&
(needs.codegen-drift.result == 'success' || needs.codegen-drift.result == 'skipped')
timeout-minutes: 10
steps:
- name: Checkout
Expand Down Expand Up @@ -467,13 +538,14 @@ jobs:
# prod バックエンドデプロイ(main ブランチ push 時)
deploy-backend-prod:
runs-on: ubuntu-latest
needs: [detect-changes, test-frontend, test-backend]
needs: [detect-changes, test-frontend, test-backend, codegen-drift]
if: |
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
needs.detect-changes.outputs.app == 'true' &&
(needs.test-frontend.result == 'success' || needs.test-frontend.result == 'skipped') &&
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped')
(needs.test-backend.result == 'success' || needs.test-backend.result == 'skipped') &&
(needs.codegen-drift.result == 'success' || needs.codegen-drift.result == 'skipped')
timeout-minutes: 20
env:
# 値は GitHub Repo Variables / Secrets で上書き可能。未設定時は既存値にフォールバック。
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ backend/local.sqlite
backend/local.sqlite-shm
backend/local.sqlite-wal

# OpenAPI 型生成の中間生成物(ADR-0007)。コミット対象は frontend/src/api/generated.ts のみ。
backend/openapi.json

# Env files
.env
frontend/.env.local
Expand Down
14 changes: 13 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
ci \
dupe-check dupe-check-html dupe-clean \
build-frontend build-backend deploy-frontend \
gen-redirects \
gen-redirects codegen-types \
migrate migrate-create \
infra-fmt infra-fmt-check infra-validate-dev infra-validate-stg infra-validate-prod infra-validate \
clean
Expand Down Expand Up @@ -53,6 +53,7 @@ help:
@echo " build-backend Docker イメージビルド"
@echo " deploy-frontend Cloudflare Pages へビルド&デプロイ (CLOUD_RUN_URL=... 指定可)"
@echo " gen-redirects Cloudflare Pages 用 _redirects を生成 (CLOUD_RUN_URL=... 指定可)"
@echo " codegen-types OpenAPI から frontend 型 (src/api/generated.ts) を再生成 (ADR-0007)"
@echo ""
@echo "マイグレーション"
@echo " migrate alembic upgrade head"
Expand Down Expand Up @@ -181,6 +182,17 @@ deploy-frontend:
gen-redirects:
nix develop --command bash -c "cd frontend && CLOUD_RUN_URL='$(CLOUD_RUN_URL)' node scripts/gen-redirects.mjs"

# ------------------------------------------------------------------ #
# OpenAPI 型コード生成 (ADR-0007)
# ------------------------------------------------------------------ #

# backend の FastAPI OpenAPI スキーマから frontend の型定義を生成する。
# export_openapi.py で backend/openapi.json を出力し、gen-types.mjs で
# frontend/src/api/generated.ts を再生成する。backend app の import に
# WeasyPrint 等のネイティブ依存解決が必要なため Nix devshell 経由で実行する。
codegen-types:
nix develop --command bash -c "set -e; cd backend && .venv/bin/python scripts/export_openapi.py && cd ../frontend && node scripts/gen-types.mjs"

# ------------------------------------------------------------------ #
# マイグレーション
# ------------------------------------------------------------------ #
Expand Down
8 changes: 4 additions & 4 deletions backend/app/routers/auth/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from ...core.settings import get_callback_base_url
from ...db import get_db
from ...repositories import UserRepository
from ...schemas import GitHubCallbackRequest, TokenResponse
from ...schemas import GitHubCallbackRequest, GitHubLoginUrlResponse, TokenResponse
from .github_auth import authenticate_github_user
from .oauth_flow import (
begin_github_oauth,
Expand Down Expand Up @@ -142,19 +142,19 @@ def me(request: Request, current_user=Depends(get_current_user)) -> TokenRespons
)


@router.get("/github/login-url")
@router.get("/github/login-url", response_model=GitHubLoginUrlResponse)
@limiter.limit("10/minute")
def github_login_url(
request: Request,
return_to: str | None = None,
) -> dict[str, str]:
) -> GitHubLoginUrlResponse:
"""GitHub OAuth 認可 URL と state を返す。

state はフロントが sessionStorage に保持し、コールバック時に CSRF 検証する。
"""
frontend_url = resolve_frontend_url_from_request(request, return_to)
authorization_url, state = begin_github_oauth(request, frontend_url)
return {"authorization_url": authorization_url, "state": state}
return GitHubLoginUrlResponse(authorization_url=authorization_url, state=state)


@router.get("/github/login")
Expand Down
12 changes: 6 additions & 6 deletions backend/app/routers/github_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
GitHubLinkRequest,
ProgressResponse,
)
from ..schemas.shared import TaskStatusResponse
from ..schemas.shared import TaskAcceptedResponse, TaskStatusResponse
from ..services.tasks import AsyncTaskCacheService, TaskType

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -98,7 +98,7 @@ def get_cache_status(
)


@router.post("/run", status_code=202)
@router.post("/run", response_model=TaskAcceptedResponse, status_code=202)
@limiter.limit("5/minute")
async def start_github_link(
request: Request,
Expand All @@ -124,7 +124,7 @@ async def start_github_link(

# DB 最新状態を取得しつつ pending へアトミック遷移。進行中なら早期リターン
if not service.try_reset_to_pending():
return {"status": cache.status}
return TaskAcceptedResponse(status=cache.status)

try:
await service.dispatch(
Expand All @@ -142,10 +142,10 @@ async def start_github_link(
except Exception:
_raise_dispatch_failed()

return {"status": "pending"}
return TaskAcceptedResponse(status="pending")


@router.post("/run/retry", status_code=202)
@router.post("/run/retry", response_model=TaskAcceptedResponse, status_code=202)
@limiter.limit("5/minute")
async def retry_github_link(
request: Request,
Expand Down Expand Up @@ -212,4 +212,4 @@ async def retry_github_link(
except Exception:
_raise_dispatch_failed()

return {"status": "pending"}
return TaskAcceptedResponse(status="pending")
3 changes: 2 additions & 1 deletion backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Pydantic スキーマ。"""

from .auth import GitHubCallbackRequest, TokenResponse, UserResponse
from .auth import GitHubCallbackRequest, GitHubLoginUrlResponse, TokenResponse, UserResponse
from .blog import (
BlogAccountCreate,
BlogAccountResponse,
Expand Down Expand Up @@ -50,6 +50,7 @@
"Experience",
"GitHubCallbackRequest",
"GitHubLinkRequest",
"GitHubLoginUrlResponse",
"GitHubLinkResponse",
"MasterItem",
"MasterItemCreate",
Expand Down
10 changes: 10 additions & 0 deletions backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ class UserResponse(BaseModel):
class GitHubCallbackRequest(BaseModel):
code: str = Field(min_length=1)
state: str = Field(min_length=1)


class GitHubLoginUrlResponse(BaseModel):
"""GitHub OAuth 認可 URL と CSRF 検証用 state を返すレスポンス。

state はフロントが sessionStorage に保持し、コールバック時に CSRF 検証する。
"""

authorization_url: str
state: str
11 changes: 11 additions & 0 deletions backend/app/schemas/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ class TaskStatusResponse(BaseModel):
error_code: str | None = None


class TaskAcceptedResponse(BaseModel):
"""非同期タスクの受付応答(202 Accepted)。

``POST /github/run`` / ``POST /github/run/retry`` など、
バックグラウンドタスクを開始するエンドポイントで共通利用される受付レスポンス。
現在のタスクステータス(``pending`` 等)のみを返す。
"""

status: str


class SubProgress(BaseModel):
"""ステップ内の細粒度な進捗(例: リポジトリ詳細取得ステップ)。"""

Expand Down
55 changes: 55 additions & 0 deletions backend/scripts/export_openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""FastAPI アプリの OpenAPI スキーマを JSON にダンプするスクリプト。

frontend の型生成(`openapi-typescript`)の入力となる `backend/openapi.json` を出力する。
ADR-0007(OpenAPI → TypeScript 型コード生成)のパイプライン Phase 0 の一部。

使用例(必ず Nix devshell 経由で実行する。WeasyPrint 等のネイティブ依存解決のため):
nix develop --command bash -c "cd backend && .venv/bin/python scripts/export_openapi.py"

出力先はデフォルトで `backend/openapi.json`。第 1 引数で変更可能。

注意:
- `app.main` を import するだけで FastAPI app は構築される(lifespan の bootstrap は実行されない)。
- ENVIRONMENT 未設定時は local 扱いとなり、INTERNAL_SECRET 等の必須チェックは走らない。
- `app.db.database` は import 時に `build_sqlalchemy_database_url()` を評価するため
TURSO_DATABASE_URL が必須。OpenAPI 出力では DB 接続しない(URL を組み立てて engine を
作るだけで connect しない)ので、未設定時はダミーの sqlite URL を充てて import を通す。
- diff の安定化のため `sort_keys=True` で出力する(openapi-typescript はキー順に依存しない)。
"""

import json
import os
import sys
from pathlib import Path

# scripts/ から見たプロジェクトルート(backend/)配下に openapi.json を出力する。
# `app` パッケージ解決を cwd に依存させないため、env_keys の import より前に path を通す。
_BACKEND_DIR = Path(__file__).resolve().parent.parent
_DEFAULT_OUTPUT = _BACKEND_DIR / "openapi.json"
sys.path.insert(0, str(_BACKEND_DIR))

from app.core import env_keys # noqa: E402

# import 時の設定チェックを避けるため、未設定なら local 環境として扱う。
os.environ.setdefault(env_keys.ENVIRONMENT, "local")

# `app.db.database` の import 時に URL 構築が走る。OpenAPI 出力は DB へ接続しないため、
# CI など TURSO_DATABASE_URL 未設定の環境では接続されないダミー sqlite URL を充てる。
os.environ.setdefault(env_keys.TURSO_DATABASE_URL, "file:openapi-export-dummy.sqlite")


def main() -> None:
from app.main import app

output_path = Path(sys.argv[1]) if len(sys.argv) > 1 else _DEFAULT_OUTPUT
schema = app.openapi()
# 末尾に改行を付け、エディタ・lint と整合させる。
output_path.write_text(
json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
print(f"OpenAPI スキーマを書き出しました: {output_path}")


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# openapi-typescript の自動生成物(手編集禁止 / フォーマット対象外)。ADR-0007 参照。
src/api/generated.ts
Loading
Loading