From 66ca03140a4cdae8e5d656f985bec4ef672026fb Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Fri, 29 May 2026 23:19:22 +0900 Subject: [PATCH] ADR0007 ph2 --- backend/app/schemas/github_link.py | 6 +- docs/adr/0007-openapi-typescript-codegen.md | 2 +- frontend/src/api/auth.ts | 13 ++- frontend/src/api/generated.ts | 81 ++++++++++++++++++- frontend/src/api/githubLink.ts | 75 ++++------------- frontend/src/api/types.ts | 31 ++++++- .../github-link/ContributionHeatmap.tsx | 15 ++-- .../github-link/GitHubLinkDashboard.tsx | 3 +- frontend/src/hooks/useAsyncTaskPage.ts | 11 +-- 9 files changed, 154 insertions(+), 83 deletions(-) diff --git a/backend/app/schemas/github_link.py b/backend/app/schemas/github_link.py index a04509c3..68bc5050 100644 --- a/backend/app/schemas/github_link.py +++ b/backend/app/schemas/github_link.py @@ -1,6 +1,6 @@ """GitHub 連携 API 用の Pydantic スキーマ。""" -from typing import Any, Dict, Optional +from typing import Dict, Optional from pydantic import BaseModel, Field @@ -63,7 +63,9 @@ class GitHubLinkResponse(BaseModel): class CachedGitHubLinkResponse(BaseModel): """DB に保存された連携結果を返す。""" - result: Optional[Dict[str, Any]] = None + # cache.result は GitHubLinkResponse(...).model_dump() を保存したものなので、 + # 型を絞って OpenAPI に GitHubLinkResponse / Contribution* を出力させる(ADR-0007 Phase 2)。 + result: Optional[GitHubLinkResponse] = None status: Optional[str] = None error_message: Optional[str] = None error_code: Optional[str] = None diff --git a/docs/adr/0007-openapi-typescript-codegen.md b/docs/adr/0007-openapi-typescript-codegen.md index 2331edc3..d025bae1 100644 --- a/docs/adr/0007-openapi-typescript-codegen.md +++ b/docs/adr/0007-openapi-typescript-codegen.md @@ -77,7 +77,7 @@ FastAPI が出力する OpenAPI スキーマから **`openapi-typescript`** で |---|---|---|---| | 0 ✅完了 | 前提・基盤 | ①`response_model` 棚卸し(未設定 14 個を「DTO 不要」と「schema 化必要」に仕分け)②不足 schema 追加 + `response_model` 付与(`/github/login-url`→`GitHubLoginUrlResponse`、202 系→共通 `TaskAcceptedResponse`)③`backend/scripts/export_openapi.py` 追加 ④`openapi-typescript` を devDependency 追加 ⑤`make codegen-types`(Nix wrap)⑥`generated.ts` 初回生成 ⑦CI ドリフト検知(`git diff --exit-code`)| 中 | | 1 ✅完了 | 読み取り(パイロット) | `api/shared.ts`(`TaskStatusResponse`)を完全移行。手書き interface を削除し、生成物の薄い再エクスポート層 `api/types.ts` へ統合。論点B(`str \| None`→`string \| null`)に伴い消費側 `useTaskPolling` / `useAsyncTaskPage` の `checkStatus` 契約を `string \| null` 許容へ更新。`make ci` green と「BE schema をわざと変えると `git diff --exit-code` が fail する」ことを確認済み | 低〜中 | -| 2 | 主要レスポンス | `api/githubLink.ts`(`TaskProgress`→`ProgressResponse` 含む)・`api/auth.ts`(`AuthResponse`→`TokenResponse`)を完全移行。手書き interface を全削除し BE 名へ rename。E2E 必須 | 中 | +| 2 ✅完了 | 主要レスポンス | `api/githubLink.ts`(`TaskProgress`→`ProgressResponse`、`{status}`→`TaskAcceptedResponse`、`GitHubLinkResponse` / `Contribution*` / `CachedGitHubLinkResponse`)・`api/auth.ts`(`AuthResponse`→`TokenResponse`、login-url→`GitHubLoginUrlResponse`)を完全移行。`CachedGitHubLinkResponse.result` を backend で `GitHubLinkResponse \| None` に絞り OpenAPI へ出力(型安全の後退を防止)。論点B(生成型 optional 化)に伴い `useAsyncTaskPage` の loadCache / `ContributionHeatmap` の weeks を null 合体で追従。`make ci` green・backend 68 passed・E2E 22 passed・codegen 冪等を確認済み。request 型 `GitHubLinkPayload`(default 値あり)は論点A により手書き温存 | 中 | | 3 | フォーム入出力含む | `types.ts` の `CareerResume*`(→`Resume*`)/ `BlogAccount`(→`BlogAccountResponse`)/ `MasterItem` 系を完全移行。`payloadBuilders.ts` / `formMappers.ts` の追従。E2E 必須 | 中〜高 | > **完全移行の前提(Phase 0 に内包)**: 生成できるのは `response_model` 付きエンドポイントのみ(実測 40 中 26)。DTO を持つのに `response_model` 未設定のエンドポイント(`/github/login-url` の `{authorization_url, state}`、`/run`・`/run/retry` の `{status}`)を schema 化・付与する作業を、完全移行の前提として Phase 0 で実施する。これを怠ると「生成物と手書きが混在する中途半端な状態」になり完全移行が成立しない。 diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index dc84980b..029cdb79 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,10 +1,9 @@ import { FALLBACK_MESSAGES } from "../constants/messages"; import { API_BASE_URL, request } from "./client"; import { PATHS } from "./paths"; +import type { GitHubLoginUrlResponse, TokenResponse } from "./types"; -type AuthResponse = { username: string; is_github_user: boolean }; - -export async function getCurrentUser(): Promise { +export async function getCurrentUser(): Promise { const response = await fetch(`${API_BASE_URL}${PATHS.auth.me}`, { credentials: "include", }); @@ -14,11 +13,11 @@ export async function getCurrentUser(): Promise { if (!response.ok) { throw new Error(FALLBACK_MESSAGES.AUTH_CHECK); } - return (await response.json()) as AuthResponse; + return (await response.json()) as TokenResponse; } -export async function handleGitHubCallback(code: string, state: string): Promise { - return request(PATHS.auth.githubCallback, { +export async function handleGitHubCallback(code: string, state: string): Promise { + return request(PATHS.auth.githubCallback, { method: "POST", body: JSON.stringify({ code, state }), }); @@ -36,7 +35,7 @@ export async function initiateGitHubLogin(returnTo: string): Promise { credentials: "include", }); if (!response.ok) throw new Error(FALLBACK_MESSAGES.GITHUB_OAUTH_START); - const data = (await response.json()) as { authorization_url: string; state: string }; + const data = (await response.json()) as GitHubLoginUrlResponse; // CSRF 検証用に state を sessionStorage へ保存する(コールバックで照合) sessionStorage.setItem(GITHUB_OAUTH_STATE_STORAGE_KEY, data.state); window.location.assign(data.authorization_url); diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 8fb1f302..dadb3415 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -809,8 +809,7 @@ export interface components { error_code?: string | null; /** Error Message */ error_message?: string | null; - /** Result */ - result?: Record | null; + result?: components["schemas"]["GitHubLinkResponse"] | null; /** Status */ status?: string | null; /** Warning Message */ @@ -908,6 +907,43 @@ export interface components { */ vacation_start_date: string; }; + /** + * ContributionCalendar + * @description 直近1年のコントリビューションカレンダー(GitHub の緑の四角)。 + */ + ContributionCalendar: { + /** + * Total Contributions + * @description 期間内のコントリビューション総数 + */ + total_contributions: number; + /** + * Weeks + * @description 週ごとの日配列(列=週、各週は最大7日) + */ + weeks?: components["schemas"]["ContributionDay"][][]; + }; + /** + * ContributionDay + * @description コントリビューションカレンダーの 1 日分。 + */ + ContributionDay: { + /** + * Count + * @description その日のコントリビューション数 + */ + count: number; + /** + * Date + * @description ISO 8601 形式の日付 (YYYY-MM-DD) + */ + date: string; + /** + * Level + * @description GitHub の濃淡レベル (0–4) + */ + level: number; + }; /** Experience */ "Experience-Input": { /** Business Description */ @@ -1024,6 +1060,47 @@ export interface components { */ include_forks: boolean; }; + /** GitHubLinkResponse */ + GitHubLinkResponse: { + /** Analyzed At */ + analyzed_at: string; + /** @description 直近1年のコントリビューションカレンダー(取得失敗時は None) */ + contribution_calendar?: components["schemas"]["ContributionCalendar"] | null; + /** + * Detected Devtools + * @description ルートファイルから検出した DevTools 名 → 使用リポジトリ数 + */ + detected_devtools?: { + [key: string]: number; + }; + /** + * Detected Frameworks + * @description 依存関係から検出したフレームワーク名 → 使用リポジトリ数 + */ + detected_frameworks?: { + [key: string]: number; + }; + /** + * Detected Infras + * @description ルートファイルから検出したインフラツール名 → 使用リポジトリ数 + */ + detected_infras?: { + [key: string]: number; + }; + /** + * Languages + * @description 言語ごとのバイト数(GitHub linguist ベース) + */ + languages?: { + [key: string]: number; + }; + /** Repos Analyzed */ + repos_analyzed: number; + /** Unique Skills */ + unique_skills: number; + /** Username */ + username: string; + }; /** * GitHubLoginUrlResponse * @description GitHub OAuth 認可 URL と CSRF 検証用 state を返すレスポンス。 diff --git a/frontend/src/api/githubLink.ts b/frontend/src/api/githubLink.ts index e0dc9294..99d190c6 100644 --- a/frontend/src/api/githubLink.ts +++ b/frontend/src/api/githubLink.ts @@ -1,67 +1,26 @@ import { request } from "./client"; import { PATHS } from "./paths"; -import type { TaskStatusResponse } from "./types"; - -export interface TaskProgress { - task_id: string; - step_index: number; - total_steps: number; - step_label: string | null; - sub_progress: { done: number; total: number } | null; -} +import type { + CachedGitHubLinkResponse, + ProgressResponse, + TaskAcceptedResponse, + TaskStatusResponse, +} from "./types"; +/** + * GitHub 連携の開始リクエスト body。 + * backend `schemas/github_link.py:GitHubLinkRequest` 相当だが、呼び出し側で + * 省略可能にするため include_forks を optional にした手書きの request 型(ADR-0007 論点A)。 + */ export interface GitHubLinkPayload { include_forks?: boolean; } -/** コントリビューションカレンダーの 1 日分 */ -export interface ContributionDay { - /** ISO 8601 形式の日付 (YYYY-MM-DD) */ - date: string; - /** その日のコントリビューション数 */ - count: number; - /** GitHub の濃淡レベル (0–4) */ - level: number; -} - -/** 直近1年のコントリビューションカレンダー(GitHub の緑の四角) */ -export interface ContributionCalendar { - /** 期間内のコントリビューション総数 */ - total_contributions: number; - /** 週ごとの日配列(列=週、各週は最大7日) */ - weeks: ContributionDay[][]; -} - -export interface GitHubLinkResponse { - username: string; - repos_analyzed: number; - unique_skills: number; - analyzed_at: string; - languages: Record; - /** 依存関係から検出したフレームワーク名 → 使用リポジトリ数 */ - detected_frameworks: Record; - /** ルートファイルから検出した DevTools 名 → 使用リポジトリ数 */ - detected_devtools: Record; - /** ルートファイルから検出したインフラツール名 → 使用リポジトリ数 */ - detected_infras: Record; - /** 直近1年のコントリビューションカレンダー(取得失敗時は null) */ - contribution_calendar?: ContributionCalendar | null; -} - -export interface CachedGitHubLinkResponse { - result: GitHubLinkResponse | null; - status?: string; - error_message?: string; - error_code?: string; - /** 連携自体は完了したが部分的に欠落した場合の警告 */ - warning_message?: string; -} - /** * GitHub 連携を開始します(202 非同期)。 */ -export function runGitHubLink(payload: GitHubLinkPayload): Promise<{ status: string }> { - return request<{ status: string }>(PATHS.githubLink.run, { +export function runGitHubLink(payload: GitHubLinkPayload): Promise { + return request(PATHS.githubLink.run, { method: "POST", body: JSON.stringify(payload), }); @@ -85,8 +44,8 @@ export function getGitHubLinkCacheStatus(): Promise { * GitHub 連携タスクの進捗を取得します。 * Redis が利用できない場合は step_index=0 のデフォルト値が返ります。 */ -export function getGitHubLinkProgress(): Promise { - return request(PATHS.githubLink.progress); +export function getGitHubLinkProgress(): Promise { + return request(PATHS.githubLink.progress); } /** @@ -94,8 +53,8 @@ export function getGitHubLinkProgress(): Promise { */ export function retryGitHubLink( payload: GitHubLinkPayload = {}, -): Promise<{ status: string }> { - return request<{ status: string }>(PATHS.githubLink.runRetry, { +): Promise { + return request(PATHS.githubLink.runRetry, { method: "POST", body: JSON.stringify(payload), }); diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d507ee0a..7508e220 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -7,8 +7,37 @@ */ import type { components } from "./generated"; +type Schemas = components["schemas"]; + /** * 非同期タスクのステータスを返す軽量レスポンス。 * backend `app/schemas/shared.py:TaskStatusResponse` のミラー。 */ -export type TaskStatusResponse = components["schemas"]["TaskStatusResponse"]; +export type TaskStatusResponse = Schemas["TaskStatusResponse"]; + +/** 非同期タスクの受付応答(202 Accepted)。backend `schemas/shared.py:TaskAcceptedResponse`。 */ +export type TaskAcceptedResponse = Schemas["TaskAcceptedResponse"]; + +/** GitHub 連携タスクの進捗。backend `schemas/shared.py:ProgressResponse`。 */ +export type ProgressResponse = Schemas["ProgressResponse"]; + +/** 進捗の細粒度サブ進捗。backend `schemas/shared.py:SubProgress`。 */ +export type SubProgress = Schemas["SubProgress"]; + +/** コントリビューションカレンダーの 1 日分。backend `schemas/github_link.py:ContributionDay`。 */ +export type ContributionDay = Schemas["ContributionDay"]; + +/** 直近1年のコントリビューションカレンダー。backend `schemas/github_link.py:ContributionCalendar`。 */ +export type ContributionCalendar = Schemas["ContributionCalendar"]; + +/** GitHub 連携の解析結果。backend `schemas/github_link.py:GitHubLinkResponse`。 */ +export type GitHubLinkResponse = Schemas["GitHubLinkResponse"]; + +/** DB に保存された連携結果。backend `schemas/github_link.py:CachedGitHubLinkResponse`。 */ +export type CachedGitHubLinkResponse = Schemas["CachedGitHubLinkResponse"]; + +/** GitHub OAuth 認可 URL と CSRF 検証用 state。backend `schemas/auth.py:GitHubLoginUrlResponse`。 */ +export type GitHubLoginUrlResponse = Schemas["GitHubLoginUrlResponse"]; + +/** 認証トークン応答(ログインユーザー情報)。backend `schemas/auth.py:TokenResponse`。 */ +export type TokenResponse = Schemas["TokenResponse"]; diff --git a/frontend/src/components/github-link/ContributionHeatmap.tsx b/frontend/src/components/github-link/ContributionHeatmap.tsx index 2e45d051..4ce3ec18 100644 --- a/frontend/src/components/github-link/ContributionHeatmap.tsx +++ b/frontend/src/components/github-link/ContributionHeatmap.tsx @@ -19,25 +19,28 @@ interface ContributionHeatmapProps { * 年間コントリビュート数と最大連続日数のサマリーも表示する。 */ export function ContributionHeatmap({ calendar }: ContributionHeatmapProps) { + // weeks は OpenAPI 生成型では optional(backend の default_factory 由来)。null 合体で安定参照にする。 + const weeks = useMemo(() => calendar.weeks ?? [], [calendar.weeks]); + /** 各週の先頭日から月ラベルを算出する(前の週から月が変わる週にのみ表示) */ const monthLabels = useMemo(() => { // "YYYY-MM-DD" は UTC 深夜として parse されるため、ローカル TZ で月がずれない // よう getUTCMonth() で月を取り出す(負オフセット TZ での月境界ずれを防ぐ) const monthOf = (week: ContributionDay[]) => week[0] ? new Date(week[0].date).getUTCMonth() : -1; - return calendar.weeks.map((week, i) => { + return weeks.map((week, i) => { const month = monthOf(week); if (month < 0) return ""; - const prevMonth = i > 0 ? monthOf(calendar.weeks[i - 1]) : -1; + const prevMonth = i > 0 ? monthOf(weeks[i - 1]) : -1; return month !== prevMonth ? MONTH_NAMES[month] : ""; }); - }, [calendar.weeks]); + }, [weeks]); /** 連続コントリビュート日数の最大値を算出する */ const longestStreak = useMemo(() => { let longest = 0; let current = 0; - for (const day of calendar.weeks.flat()) { + for (const day of weeks.flat()) { if (day.count > 0) { current += 1; longest = Math.max(longest, current); @@ -46,7 +49,7 @@ export function ContributionHeatmap({ calendar }: ContributionHeatmapProps) { } } return longest; - }, [calendar.weeks]); + }, [weeks]); return (
@@ -74,7 +77,7 @@ export function ContributionHeatmap({ calendar }: ContributionHeatmapProps) { ))}
- {calendar.weeks.map((week, wi) => ( + {weeks.map((week, wi) => (
{week.map((day) => ( ({ loadCache: async () => { const cache = await getGitHubLinkCache(); - return { result: cache.result, status: cache.status }; + // result は生成型では optional(`GitHubLinkResponse | null | undefined`)のため null に正規化する。 + return { result: cache.result ?? null, status: cache.status }; }, checkStatus: getGitHubLinkCacheStatus, fetchProgress: getGitHubLinkProgress, diff --git a/frontend/src/hooks/useAsyncTaskPage.ts b/frontend/src/hooks/useAsyncTaskPage.ts index eaa84d60..79f1147f 100644 --- a/frontend/src/hooks/useAsyncTaskPage.ts +++ b/frontend/src/hooks/useAsyncTaskPage.ts @@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from "react"; import { useTaskPolling } from "./useTaskPolling"; import type { AppErrorState } from "../utils/appError"; import { isInProgressStatus } from "../utils/taskStatus"; -import type { TaskProgress } from "../api/githubLink"; +import type { ProgressResponse } from "../api/types"; /** 非同期タスクページのフェーズ型 */ export type AsyncTaskPhase = "loading-cache" | "input" | "polling" | "result"; @@ -28,7 +28,8 @@ export type UseAsyncTaskPageOptions = { */ loadCache: () => Promise<{ result: TResult | null; - status?: string; + // status は backend schema が `str | None`(生成型では `string | null`)のため null を許容する(ADR-0007 論点B)。 + status?: string | null; }>; /** * タスクステータス取得関数(ポーリング用)。 @@ -44,7 +45,7 @@ export type UseAsyncTaskPageOptions = { * 指定した場合、ポーリングのたびに並走して呼ばれる。 * 失敗してもポーリング本体には影響しない。 */ - fetchProgress?: () => Promise; + fetchProgress?: () => Promise; }; /** useAsyncTaskPage の戻り値型 */ @@ -78,7 +79,7 @@ type UseAsyncTaskPageReturn = { /** * 現在の進捗情報。fetchProgress が未指定または取得失敗時は null。 */ - progress: TaskProgress | null; + progress: ProgressResponse | null; }; /** @@ -97,7 +98,7 @@ export function useAsyncTaskPage({ const [phase, setPhase] = useState("loading-cache"); const [result, setResult] = useState(null); const [error, setError] = useState(null); - const [progress, setProgress] = useState(null); + const [progress, setProgress] = useState(null); /** * checkStatus を、進捗取得を並走させるラッパーに差し替える。