diff --git a/.claude/rules/infra/opentofu.md b/.claude/rules/infra/opentofu.md index 86876031..6cd4a5e9 100644 --- a/.claude/rules/infra/opentofu.md +++ b/.claude/rules/infra/opentofu.md @@ -11,7 +11,7 @@ infra/ └── environments/ # dev, stg, prod(各環境で tfvars 管理) ``` -CLI: OpenTofu (`tofu`) を使用する。Nix で管理されており `nix develop` シェル内で利用可能。`.tf` の構文は Terraform と同一。 +CLI: OpenTofu (`tofu`) を使用する。Nix で管理されており `nix develop` シェル内で利用可能。`.tf` の構文は Terraform と同一。`tofu init` / `plan` / `apply` の具体的な実行手順・GCS backend 認証は `docs/deployment.md`「OpenTofu」セクションを正本として参照(ここでは再掲しない)。 デプロイ: GitHub Actions で `dev` ブランチ push 時に frontend → Cloudflare Pages、backend → Docker → Artifact Registry → Cloud Run。 DB は Turso (libSQL) を使用。**DB 本体は OpenTofu の `infra/modules/turso/` で管理**(jpedroh/turso provider)。`module.turso.database_url` を cloud_run module の `turso_database_url` に渡す構成。auth token のみ state に乗せたくないため `turso CLI` で発行 → Secret Manager `-turso-auth-token` に手動投入する運用。詳細は `docs/data-model.md` の「Turso セットアップ」参照。 diff --git a/.jscpd.json b/.jscpd.json index a3e74adb..1eca1310 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -55,7 +55,13 @@ "**/infra/environments/prod/moved.tf", "**/infra/environments/dev/outputs.tf", "**/infra/environments/stg/outputs.tf", - "**/infra/environments/prod/outputs.tf" + "**/infra/environments/prod/outputs.tf", + "**/infra/environments/dev/versions.tf", + "**/infra/environments/stg/versions.tf", + "**/infra/environments/prod/versions.tf", + "**/infra/environments/dev/main.tf", + "**/infra/environments/stg/main.tf", + "**/infra/environments/prod/main.tf" ], "reporters": ["json", "html", "markdown", "consoleFull"], "output": "report/dupe", diff --git a/README.md b/README.md index 33991395..27aa6bca 100644 --- a/README.md +++ b/README.md @@ -60,22 +60,9 @@ GitHub活動分析、ブログ連携による発信力を集計 ## クイックスタート -詳細手順は [docs/development.md](./docs/development.md) を参照。 - -```bash -nix develop # devshell に入る(direnv 利用時は自動) -make setup # git hooks + backend (.venv + uv) + frontend (npm ci) -make generate-keys # JWT RS256 鍵ペアを生成 -cp backend/.env.example backend/.env - -make dev # docker compose up(FastAPI + Ollama + Redis + libSQL) -``` - -CI 相当を一括で走らせる: - -```bash -make ci # lint + test + build-frontend -``` +初回セットアップ(`nix develop` / `make setup` / `make generate-keys` / `make dev`)と +CI 相当の一括実行(`make ci`)の手順は [docs/development.md](./docs/development.md) を正本として参照してください。 +README にコマンドを再掲すると docs と二重管理になり同期漏れの原因になるため、リンクのみとしています。 ## システム構成図 diff --git a/backend/alembic_migrations/versions/0039_add_capital_unit.py b/backend/alembic_migrations/versions/0039_add_capital_unit.py new file mode 100644 index 00000000..269ed0c2 --- /dev/null +++ b/backend/alembic_migrations/versions/0039_add_capital_unit.py @@ -0,0 +1,41 @@ +"""resume_experiences に capital_unit カラムを追加する + +資本金の単位(万円 / 百万円 / 千万円 / 億円)を在籍企業ごとに選択できるようにする。 +従来は表示時に「千万円」固定だったため、既存行の後方互換として server_default を +「千万円」にする。 + +libSQL (SQLite 互換) は ALTER COLUMN / DROP COLUMN を直接サポートしないため、 +downgrade の列削除は batch_alter_table(テーブル再作成)で行う。ADD COLUMN は +SQLite が直接サポートするため upgrade は op.add_column をそのまま使う。 + +Revision ID: 0039_add_capital_unit +Revises: 0038_add_project_periods_table +Create Date: 2026-05-29 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0039_add_capital_unit" +down_revision: Union[str, None] = "0038_add_project_periods_table" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "resume_experiences", + sa.Column( + "capital_unit", + sa.String(12), + nullable=False, + server_default="千万円", + ), + ) + + +def downgrade() -> None: + with op.batch_alter_table("resume_experiences") as batch_op: + batch_op.drop_column("capital_unit") diff --git a/backend/alembic_migrations/versions/0040_add_resume_non_it_and_vacation.py b/backend/alembic_migrations/versions/0040_add_resume_non_it_and_vacation.py new file mode 100644 index 00000000..51a32842 --- /dev/null +++ b/backend/alembic_migrations/versions/0040_add_resume_non_it_and_vacation.py @@ -0,0 +1,68 @@ +"""非IT経歴フラグ・詳細と休暇エントリのカラムを追加する + +- resume_experiences に is_it_company(IT企業かどうか)と description(非IT時の詳細)を追加。 + 既存行は IT 企業前提だったため is_it_company の server_default を True(1) にする。 +- resume_clients に is_vacation(休暇エントリか)と vacation_* 期間/詳細を追加。 + 既存行は通常の取引先のため is_vacation の server_default を False(0) にする。 + +libSQL (SQLite 互換) は ADD COLUMN を直接サポートするため upgrade は op.add_column を +そのまま使う。downgrade の列削除は ALTER/DROP COLUMN 非対応のため batch_alter_table +(テーブル再作成)で行う。 + +Revision ID: 0040_add_resume_non_it_and_vacation +Revises: 0039_add_capital_unit +Create Date: 2026-05-29 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0040_add_resume_non_it_and_vacation" +down_revision: Union[str, None] = "0039_add_capital_unit" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "resume_experiences", + sa.Column("is_it_company", sa.Boolean(), nullable=False, server_default=sa.text("1")), + ) + op.add_column( + "resume_experiences", + sa.Column("description", sa.Text(), nullable=False, server_default=""), + ) + op.add_column( + "resume_clients", + sa.Column("is_vacation", sa.Boolean(), nullable=False, server_default=sa.text("0")), + ) + op.add_column( + "resume_clients", + sa.Column("vacation_start_date", sa.Date(), nullable=True), + ) + op.add_column( + "resume_clients", + sa.Column("vacation_end_date", sa.Date(), nullable=True), + ) + op.add_column( + "resume_clients", + sa.Column("vacation_is_current", sa.Boolean(), nullable=False, server_default=sa.text("0")), + ) + op.add_column( + "resume_clients", + sa.Column("vacation_description", sa.Text(), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + with op.batch_alter_table("resume_clients") as batch_op: + batch_op.drop_column("vacation_description") + batch_op.drop_column("vacation_is_current") + batch_op.drop_column("vacation_end_date") + batch_op.drop_column("vacation_start_date") + batch_op.drop_column("is_vacation") + with op.batch_alter_table("resume_experiences") as batch_op: + batch_op.drop_column("description") + batch_op.drop_column("is_it_company") diff --git a/backend/app/core/env_keys.py b/backend/app/core/env_keys.py index d871963e..d323aa0a 100644 --- a/backend/app/core/env_keys.py +++ b/backend/app/core/env_keys.py @@ -70,6 +70,13 @@ VERTEX_PROJECT_ID = "VERTEX_PROJECT_ID" VERTEX_LOCATION = "VERTEX_LOCATION" VERTEX_MODEL = "VERTEX_MODEL" +# Ollama プロバイダ(休眠インフラ・温存)。ローカル開発では docker-compose.yml の +# environment ブロックで注入する。本番(Cloud Run)には注入していない。 +# なお `OLLAMA_KEEP_ALIVE`(docker-compose.yml の ollama サービス側)は +# Ollama サーバ自身の設定であり backend は参照しないため、ここには定義しない。 +OLLAMA_BASE_URL = "OLLAMA_BASE_URL" +OLLAMA_MODEL = "OLLAMA_MODEL" +OLLAMA_TIMEOUT = "OLLAMA_TIMEOUT" # --- 非同期タスク(Cloud Tasks / Local BackgroundTasks) --- diff --git a/backend/app/messages.json b/backend/app/messages.json index 506dfd36..c4335c30 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -39,7 +39,8 @@ "github_user_not_found": "GitHubユーザーが見つかりません: {username}", "profile_fetch_failed": "GitHubプロフィールの取得に失敗しました。しばらくしてから再度お試しください。", "no_link_cache": "連携データがありません。先にGitHub連携を実行してください。", - "not_retryable": "このタスクはリトライできない状態です(現在: {status})。" + "not_retryable": "このタスクはリトライできない状態です(現在: {status})。", + "contribution_fetch_failed": "コントリビューション情報の取得に失敗しました。スキル集計は表示されています。" }, "validation": { "required_field": "{field}は必須です。", diff --git a/backend/app/models/resume.py b/backend/app/models/resume.py index 58aa9cd5..5435ec3a 100644 --- a/backend/app/models/resume.py +++ b/backend/app/models/resume.py @@ -104,6 +104,12 @@ class ResumeExperience(Base): is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) employee_count: Mapped[str] = mapped_column(String(60), nullable=False, default="") capital: Mapped[str] = mapped_column(String(120), nullable=False, default="") + # 資本金の単位(万円 / 百万円 / 千万円 / 億円)。既定は後方互換のため「千万円」。 + capital_unit: Mapped[str] = mapped_column(String(12), nullable=False, default="千万円") + # IT 企業かどうか。False(非IT)の場合は取引先/プロジェクトを持たず description を使う。 + is_it_company: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # 非IT企業の職務内容を記述する自由記述欄(見出し「詳細」)。 + description: Mapped[str] = mapped_column(Text, nullable=False, default="") client_rows: Mapped[list["ResumeClient"]] = relationship( back_populates="experience", cascade="all, delete-orphan", @@ -138,6 +144,16 @@ class ResumeClient(Base): sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) name: Mapped[str] = mapped_column(String(200), nullable=False, default="") has_client: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # 休暇エントリ。True のとき取引先ではなく在籍中の休暇を表し vacation_* を使う。 + is_vacation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + vacation_start_date_value: Mapped[date | None] = mapped_column( + "vacation_start_date", Date, nullable=True + ) + vacation_end_date_value: Mapped[date | None] = mapped_column( + "vacation_end_date", Date, nullable=True + ) + vacation_is_current: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + vacation_description: Mapped[str] = mapped_column(Text, nullable=False, default="") project_rows: Mapped[list["ResumeProject"]] = relationship( back_populates="client", cascade="all, delete-orphan", @@ -150,6 +166,15 @@ def projects(self) -> list["ResumeProject"]: """プロジェクトを期間の降順でソートして返す。""" return sort_by_period_desc(list(self.project_rows)) + @property + def vacation_start_date(self) -> str: + return format_year_month(self.vacation_start_date_value) or "" + + @property + def vacation_end_date(self) -> str: + """DB の vacation_end_date が NULL(継続中)の場合は "" を返す。""" + return format_year_month(self.vacation_end_date_value) or "" + class ResumeProject(Base): __tablename__ = "resume_projects" diff --git a/backend/app/repositories/blog.py b/backend/app/repositories/blog.py index 65c437ae..355fae5e 100644 --- a/backend/app/repositories/blog.py +++ b/backend/app/repositories/blog.py @@ -87,6 +87,7 @@ def list_by_user(self, platform: str | None = None) -> list[BlogArticle]: return list(self.db.scalars(statement).all()) def upsert_many(self, articles: list[dict]) -> int: + """複数アカウントにまたがる記事を追加・更新する(既存の削除は行わない)。""" normalized_articles = [self._normalize_article(article) for article in articles] if not normalized_articles: return 0 @@ -102,29 +103,14 @@ def upsert_many(self, articles: list[dict]) -> int: .options(selectinload(BlogArticle.tag_rows)) ) existing_articles = list(self.db.scalars(existing_statement).all()) - existing_map = { - (article.account_id, article.external_id): article for article in existing_articles - } - - added = 0 - for article in normalized_articles: - key = (article["account_id"], article["external_id"]) - existing = existing_map.get(key) - if existing: - self._apply_article_payload(existing, article) - continue - - entity = BlogArticle(account_id=article["account_id"]) - self._apply_article_payload(entity, article) - self.db.add(entity) - existing_map[key] = entity - added += 1 - - self.db.commit() - return added + return self._merge(normalized_articles, existing_articles, delete_missing=False) def sync_many(self, account_id: str, articles: list[dict]) -> int: - normalized_articles = [self._normalize_article(article) for article in articles] + """単一アカウントの記事を全置換する(incoming に無い既存記事は削除する)。""" + # 新規エンティティ生成と key 突合のため、各記事へ account_id を確実に付与する。 + normalized_articles = [ + self._normalize_article({**article, "account_id": account_id}) for article in articles + ] existing_statement = ( select(BlogArticle) @@ -134,24 +120,45 @@ def sync_many(self, account_id: str, articles: list[dict]) -> int: .options(selectinload(BlogArticle.tag_rows)) ) existing_articles = list(self.db.scalars(existing_statement).all()) - existing_map = {article.external_id: article for article in existing_articles} - incoming_external_ids = {article["external_id"] for article in normalized_articles} + return self._merge(normalized_articles, existing_articles, delete_missing=True) + + def _merge( + self, + normalized_articles: list[dict[str, Any]], + existing_articles: list[BlogArticle], + *, + delete_missing: bool, + ) -> int: + """正規化済み記事を既存レコードへマージし、追加件数を返す。 + + key は ``(account_id, external_id)``。``delete_missing=True`` のとき、 + incoming に存在しない既存記事を削除する(単一アカウントの全置換 sync 用)。 + """ + existing_map = { + (article.account_id, article.external_id): article for article in existing_articles + } - for external_id, article in existing_map.items(): - if external_id not in incoming_external_ids: - self.db.delete(article) + if delete_missing: + incoming_keys = { + (article["account_id"], article["external_id"]) + for article in normalized_articles + } + for key, article in list(existing_map.items()): + if key not in incoming_keys: + self.db.delete(article) added = 0 for article in normalized_articles: - existing = existing_map.get(article["external_id"]) + key = (article["account_id"], article["external_id"]) + existing = existing_map.get(key) if existing: self._apply_article_payload(existing, article) continue - entity = BlogArticle(account_id=account_id) + entity = BlogArticle(account_id=article["account_id"]) self._apply_article_payload(entity, article) self.db.add(entity) - existing_map[article["external_id"]] = entity + existing_map[key] = entity added += 1 self.db.commit() diff --git a/backend/app/repositories/resume.py b/backend/app/repositories/resume.py index 068cab8f..eb697ef1 100644 --- a/backend/app/repositories/resume.py +++ b/backend/app/repositories/resume.py @@ -77,6 +77,9 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum is_current=payload.get("is_current", False), employee_count=payload.get("employee_count", ""), capital=payload.get("capital", ""), + capital_unit=payload.get("capital_unit", "千万円"), + is_it_company=payload.get("is_it_company", True), + description=payload.get("description", ""), client_rows=[ self._build_client_row(client_index, client) for client_index, client in enumerate(payload.get("clients", [])) @@ -86,10 +89,17 @@ def _build_experience_row(self, index: int, payload: dict[str, object]) -> Resum def _build_client_row(self, index: int, payload: dict[str, object]) -> ResumeClient: projects = list(payload.get("projects", [])) sorted_projects = sorted(projects, key=self._project_sort_key) + vacation_start = payload.get("vacation_start_date") or "" + vacation_end = payload.get("vacation_end_date") or "" return ResumeClient( sort_order=index, name=payload.get("name", ""), has_client=payload.get("has_client", True), + is_vacation=payload.get("is_vacation", False), + vacation_start_date_value=parse_year_month(vacation_start) if vacation_start else None, + vacation_end_date_value=parse_year_month(vacation_end) if vacation_end else None, + vacation_is_current=payload.get("vacation_is_current", False), + vacation_description=payload.get("vacation_description", ""), project_rows=[ self._build_project_row(project_index, project) for project_index, project in enumerate(sorted_projects) diff --git a/backend/app/routers/github_link.py b/backend/app/routers/github_link.py index d31de433..063dc422 100644 --- a/backend/app/routers/github_link.py +++ b/backend/app/routers/github_link.py @@ -30,6 +30,16 @@ router = APIRouter(prefix="/api/github-link", tags=["github-link"]) +def _raise_dispatch_failed() -> None: + """タスクのディスパッチに失敗したときの 500 エラーを送出する。""" + raise_app_error( + status_code=500, + code=ErrorCode.INTERNAL_ERROR, + message=get_error("task.dispatch_failed"), + action="しばらく待ってから再試行してください", + ) + + def _get_or_create_cache(db: Session, user_id: str) -> GitHubLinkCache: """ユーザーのキャッシュレコードを取得、なければ作成する。""" cache = db.query(GitHubLinkCache).filter_by(user_id=user_id).first() @@ -130,12 +140,7 @@ async def start_github_link( logger=logger, ) except Exception: - raise_app_error( - status_code=500, - code=ErrorCode.INTERNAL_ERROR, - message=get_error("task.dispatch_failed"), - action="しばらく待ってから再試行してください", - ) + _raise_dispatch_failed() return {"status": "pending"} @@ -205,11 +210,6 @@ async def retry_github_link( logger=logger, ) except Exception: - raise_app_error( - status_code=500, - code=ErrorCode.INTERNAL_ERROR, - message=get_error("task.dispatch_failed"), - action="しばらく待ってから再試行してください", - ) + _raise_dispatch_failed() return {"status": "pending"} diff --git a/backend/app/schemas/github_link.py b/backend/app/schemas/github_link.py index 449ffd2c..a04509c3 100644 --- a/backend/app/schemas/github_link.py +++ b/backend/app/schemas/github_link.py @@ -15,6 +15,24 @@ class GitHubLinkRequest(BaseModel): ) +class ContributionDay(BaseModel): + """コントリビューションカレンダーの 1 日分。""" + + date: str = Field(description="ISO 8601 形式の日付 (YYYY-MM-DD)") + count: int = Field(description="その日のコントリビューション数") + level: int = Field(description="GitHub の濃淡レベル (0–4)") + + +class ContributionCalendar(BaseModel): + """直近1年のコントリビューションカレンダー(GitHub の緑の四角)。""" + + total_contributions: int = Field(description="期間内のコントリビューション総数") + weeks: list[list[ContributionDay]] = Field( + default_factory=list, + description="週ごとの日配列(列=週、各週は最大7日)", + ) + + class GitHubLinkResponse(BaseModel): username: str repos_analyzed: int @@ -36,6 +54,10 @@ class GitHubLinkResponse(BaseModel): default_factory=dict, description="ルートファイルから検出したインフラツール名 → 使用リポジトリ数", ) + contribution_calendar: Optional[ContributionCalendar] = Field( + default=None, + description="直近1年のコントリビューションカレンダー(取得失敗時は None)", + ) class CachedGitHubLinkResponse(BaseModel): diff --git a/backend/app/schemas/resume.py b/backend/app/schemas/resume.py index c7de9007..54c72e76 100644 --- a/backend/app/schemas/resume.py +++ b/backend/app/schemas/resume.py @@ -5,6 +5,11 @@ ``CareerProject`` / ``CareerClient`` / ``CareerExperience`` 等)と対になる DTO。 言語境界のため codegen 未導入の手動同期で運用している(エラーコードの errors.py と同方針)。 フィールドを増減・rename する場合は対応する FE type も同時に更新すること。 + +非IT経歴: ``Experience.is_it_company=False`` のとき取引先/プロジェクトを持たず +``Experience.description``(詳細)のみ使う。 +休暇: ``Client.is_vacation=True`` のとき取引先ではなく在籍中の休暇を表し、 +``Client.vacation_*``(期間・詳細)を使う。 """ from datetime import datetime @@ -125,14 +130,45 @@ def _migrate_legacy_fields(cls, data: dict) -> dict: return data class Client(BaseModel): - """ユーザ(常駐先/クライアント企業)。""" + """ユーザ(常駐先/クライアント企業)。 + + ``is_vacation=True`` の場合は取引先ではなく在籍中の休暇(育児/介護/留学等)を表し、 + name / projects の代わりに ``vacation_*`` 期間と詳細を保持する。 + """ name: str = Field(max_length=200, default="") has_client: bool = True projects: list[Project] = Field(default_factory=list) + # 休暇エントリ。True のとき vacation_* を期間・詳細として扱い projects は無視する。 + is_vacation: bool = False + vacation_start_date: str = Field(default="", max_length=30) + # 継続中(vacation_is_current=True)の場合は "" を渡す契約。 + vacation_end_date: str = Field(default="", max_length=30) + vacation_is_current: bool = False + vacation_description: str = Field(max_length=4500, default="") model_config = ConfigDict(from_attributes=True) + @model_validator(mode="after") + def validate_vacation_dates(self) -> "Client": + """休暇の期間を検証する(Experience.validate_dates と同じ契約)。 + + 休暇でない取引先は検証対象外。開始年月は必須、継続中なら end を "" + に正規化し、それ以外は終了年月必須かつ end >= start を要求する。 + """ + if not self.is_vacation: + return self + if not self.vacation_start_date.strip(): + raise ValueError(get_error("validation.start_date_required")) + if self.vacation_is_current: + self.vacation_end_date = "" + return self + if not self.vacation_end_date.strip(): + raise ValueError(get_error("validation.end_date_required")) + if self.vacation_end_date < self.vacation_start_date: + raise ValueError(get_error("validation.date_range_invalid")) + return self + class Experience(BaseModel): company: str = Field(min_length=1, max_length=120) @@ -145,6 +181,12 @@ class Experience(BaseModel): is_current: bool = False employee_count: str = Field(max_length=60, default="") capital: str = Field(max_length=120, default="") + # 資本金の単位。既定は後方互換のため「千万円」。FE の CapitalUnit と対の DTO。 + capital_unit: Literal["万円", "百万円", "千万円", "億円"] = Field(default="千万円") + # IT 企業かどうか。False(非IT)の場合は取引先/プロジェクトを持たず description を使う。 + is_it_company: bool = True + # 非IT企業の職務内容を記述する自由記述欄(見出し「詳細」)。business_description=事業内容 とは別。 + description: str = Field(max_length=4500, default="") clients: list[Client] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/intelligence/github/contributions.py b/backend/app/services/intelligence/github/contributions.py new file mode 100644 index 00000000..24bb794b --- /dev/null +++ b/backend/app/services/intelligence/github/contributions.py @@ -0,0 +1,165 @@ +""" +GitHub GraphQL API でコントリビューションカレンダーを取得するモジュール。 + +REST では取得できない「緑の四角」カレンダー(contributionsCollection)を +GraphQL から取得する。`read:user` スコープのトークンで公開プロフィールの +コントリビューション情報を読み取れる。 + +このモジュールは連携パイプラインの**補助処理**であり、取得失敗時は +``logger.warning`` を残して ``None`` を返す(主処理のリポジトリ解析を巻き添えにしない)。 +""" + +import logging +from typing import Optional + +import httpx + +from ....schemas.github_link import ContributionCalendar, ContributionDay +from .api_client import GITHUB_API + +logger = logging.getLogger(__name__) + +_GRAPHQL_ENDPOINT = f"{GITHUB_API}/graphql" + +# GraphQL の contributionLevel enum を 0–4 の整数に正規化する対応表。 +_LEVEL_MAP = { + "NONE": 0, + "FIRST_QUARTILE": 1, + "SECOND_QUARTILE": 2, + "THIRD_QUARTILE": 3, + "FOURTH_QUARTILE": 4, +} + +_CONTRIBUTION_QUERY = """ +query($login: String!) { + user(login: $login) { + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date + contributionCount + contributionLevel + } + } + } + } + } +} +""" + + +async def fetch_contribution_calendar( + username: str, + token: str, +) -> Optional[ContributionCalendar]: + """GitHub のコントリビューションカレンダー(直近1年)を取得する。 + + 取得に失敗した場合(認証エラー・レート制限・ネットワーク障害・GraphQL エラー等)は + ``logger.warning`` を残して ``None`` を返す。連携自体は継続させる補助処理のため、 + 例外を送出しない。 + + Args: + username: 対象 GitHub ユーザー名。 + token: 認証用アクセストークン(復号済み)。 + + Returns: + ``ContributionCalendar`` または取得失敗時は ``None``。 + """ + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + } + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + _GRAPHQL_ENDPOINT, + headers=headers, + json={ + "query": _CONTRIBUTION_QUERY, + "variables": {"login": username}, + }, + ) + except (httpx.TimeoutException, httpx.ConnectError, httpx.HTTPError) as exc: + logger.warning( + "コントリビューション取得でネットワーク障害 (username=%s): %s", + username, + exc, + ) + return None + + if resp.status_code != 200: + logger.warning( + "コントリビューション取得が HTTP %s で失敗 (username=%s)", + resp.status_code, + username, + ) + return None + + try: + payload = resp.json() + except ValueError: + logger.warning("コントリビューション応答の JSON 解析に失敗 (username=%s)", username) + return None + + if payload.get("errors"): + logger.warning( + "コントリビューション取得で GraphQL エラー (username=%s): %s", + username, + payload["errors"], + ) + return None + + user = (payload.get("data") or {}).get("user") + if not user: + logger.warning("コントリビューション対象ユーザーが見つからない (username=%s)", username) + return None + + calendar_raw = (user.get("contributionsCollection") or {}).get( + "contributionCalendar" + ) + if not calendar_raw: + logger.warning("コントリビューションカレンダーが空 (username=%s)", username) + return None + + try: + return _parse_calendar(calendar_raw) + except Exception as exc: + logger.warning( + "コントリビューションカレンダーの解析に失敗 (username=%s): %s", + username, + exc, + ) + return None + + +def _parse_calendar(calendar_raw: dict) -> ContributionCalendar: + """GraphQL の contributionCalendar を ``ContributionCalendar`` に変換する。 + + 不正な day エントリ(date 欠落・型不一致など)は黙って読み飛ばし、 + 解析全体を巻き添えにしない(補助処理として best-effort で変換する)。 + """ + weeks: list[list[ContributionDay]] = [] + for week in calendar_raw.get("weeks", []): + days: list[ContributionDay] = [] + for day in week.get("contributionDays", []): + date = day.get("date") + if not isinstance(date, str) or not date: + # date 欠落・型不一致の day はスキップ + continue + count = day.get("contributionCount", 0) + days.append( + ContributionDay( + date=date, + count=count if isinstance(count, int) else 0, + level=_LEVEL_MAP.get(day.get("contributionLevel", "NONE"), 0), + ) + ) + weeks.append(days) + + total = calendar_raw.get("totalContributions", 0) + return ContributionCalendar( + total_contributions=total if isinstance(total, int) else 0, + weeks=weeks, + ) diff --git a/backend/app/services/intelligence/github_link_service.py b/backend/app/services/intelligence/github_link_service.py index d6e2a0e3..3567edc8 100644 --- a/backend/app/services/intelligence/github_link_service.py +++ b/backend/app/services/intelligence/github_link_service.py @@ -12,10 +12,12 @@ from ...core.encryption import decrypt_field from ...core.logging_utils import get_logger +from ...core.messages import get_error from ...models import GitHubLinkCache from ..progress_service import set_progress from ..tasks.exceptions import NonRetryableError from ..tasks.handlers.base import SessionFactory +from .github.contributions import fetch_contribution_calendar from .github_collector import GitHubUserNotFoundError, collect_repos from .pipeline import aggregate_intelligence from .response_mapper import map_pipeline_result @@ -94,11 +96,17 @@ async def _on_repo_fetched(done: int, total: int) -> None: db.commit() raise exc + # コントリビューションカレンダー取得(補助処理: 失敗しても連携は継続) + calendar = None + if token: + calendar = await fetch_contribution_calendar(payload["github_username"], token) + # ステップ 3: スキル抽出(集計関数で一括処理) await set_progress(task_id, 3, _TOTAL_STEPS, "スキル集計中...") result = aggregate_intelligence(payload["github_username"], repos) response = map_pipeline_result(result) + response.contribution_calendar = calendar result_dict = response.model_dump() # ── フェーズC: 結果書き戻し(新セッション)───────────────────────────── @@ -115,7 +123,13 @@ async def _on_repo_fetched(done: int, total: int) -> None: cache.result = result_dict cache.status = "completed" cache.error_message = None - cache.warning_message = None + # コントリビューション取得失敗は連携自体を失敗させず警告として残す + # (token が無い場合は取得を試行していないため警告は出さない) + cache.warning_message = ( + get_error("github_link.contribution_fetch_failed") + if token and calendar is None + else None + ) cache.completed_at = _now() db.commit() diff --git a/backend/app/services/intelligence/llm/ollama_client.py b/backend/app/services/intelligence/llm/ollama_client.py index ed293a52..70647733 100644 --- a/backend/app/services/intelligence/llm/ollama_client.py +++ b/backend/app/services/intelligence/llm/ollama_client.py @@ -6,6 +6,7 @@ import httpx +from ....core import env_keys from ....services.tasks.exceptions import NonRetryableError, RetryableError from .base import LLMClient @@ -19,9 +20,9 @@ class OllamaClient(LLMClient): """Ollama API を使用した LLM クライアント。""" def __init__(self) -> None: - self.base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") - self.model = os.environ.get("OLLAMA_MODEL", "gemma3:4b") - self.timeout = float(os.environ.get("OLLAMA_TIMEOUT", "1200.0")) # デフォルトは 20 分 + self.base_url = os.environ.get(env_keys.OLLAMA_BASE_URL, "http://localhost:11434") + self.model = os.environ.get(env_keys.OLLAMA_MODEL, "gemma3:4b") + self.timeout = float(os.environ.get(env_keys.OLLAMA_TIMEOUT, "1200.0")) # デフォルトは 20 分 async def generate(self, system_prompt: str, user_prompt: str) -> str | None: """Ollama API でテキスト生成を実行する。 diff --git a/backend/app/services/markdown/generators/resume_generator.py b/backend/app/services/markdown/generators/resume_generator.py index 10009412..e9f35c58 100644 --- a/backend/app/services/markdown/generators/resume_generator.py +++ b/backend/app/services/markdown/generators/resume_generator.py @@ -59,12 +59,35 @@ def build_resume_markdown(payload: dict[str, Any]) -> str: lines.append(field_line("従業員数", f"{emp}名")) cap = _a(exp, "capital") if cap: - lines.append(field_line("資本金", f"{cap}千万円")) + cap_unit = _a(exp, "capital_unit", "千万円") + lines.append(field_line("資本金", f"{cap}{cap_unit}")) lines.append("") + if not _a(exp, "is_it_company", True): + # 非IT企業: 取引先/プロジェクトの代わりに詳細を出力 + detail = _a(exp, "description") + if detail: + lines.append(field_line("詳細", detail)) + lines.append("") + continue + # clients → projects(後方互換含む正規化は shared に集約) clients = normalize_clients(exp) for client in clients: + if _a(client, "is_vacation", False): + period = format_period( + _a(client, "vacation_start_date"), + _a(client, "vacation_end_date", ""), + _a(client, "vacation_is_current", False), + ) + lines.append("#### 休暇") + lines.append("") + lines.append(field_line("期間", period)) + vacation_detail = _a(client, "vacation_description") + if vacation_detail: + lines.append(field_line("詳細", vacation_detail)) + lines.append("") + continue client_name = _a(client, "name") if client_name: lines.append(f"#### {client_name}") diff --git a/backend/app/services/pdf/generators/resume_generator.py b/backend/app/services/pdf/generators/resume_generator.py index 091f380e..56ad0c5e 100644 --- a/backend/app/services/pdf/generators/resume_generator.py +++ b/backend/app/services/pdf/generators/resume_generator.py @@ -128,6 +128,24 @@ def _build_project_html(project) -> str: ) +def _build_vacation_html(client) -> str: + """休暇エントリ1件分のHTMLを組み立てる(期間 + 詳細)。""" + period = _format_period( + _a(client, "vacation_start_date"), + _a(client, "vacation_end_date", ""), + _a(client, "vacation_is_current", False), + ) + header = f"休暇 {_esc(period)}" if period else "休暇" + description = _a(client, "vacation_description") + body = _md(description) if description else "-" + return ( + f'
' + f'
{header}
' + f'
{body}
' + f"
" + ) + + def _build_html(resume: dict) -> str: """職務経歴書データからHTML文字列を組み立てる""" parts: list[str] = [] @@ -171,7 +189,8 @@ def _build_html(resume: dict) -> str: ) capital_raw = _a(exp, "capital") emp_raw = _a(exp, "employee_count") - capital = f"{_esc(capital_raw)}千万円" if capital_raw else "" + capital_unit = _a(exp, "capital_unit", "千万円") + capital = f"{_esc(capital_raw)}{_esc(capital_unit)}" if capital_raw else "" emp = f"{_esc(emp_raw)}名" if emp_raw else "" info_parts = [f"事業内容:{biz}"] if capital: @@ -188,17 +207,27 @@ def _build_html(resume: dict) -> str: ) parts.append('
') - # 取引先 → プロジェクト(後方互換含む正規化は shared に集約) - clients = normalize_clients(exp) - for client in clients: - client_name = _a(client, "name") - if client_name: - parts.append( - f'
' f"取引先名:{_esc(client_name)}
", - ) - projects = _a(client, "projects", []) - for proj in projects: - parts.append(_build_project_html(proj)) + if not _a(exp, "is_it_company", True): + # 非IT企業: 取引先/プロジェクトの代わりに詳細を表示 + detail = _a(exp, "description") + parts.append( + f'
{_md(detail)}
' if detail else "

-

", + ) + else: + # 取引先 → プロジェクト(後方互換含む正規化は shared に集約) + clients = normalize_clients(exp) + for client in clients: + if _a(client, "is_vacation", False): + parts.append(_build_vacation_html(client)) + continue + client_name = _a(client, "name") + if client_name: + parts.append( + f'
' f"取引先名:{_esc(client_name)}
", + ) + projects = _a(client, "projects", []) + for proj in projects: + parts.append(_build_project_html(proj)) parts.append("
") diff --git a/backend/app/services/pdf/templates/resume.css b/backend/app/services/pdf/templates/resume.css index ce229f60..819c7455 100644 --- a/backend/app/services/pdf/templates/resume.css +++ b/backend/app/services/pdf/templates/resume.css @@ -67,6 +67,36 @@ h2 { padding: 4px; } +/* 非IT企業の詳細(取引先/プロジェクトの代わり) */ +.company-detail { + font-size: 8pt; + line-height: 1.6; +} + +.company-detail p { + margin: 0 0 0.5em 0; +} + +/* 休暇エントリ */ +.vacation { + margin-bottom: 2mm; +} + +.vacation-header { + font-size: 9pt; + font-weight: bold; + margin: 2mm 0 1mm 0; +} + +.vacation-body { + font-size: 8pt; + line-height: 1.6; +} + +.vacation-body p { + margin: 0 0 0.5em 0; +} + /* 取引先 */ .client-name { font-size: 10pt; diff --git a/backend/app/services/tasks/worker.py b/backend/app/services/tasks/worker.py index d9d24218..3b0bc97a 100644 --- a/backend/app/services/tasks/worker.py +++ b/backend/app/services/tasks/worker.py @@ -17,6 +17,7 @@ """ import time +from collections.abc import Callable from datetime import datetime, timezone from sqlalchemy.orm import Session @@ -104,12 +105,7 @@ async def execute_task( duration_ms, extra={"task_id": task_type.value, "user_id": user_id, "duration_ms": duration_ms}, ) - if isinstance(user_id, str) and user_id != "unknown": - db = SessionLocal() - try: - _create_notification(db, task_type, user_id, "completed") - finally: - _safe_close(db) + _finalize_completed(task_type, user_id) except NonRetryableError as exc: duration_ms = _monotonic_ms_since(start) logger.warning( @@ -125,13 +121,7 @@ async def execute_task( }, exc_info=True, ) - db = SessionLocal() - try: - _mark_dead_letter(db, task_type, payload, error=exc) - if isinstance(user_id, str) and user_id != "unknown": - _create_notification(db, task_type, user_id, "failed") - finally: - _safe_close(db) + _finalize_dead_letter(task_type, payload, user_id, error=exc) raise except Exception as exc: duration_ms = _monotonic_ms_since(start) @@ -151,13 +141,7 @@ async def execute_task( }, exc_info=True, ) - db = SessionLocal() - try: - _mark_dead_letter(db, task_type, payload, error=exc) - if isinstance(user_id, str) and user_id != "unknown": - _create_notification(db, task_type, user_id, "failed") - finally: - _safe_close(db) + _finalize_dead_letter(task_type, payload, user_id, error=exc) else: logger.warning( "タスク失敗(リトライ予定)", @@ -173,11 +157,7 @@ async def execute_task( }, exc_info=True, ) - db = SessionLocal() - try: - _mark_retrying(db, task_type, payload, retry_count, max_attempts, error=exc) - finally: - _safe_close(db) + _finalize_retrying(task_type, payload, retry_count, max_attempts, error=exc) raise @@ -193,6 +173,64 @@ def _safe_close(db: Session) -> None: logger.warning("セッションのクローズに失敗しました", exc_info=True) +# ---------- 終端処理(独立した新規セッションで実行)---------- +# 状態遷移更新・通知は、ハンドラ側セッションが失効していても終端ステータスを確実に +# 永続化するため、常に新規セッションで実行する(libSQL Hrana 失効対策)。 + + +def _run_in_new_session(work: Callable[[Session], None]) -> None: + """新規セッションを開いて ``work`` を実行し、必ず close する。 + + 成功通知・dead_letter・retrying の各終端処理で共通する + 「新規セッション開閉」のみを担う。挙動は分岐ごとに ``work`` で渡す。 + """ + db = SessionLocal() + try: + work(db) + finally: + _safe_close(db) + + +def _notify_if_real_user(db: Session, task_type: TaskType, user_id, status: str) -> None: + """実ユーザー(``user_id`` が ``"unknown"`` でない文字列)のときだけ通知を作成する。""" + if isinstance(user_id, str) and user_id != "unknown": + _create_notification(db, task_type, user_id, status) + + +def _finalize_completed(task_type: TaskType, user_id) -> None: + """タスク成功時の通知を作成する。実ユーザーでなければ何もしない。""" + if not (isinstance(user_id, str) and user_id != "unknown"): + return + _run_in_new_session(lambda db: _create_notification(db, task_type, user_id, "completed")) + + +def _finalize_dead_letter( + task_type: TaskType, payload: dict, user_id, *, error: Exception +) -> None: + """終端ステータス(dead_letter)への更新と失敗通知を行う。 + + ``_mark_dead_letter`` は DB エラーを握りつぶす(rollback しない)ため、commit 失敗時に + セッションが失効状態のまま残りうる。通知作成が汚染セッションを再利用しないよう、 + 状態更新と通知はそれぞれ独立した新規セッションで実行する。 + """ + _run_in_new_session(lambda db: _mark_dead_letter(db, task_type, payload, error=error)) + _run_in_new_session(lambda db: _notify_if_real_user(db, task_type, user_id, "failed")) + + +def _finalize_retrying( + task_type: TaskType, + payload: dict, + retry_count: int, + max_attempts: int, + *, + error: Exception, +) -> None: + """リトライ待ち状態(retrying)への更新を行う(通知は出さない)。""" + _run_in_new_session( + lambda db: _mark_retrying(db, task_type, payload, retry_count, max_attempts, error=error) + ) + + # ---------- ハンドラ薄ラッパー(後方互換)---------- # テストや既存呼び出しから ``_run_github_link`` を直接呼べるよう、 # ハンドラ実装への薄いシムを残す。引数は ``session_factory``。 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 512f92fb..35284d1f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -164,6 +164,53 @@ def _override_get_db(): module.execute_task = original +def make_resume_payload(**overrides) -> dict: + """有効な職務経歴書 payload を生成する(トップレベルキーを overrides で上書き可)。 + + 取引先・プロジェクト・体制まで含む完全な構造を返す。呼ぶたびに新しい dict を返すので + テスト間で共有しても副作用が無い。テスト固有の最小 payload や検証用の変種は + 各テスト側でそのまま定義してよい(intent を読みやすく残すため)。 + """ + payload: dict = { + "full_name": "山田 太郎", + "career_summary": "キャリアサマリー", + "self_pr": "自己PR", + "experiences": [ + { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2021-04", + "end_date": "2024-03", + "is_current": False, + "employee_count": "300", + "capital": "10", + "clients": [ + { + "name": "顧客A", + "has_client": True, + "projects": [ + { + "name": "プロジェクトA", + "start_date": "2021-04", + "end_date": "2022-03", + "is_current": False, + "role": "SE", + "description": "", + "team": {"total": "5", "members": []}, + "technology_stacks": [], + "phases": [], + } + ], + } + ], + } + ], + "qualifications": [{"acquired_date": "2020-04", "name": "応用情報技術者"}], + } + payload.update(overrides) + return payload + + def auth_header(client, username: str = "testuser") -> dict: """テスト用の認証 Cookie をセットするヘルパー。CSRF トークンをヘッダーに含む dict を返す。 diff --git a/backend/tests/test_contributions.py b/backend/tests/test_contributions.py new file mode 100644 index 00000000..b07c4d9e --- /dev/null +++ b/backend/tests/test_contributions.py @@ -0,0 +1,158 @@ +""" +GitHub コントリビューションカレンダー取得(GraphQL)のテスト。 + +対象モジュール: app.services.intelligence.github.contributions +テスト方針: + - httpx.AsyncClient をモック化し、実 GitHub API は叩かない + - 補助処理として失敗時に None を返す(例外を送出しない)ことを検証 + - contributionLevel → 0–4 の正規化を検証 +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +from app.services.intelligence.github.contributions import ( + _parse_calendar, + fetch_contribution_calendar, +) + + +def _run(coro): + """async 関数を同期的に実行するヘルパー。""" + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def _make_mock_client(*, status_code=200, payload=None, post_side_effect=None): + """POST レスポンスを返すモック AsyncClient を生成する。""" + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + if post_side_effect is not None: + mock_client.post = AsyncMock(side_effect=post_side_effect) + else: + mock_resp = MagicMock() + mock_resp.status_code = status_code + mock_resp.json = MagicMock(return_value=payload or {}) + mock_client.post = AsyncMock(return_value=mock_resp) + return mock_client + + +_CALENDAR_PAYLOAD = { + "data": { + "user": { + "contributionsCollection": { + "contributionCalendar": { + "totalContributions": 42, + "weeks": [ + { + "contributionDays": [ + { + "date": "2024-01-01", + "contributionCount": 0, + "contributionLevel": "NONE", + }, + { + "date": "2024-01-02", + "contributionCount": 5, + "contributionLevel": "SECOND_QUARTILE", + }, + ] + }, + { + "contributionDays": [ + { + "date": "2024-01-08", + "contributionCount": 12, + "contributionLevel": "FOURTH_QUARTILE", + }, + ] + }, + ], + } + } + } + } +} + + +def _patch_client(mock_client): + return patch( + "app.services.intelligence.github.contributions.httpx.AsyncClient", + return_value=mock_client, + ) + + +class TestFetchContributionCalendar: + def test_success_parses_calendar(self): + """正常系: totalContributions と週ごとの level が正しく取得されること。""" + with _patch_client(_make_mock_client(payload=_CALENDAR_PAYLOAD)): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + + assert calendar is not None + assert calendar.total_contributions == 42 + assert len(calendar.weeks) == 2 + # NONE → 0, SECOND_QUARTILE → 2, FOURTH_QUARTILE → 4 + assert [d.level for d in calendar.weeks[0]] == [0, 2] + assert calendar.weeks[0][1].count == 5 + assert calendar.weeks[1][0].level == 4 + assert calendar.weeks[1][0].date == "2024-01-08" + + def test_graphql_errors_returns_none(self): + """GraphQL errors を含む応答では None を返すこと。""" + payload = {"data": {"user": None}, "errors": [{"message": "Bad creds"}]} + with _patch_client(_make_mock_client(payload=payload)): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + assert calendar is None + + def test_user_none_returns_none(self): + """user が null の応答では None を返すこと。""" + payload = {"data": {"user": None}} + with _patch_client(_make_mock_client(payload=payload)): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + assert calendar is None + + def test_non_200_returns_none(self): + """HTTP 401 等では None を返すこと(例外を投げない)。""" + with _patch_client(_make_mock_client(status_code=401, payload={})): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + assert calendar is None + + def test_network_error_returns_none(self): + """ネットワーク障害でも例外を投げず None を返すこと(補助処理)。""" + with _patch_client( + _make_mock_client(post_side_effect=httpx.ConnectError("boom")) + ): + calendar = _run(fetch_contribution_calendar("gh-user", "token123")) + assert calendar is None + + +class TestParseCalendar: + def test_level_mapping_and_defaults(self): + """contributionLevel が 0–4 に正規化され、未知値は 0 になること。""" + raw = { + "totalContributions": 3, + "weeks": [ + { + "contributionDays": [ + {"date": "2024-02-01", "contributionCount": 1, "contributionLevel": "FIRST_QUARTILE"}, + {"date": "2024-02-02", "contributionCount": 2, "contributionLevel": "THIRD_QUARTILE"}, + {"date": "2024-02-03", "contributionCount": 0, "contributionLevel": "UNKNOWN"}, + ] + } + ], + } + calendar = _parse_calendar(raw) + assert calendar.total_contributions == 3 + assert [d.level for d in calendar.weeks[0]] == [1, 3, 0] + + def test_empty_weeks(self): + """weeks が空でも壊れないこと。""" + calendar = _parse_calendar({"totalContributions": 0, "weeks": []}) + assert calendar.total_contributions == 0 + assert calendar.weeks == [] diff --git a/backend/tests/test_delete_documents.py b/backend/tests/test_delete_documents.py index 0f0d1c56..bcb25b13 100644 --- a/backend/tests/test_delete_documents.py +++ b/backend/tests/test_delete_documents.py @@ -1,46 +1,11 @@ from fastapi.testclient import TestClient -from conftest import auth_header +from conftest import auth_header, make_resume_payload # ── 職務経歴書の削除 ────────────────────────────────────────── -_RESUME_PAYLOAD = { - "full_name": "山田 太郎", - "career_summary": "キャリアサマリー", - "self_pr": "自己PR", - "experiences": [ - { - "company": "Example株式会社", - "business_description": "SES事業", - "start_date": "2021-04", - "end_date": "2024-03", - "is_current": False, - "employee_count": "300", - "capital": "10", - "clients": [ - { - "name": "顧客A", - "has_client": True, - "projects": [ - { - "name": "プロジェクトA", - "start_date": "2021-04", - "end_date": "2022-03", - "is_current": False, - "role": "SE", - "description": "", - "team": {"total": "5", "members": []}, - "technology_stacks": [], - "phases": [], - } - ], - } - ], - } - ], - "qualifications": [{"acquired_date": "2020-04", "name": "応用情報技術者"}], -} +_RESUME_PAYLOAD = make_resume_payload() def test_delete_resume_success(client: TestClient) -> None: diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 96f29848..757a1dcb 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -173,6 +173,99 @@ def test_resume_round_trips_nested_structure(client: TestClient) -> None: assert project["phases"] == ["基本設計", "開発"] +def test_resume_round_trips_non_it_and_vacation(client: TestClient) -> None: + headers = auth_header(client, "resume-nonit-vacation-user") + + payload = { + "full_name": "佐藤 花子", + "career_summary": "キャリアサマリー", + "self_pr": "自己PR", + "experiences": [ + { + "company": "〇〇商事", + "business_description": "小売業", + "start_date": "2016-04", + "end_date": "2019-03", + "is_current": False, + "is_it_company": False, + "description": "店舗運営・在庫管理・スタッフ教育を担当", + "clients": [], + }, + { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2019-04", + "end_date": "2024-03", + "is_current": False, + "is_it_company": True, + "clients": [ + { + "is_vacation": True, + "vacation_start_date": "2020-04", + "vacation_end_date": "2021-03", + "vacation_is_current": False, + "vacation_description": "育児休暇を取得。期間中にProgateで学習", + }, + { + "name": "顧客A", + "has_client": True, + "projects": [ + { + "name": "API開発", + "periods": [ + { + "start_date": "2021-04", + "end_date": "2024-03", + "is_current": False, + } + ], + "role": "SE", + "description": "性能改善", + "technology_stacks": [{"category": "language", "name": "Python"}], + } + ], + }, + ], + }, + ], + "qualifications": [], + } + + resp = client.post("/api/resumes", json=payload, headers=headers) + assert resp.status_code == 201 + resume_id = resp.json()["id"] + + resp = client.get(f"/api/resumes/{resume_id}", headers=headers) + assert resp.status_code == 200 + data = resp.json() + + # 経歴は在籍期間の降順(新しい在籍が先)でソートされる。 + # dict 化すると順序が失われるため、変換前にリストの並びを明示検証する。 + ordered = data["experiences"] + assert [exp["company"] for exp in ordered] == ["Example株式会社", "〇〇商事"] + end_dates = [exp["end_date"] for exp in ordered] + assert all( + prev >= curr for prev, curr in zip(end_dates, end_dates[1:], strict=False) + ), f"在籍期間の降順ソートが崩れている: {end_dates}" + + experiences = {exp["company"]: exp for exp in ordered} + + non_it = experiences["〇〇商事"] + assert non_it["is_it_company"] is False + assert non_it["description"] == "店舗運営・在庫管理・スタッフ教育を担当" + assert non_it["clients"] == [] + + it_exp = experiences["Example株式会社"] + assert it_exp["is_it_company"] is True + vacation = next(c for c in it_exp["clients"] if c["is_vacation"]) + assert vacation["vacation_start_date"] == "2020-04" + assert vacation["vacation_end_date"] == "2021-03" + assert vacation["vacation_description"] == "育児休暇を取得。期間中にProgateで学習" + normal = next(c for c in it_exp["clients"] if not c["is_vacation"]) + assert normal["name"] == "顧客A" + assert normal["projects"][0]["name"] == "API開発" + + # ── Health Check ─────────────────────────────────────────────── diff --git a/backend/tests/test_markdown_generator.py b/backend/tests/test_markdown_generator.py new file mode 100644 index 00000000..14d375a4 --- /dev/null +++ b/backend/tests/test_markdown_generator.py @@ -0,0 +1,135 @@ +from app.services.markdown.generators.resume_generator import build_resume_markdown + + +def _payload(capital: str, capital_unit: str | None) -> dict: + exp: dict = { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2021-04", + "end_date": "2024-03", + "is_current": False, + "capital": capital, + "clients": [], + } + if capital_unit is not None: + exp["capital_unit"] = capital_unit + return { + "full_name": "山田 太郎", + "career_summary": "職務要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [exp], + } + + +def test_capital_unit_reflected_in_markdown() -> None: + md = build_resume_markdown(_payload("5", "百万円")) + assert "5百万円" in md + assert "千万円" not in md + + +def test_capital_unit_defaults_to_sen_man_when_missing() -> None: + # capital_unit を持たない旧データは後方互換で「千万円」表示になる。 + md = build_resume_markdown(_payload("5", None)) + assert "5千万円" in md + + +def _it_experience() -> dict: + return { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2019-04", + "end_date": "2024-03", + "is_current": False, + "is_it_company": True, + "clients": [ + { + "name": "顧客A", + "has_client": True, + "is_vacation": False, + "projects": [ + { + "name": "API開発", + "periods": [ + {"start_date": "2021-04", "end_date": "2024-03", "is_current": False} + ], + "role": "SE", + "description": "性能改善を担当", + "technology_stacks": [{"category": "language", "name": "Python"}], + } + ], + } + ], + } + + +def test_non_it_experience_renders_description_without_project_table() -> None: + """非IT経歴は詳細を出力し、取引先/プロジェクト見出しを出さない。""" + payload = { + "full_name": "佐藤 花子", + "career_summary": "要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [ + { + "company": "〇〇商事", + "business_description": "小売業", + "start_date": "2016-04", + "end_date": "2019-03", + "is_current": False, + "is_it_company": False, + "description": "店舗運営・在庫管理を担当", + "clients": [], + } + ], + } + + md = build_resume_markdown(payload) + + assert "店舗運営・在庫管理を担当" in md + # 非IT経歴では取引先見出し(####)やプロジェクト見出し(#####)を出さない。 + assert "####" not in md + + +def test_vacation_client_renders_period_and_detail() -> None: + """休暇エントリは期間と詳細を出力する。""" + exp = _it_experience() + exp["clients"] = [ + { + "is_vacation": True, + "vacation_start_date": "2020-04", + "vacation_end_date": "2021-03", + "vacation_is_current": False, + "vacation_description": "育児休暇を取得", + } + ] + payload = { + "full_name": "佐藤 花子", + "career_summary": "要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [exp], + } + + md = build_resume_markdown(payload) + + assert "#### 休暇" in md + assert "2020-04 - 2021-03" in md + assert "育児休暇を取得" in md + + +def test_it_experience_renders_projects_unchanged() -> None: + """既存のIT経路は取引先・プロジェクトを従来どおり出力する。""" + payload = { + "full_name": "山田 太郎", + "career_summary": "要約", + "self_pr": "自己PR", + "qualifications": [], + "experiences": [_it_experience()], + } + + md = build_resume_markdown(payload) + + assert "#### 顧客A" in md + assert "##### API開発" in md + assert "性能改善を担当" in md diff --git a/backend/tests/test_pdf_generator.py b/backend/tests/test_pdf_generator.py index 4e891d31..7f7437a1 100644 --- a/backend/tests/test_pdf_generator.py +++ b/backend/tests/test_pdf_generator.py @@ -32,6 +32,50 @@ def test_build_resume_pdf_returns_pdf_bytes() -> None: assert len(pdf_bytes) > 100 +def test_build_resume_pdf_with_non_it_and_vacation() -> None: + """非IT経歴と休暇エントリを含む payload でも PDF を生成できる。""" + payload = { + "full_name": "佐藤 花子", + "qualifications": [], + "career_summary": "職務要約", + "self_pr": "自己PR", + "experiences": [ + { + "company": "〇〇商事", + "business_description": "小売業", + "start_date": "2016-04", + "end_date": "2019-03", + "is_current": False, + "is_it_company": False, + "description": "店舗運営・在庫管理を担当", + "clients": [], + }, + { + "company": "Example株式会社", + "business_description": "SES事業", + "start_date": "2019-04", + "end_date": "2024-03", + "is_current": False, + "is_it_company": True, + "clients": [ + { + "is_vacation": True, + "vacation_start_date": "2020-04", + "vacation_end_date": "2021-03", + "vacation_is_current": False, + "vacation_description": "育児休暇を取得", + } + ], + }, + ], + } + + pdf_bytes = build_resume_pdf(payload) + + assert pdf_bytes.startswith(b"%PDF") + assert len(pdf_bytes) > 100 + + def test_parse_date_ym() -> None: assert _parse_date_ym("2020-04") == ("2020", "4") assert _parse_date_ym("2020-12-01") == ("2020", "12") diff --git a/backend/tests/test_schemas.py b/backend/tests/test_schemas.py index 6c228d82..9f48c52b 100644 --- a/backend/tests/test_schemas.py +++ b/backend/tests/test_schemas.py @@ -1,5 +1,6 @@ import pytest from app.schemas import ( + Client, Experience, Project, ResumeCreate, @@ -299,3 +300,73 @@ def test_project_end_date_none_is_rejected() -> None: is_current=True, technology_stacks=[], ) + + +def test_non_it_experience_with_description_and_no_clients() -> None: + """非IT経歴: is_it_company=False で取引先なし・詳細のみでも保存できる。""" + payload = experience_payload() + payload["is_it_company"] = False + payload["description"] = "店舗運営・在庫管理を担当" + payload["clients"] = [] + + exp = Experience(**payload) + + assert exp.is_it_company is False + assert exp.description == "店舗運営・在庫管理を担当" + assert exp.clients == [] + + +def test_experience_defaults_to_it_company() -> None: + """経歴: is_it_company は既定 True(後方互換)。""" + exp = Experience(**experience_payload()) + assert exp.is_it_company is True + assert exp.description == "" + + +def test_vacation_client_accepts_valid_period() -> None: + """休暇: is_vacation=True で期間・詳細を保持できる。""" + client = Client( + is_vacation=True, + vacation_start_date="2020-04", + vacation_end_date="2021-03", + vacation_is_current=False, + vacation_description="育児休暇", + ) + assert client.is_vacation is True + assert client.vacation_start_date == "2020-04" + assert client.vacation_end_date == "2021-03" + + +def test_vacation_client_current_normalizes_end_to_empty() -> None: + """休暇: 継続中(vacation_is_current=True)なら終了年月は "" に正規化される。""" + client = Client( + is_vacation=True, + vacation_start_date="2020-04", + vacation_end_date="2021-03", + vacation_is_current=True, + ) + assert client.vacation_end_date == "" + + +def test_vacation_client_requires_start_date() -> None: + """休暇: 開始年月が空なら 422(日本語メッセージ)。""" + with pytest.raises(ValidationError, match="開始年月を入力してください"): + Client(is_vacation=True, vacation_start_date="", vacation_description="育児休暇") + + +def test_vacation_client_end_before_start_is_rejected() -> None: + """休暇: 終了年月が開始年月より前ならエラー。""" + with pytest.raises(ValidationError, match="開始日は終了日より前"): + Client( + is_vacation=True, + vacation_start_date="2021-04", + vacation_end_date="2020-03", + vacation_is_current=False, + ) + + +def test_non_vacation_client_skips_vacation_validation() -> None: + """休暇でない取引先は vacation 期間が空でも検証対象外。""" + client = Client(name="クライアントA", has_client=True) + assert client.is_vacation is False + assert client.vacation_start_date == "" diff --git a/backend/tests/test_worker/test_github_link.py b/backend/tests/test_worker/test_github_link.py index 66f68558..0e4a0d0b 100644 --- a/backend/tests/test_worker/test_github_link.py +++ b/backend/tests/test_worker/test_github_link.py @@ -57,6 +57,11 @@ def test_status_transitions_to_completed(self, db_session: Session, session_fact new_callable=AsyncMock, return_value=repos, ), + patch( + "app.services.intelligence.github_link_service.fetch_contribution_calendar", + new_callable=AsyncMock, + return_value=None, + ), patch( "app.services.progress_service.set_progress", new_callable=AsyncMock, @@ -88,6 +93,8 @@ def test_completed_persists_mapped_result_content( ): """フェーズC: 成功時に map_pipeline_result の出力が cache.result へ そのまま永続化され、error/warning がクリアされること(内容まで検証)。""" + from app.schemas.github_link import ContributionCalendar, ContributionDay + user, cache = self._make_user_and_cache(db_session, "github:content-user") # 前回失敗の痕跡が成功時にクリアされることも併せて確認する cache.error_message = "前回の失敗" @@ -95,6 +102,12 @@ def test_completed_persists_mapped_result_content( db_session.commit() repos = self._sample_repos() + # コントリビューション取得が成功するケース。これにより新たな警告は出ず、 + # 前回の warning_message がクリアされることを純粋に検証できる。 + calendar = ContributionCalendar( + total_contributions=7, + weeks=[[ContributionDay(date="2024-03-01", count=3, level=2)]], + ) sentinel_result = { "skills": [{"name": "Python", "score": 80}], "summary": "集計結果", @@ -108,6 +121,11 @@ def test_completed_persists_mapped_result_content( new_callable=AsyncMock, return_value=repos, ), + patch( + "app.services.intelligence.github_link_service.fetch_contribution_calendar", + new_callable=AsyncMock, + return_value=calendar, + ), patch("app.services.progress_service.set_progress", new_callable=AsyncMock), patch( "app.services.intelligence.github_link_service.decrypt_field", @@ -144,6 +162,99 @@ def test_completed_persists_mapped_result_content( assert cache.warning_message is None assert cache.completed_at is not None + def test_contribution_calendar_persisted_in_result( + self, db_session: Session, session_factory + ): + """コントリビューションカレンダーが取得できた場合、result に格納され + warning_message が立たないこと。""" + from app.schemas.github_link import ContributionCalendar, ContributionDay + + user, cache = self._make_user_and_cache(db_session, "github:calendar-user") + repos = self._sample_repos() + calendar = ContributionCalendar( + total_contributions=7, + weeks=[[ContributionDay(date="2024-03-01", count=3, level=2)]], + ) + + with ( + patch( + "app.services.intelligence.github_link_service.collect_repos", + new_callable=AsyncMock, + return_value=repos, + ), + patch( + "app.services.intelligence.github_link_service.fetch_contribution_calendar", + new_callable=AsyncMock, + return_value=calendar, + ), + patch("app.services.progress_service.set_progress", new_callable=AsyncMock), + patch( + "app.services.intelligence.github_link_service.decrypt_field", + return_value="token123", + ), + ): + _run( + _run_github_link( + session_factory, + { + "user_id": user.id, + "github_username": "gh-user", + "github_token": "encrypted_token", + "include_forks": False, + }, + ) + ) + + db_session.refresh(cache) + assert cache.status == "completed" + assert cache.result is not None + assert cache.result["contribution_calendar"]["total_contributions"] == 7 + assert cache.result["contribution_calendar"]["weeks"][0][0]["level"] == 2 + assert cache.warning_message is None + + def test_contribution_fetch_failure_sets_warning( + self, db_session: Session, session_factory + ): + """コントリビューション取得失敗(None)でも連携は completed のままで、 + warning_message が立つこと。""" + user, cache = self._make_user_and_cache(db_session, "github:warn-user") + repos = self._sample_repos() + + with ( + patch( + "app.services.intelligence.github_link_service.collect_repos", + new_callable=AsyncMock, + return_value=repos, + ), + patch( + "app.services.intelligence.github_link_service.fetch_contribution_calendar", + new_callable=AsyncMock, + return_value=None, + ), + patch("app.services.progress_service.set_progress", new_callable=AsyncMock), + patch( + "app.services.intelligence.github_link_service.decrypt_field", + return_value="token123", + ), + ): + _run( + _run_github_link( + session_factory, + { + "user_id": user.id, + "github_username": "gh-user", + "github_token": "encrypted_token", + "include_forks": False, + }, + ) + ) + + db_session.refresh(cache) + assert cache.status == "completed" + assert cache.result is not None + assert cache.result["contribution_calendar"] is None + assert cache.warning_message is not None + def test_status_transitions_to_processing_at_start( self, db_session: Session, session_factory ): diff --git a/docker-compose.yml b/docker-compose.yml index 7e0e32c5..8a456639 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,8 @@ services: GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} LLM_PROVIDER: ${LLM_PROVIDER} + # OLLAMA_* の名前の正本は backend/app/core/env_keys.py(OLLAMA_BASE_URL / OLLAMA_MODEL / OLLAMA_TIMEOUT)。 + # 下記 ollama サービスの OLLAMA_KEEP_ALIVE は Ollama サーバ自身の設定で backend は参照しない。 OLLAMA_BASE_URL: ${OLLAMA_BASE_URL} OLLAMA_MODEL: ${OLLAMA_MODEL} OLLAMA_TIMEOUT: ${OLLAMA_TIMEOUT} diff --git a/docs/adr/0007-openapi-typescript-codegen.md b/docs/adr/0007-openapi-typescript-codegen.md new file mode 100644 index 00000000..d630f601 --- /dev/null +++ b/docs/adr/0007-openapi-typescript-codegen.md @@ -0,0 +1,102 @@ +# ADR-0007: OpenAPI → TypeScript 型コード生成の導入検討 + +## ステータス + +Proposed + +> 本 ADR は「BE の Pydantic schema と FE の TypeScript 型の二重定義に対し、OpenAPI から TS 型を自動生成する仕組みを導入するか」の判断材料であり、採用確定ではない。 +> 後述の Phase 1 パイロット(生成パイプライン基盤の構築 + 1 ドメインの試験移行)の結果を見て `Accepted` への昇格、または `Deprecated`(現状の手動同期を継続)を判断する。 +> 本 ADR の起票自体は領域横断リファクタ(`XR_apply`)のスコープ内だが、**パイプライン実装と型移行は本 ADR が定義する後続 PR で行う**(codegen は重い投資であり、env/docs 修正と束ねるとレビュー不能になるため)。 + +## コンテキスト + +DevForge は backend(FastAPI + Pydantic)が REST API の DTO を `backend/app/schemas/**` で定義し、frontend(React + TypeScript)が同じ構造を `frontend/src/types.ts` / `frontend/src/api/*.ts` に**手書きで再定義**している。両者は言語境界で分断されており、フィールド構造(snake_case まで)が一致するよう人手で同期している。 + +現状の構成と痛み: + +- **手動同期の規律は高い**: 一部はクロス参照コメントまで備える。例として `frontend/src/api/shared.ts` は冒頭に「backend の `app/schemas/shared.py` に対応する」と明記し、`frontend/src/api/githubLink.ts` は backend `schemas/github_link.py` と同名・同構造の interface を持つ。 +- **ドリフト検知機構が無い**: backend でフィールド追加・rename・型変更を行っても、frontend 側は**型エラーにならず**、ランタイムで `undefined` を踏むまで気づけない。現状は目視同期で破綻していないが、規律に依存しており機械的な保証が無い。 +- **対照的にエラーコードは型縛りで守られている**: `backend/app/core/errors.py:ErrorCode`(列挙)↔ `frontend/src/constants/errorCodes.ts:ERROR_CODES` は、`errorMessages.ts` が `Record` でキー網羅漏れを **TypeScript の型エラーとして検知**する。DTO にも同等の「漏れたらビルドで落ちる」仕組みが望ましい。 +- **API パスは既に FE 側 SSoT が成立**: `frontend/src/api/paths.ts` に `PATHS` として完全集約済み。DTO だけが機械検知の穴として残っている。 + +機械検出(jscpd)は `.py` と `.ts` を別トークナイザで扱うため cross-realm clone を検出できず(実測 0 件)、DTO の二重定義は**目視でしか捕捉できない最大の盲点**である(領域横断レビュー `report/XR_report_20260529_1006.md` の Medium 指摘)。 + +該当する DTO ペア(フィールド構造がほぼ完全一致): + +| backend schema | frontend 型 | +|---|---| +| `schemas/github_link.py`(`ContributionDay` / `ContributionCalendar` / `GitHubLinkResponse` / `CachedGitHubLinkResponse`) | `api/githubLink.ts` | +| `schemas/resume.py`(`Experience` / `ResumeBase` / `ResumeResponse`) | `types.ts`(`CareerExperience` / `CareerResumePayload` / `CareerResumeResponse`) | +| `schemas/shared.py`(`TaskStatusResponse` / `SubProgress` / `ProgressResponse`) | `api/shared.ts` | +| `schemas/master_data.py`(`MasterItem` / `TechStackMasterItem`) | `types.ts` | +| `schemas/blog.py`(`BlogAccountResponse` / `BlogArticleResponse`) | `types.ts`(`BlogAccount` / `BlogArticle`) | +| `schemas/auth.py`(`TokenResponse`) | `api/auth.ts`(`AuthResponse`) | + +## 決定内容 + +FastAPI が出力する OpenAPI スキーマから **`openapi-typescript`** で TypeScript 型を生成するパイプラインを導入する案を提示する。導入する場合の方針は以下のとおり。 + +### 基本方針 + +- **backend(Pydantic schema)を DTO の Single Source of Truth とする**。frontend の手書き型は生成物に置き換えていく。 +- 生成先は `frontend/src/api/generated.ts`(コミット対象・**手編集禁止**をファイル冒頭コメントで明示)。 +- **`request()`(`api/client.ts`)の 401 リフレッシュ・CSRF・Cookie 認証ロジックは一切変更しない**。生成された型を `request()` の型引数として渡すだけにする。 +- **API パスの SSoT である `api/paths.ts` は維持**する。codegen は型のみを対象とし、パス定数は置き換えない。 + +### パイプライン構成 + +1. **OpenAPI エクスポート**: backend の FastAPI app から `app.openapi()` を JSON にダンプする backend スクリプト(`backend/scripts/export_openapi.py` 想定)を追加。出力は `backend/openapi.json`(または一時ファイル)。 +2. **型生成**: `openapi-typescript` を frontend の devDependency に追加し、`backend/openapi.json` → `frontend/src/api/generated.ts` を生成。 +3. **make ターゲット**: 既存の Nix devshell ラップ規約(`.claude/CLAUDE.md`)に従い、`make codegen-types`(= openapi エクスポート + 型生成)を追加。`Makefile` は `nix develop --command bash -c "..."` でラップする。 +4. **CI ドリフト検知**: CI で `make codegen-types` を実行し、`git diff --exit-code frontend/src/api/generated.ts` が非ゼロなら fail させる。これにより「backend を変えたのに型を再生成していない」状態をビルドで落とす(エラーコードの型縛りと同じ思想)。 + +### 段階移行プラン + +採用する場合は以下の順で進める。Phase 1 をパイロットとし、効果を見てから先へ進む。 + +| Phase | 対象 | 内容 | リスク | +|---|---|---|---| +| 0 | 基盤 | `export_openapi.py` / `openapi-typescript` 依存追加 / `make codegen-types` / `generated.ts` 初回生成 / CI ドリフト検知 | 中 | +| 1 | 読み取り(パイロット) | `api/shared.ts`(`TaskStatusResponse` 系)を `generated.ts` の型へ置換。手書き interface を `type X = components["schemas"]["..."]` の再エクスポートに変更 | 低〜中 | +| 2 | 主要レスポンス | `api/githubLink.ts` / `schemas/github_link.py` 系、`api/auth.ts` の `AuthResponse` を移行 | 中 | +| 3 | フォーム入出力含む | `types.ts` の `CareerResume*` / `BlogAccount` / `MasterItem` 系を移行。`payloadBuilders.ts` / `formMappers.ts` への影響を確認 | 中〜高 | + +### 移行しないもの(重要) + +- **`api/client.ts` の 401/CSRF ロジック**: 変更しない。 +- **`api/paths.ts` の `PATHS`**: API パスの SSoT として維持(codegen 対象外)。 +- **`frontend/src/constants/errorCodes.ts` / `errorMessages.ts`**: エラーコードは既に型縛りで守られており、OpenAPI codegen とは別系統。本 ADR の対象外。 +- **`formTypes.ts` のクライアント専用フォーム状態**: サーバ DTO ではないため対象外。 + +## 代替案 + +| 選択肢 | 評価 | +|---|---| +| 現状維持(手動同期 + クロス参照コメント) | 既存資産はそのまま活きるが、ドリフトをビルドで検知できず規律依存のまま。フィールド rename 事故のリスクが残り続ける | +| `openapi-typescript`(本案) | 型のみ生成・ランタイム依存ゼロ・`request()` と非干渉。生成物が大きくなりがちだが影響は型レイヤに閉じる | +| `orval` / `openapi-generator`(クライアント生成) | fetch クライアントごと生成するため、自前の 401/CSRF 付き `request()` と二重化する。既存資産を捨てることになり不適 | +| Pydantic → TS を独自スクリプトで変換 | OpenAPI を経由しないため FastAPI のレスポンスモデル(`response_model`)との整合が取れず、独自実装の保守コストが高い | + +## トレードオフ・既知のリスク + +1. **生成物の肥大**: `generated.ts` は全 schema を含むため大きくなる。型のみで本番バンドルには乗らない(`import type`)が、差分レビューのノイズにはなる。手編集禁止コメントで誤編集を防ぐ。 +2. **backend app の import コスト**: openapi エクスポートは FastAPI app を import する必要があり、WeasyPrint 等のネイティブ依存解決のため **Nix devshell 経由必須**(生シェル直叩き禁止。`.claude/CLAUDE.md` 準拠)。 +3. **CI 実行時間の増加**: codegen + `git diff` チェックのステップが増える。 +4. **命名差の吸収**: 現行 FE は backend と別名(`CareerResumeResponse` ↔ `ResumeResponse`)を使う箇所がある。移行時は再エクスポート(`export type CareerResumeResponse = components["schemas"]["ResumeResponse"]`)で名前を保ち、呼び出し側の破壊を避ける。 +5. **`response_model` 未設定エンドポイントの穴**: OpenAPI に型が出ない(`/auth/me` は `response_model=TokenResponse` を確認済みだが、未設定箇所があると生成されない)。移行前に backend 全 router の `response_model` 付与状況を棚卸しする必要がある。 +6. **E2E 影響**: github 連携・ブログ・通知の UI フローに関わる型を移行するため、Phase 2 以降は `npm run test:e2e` 必須。 + +## 将来の移行条件 + +- **Accepted への昇格条件**: Phase 0 + Phase 1 パイロットが `make ci` green を満たし、CI ドリフト検知が機能する(backend schema をわざと変えると CI が落ちる)ことを確認できること。 +- **Deprecated(現状維持)への判断**: 生成物の肥大・CI コスト・命名吸収の手間が、手動同期で足りている現状の規律に見合わないと判断した場合は、本 ADR を `Deprecated` にし現状の手動同期 + クロス参照コメントを継続する。 +- backend の `response_model` 付与が不完全で OpenAPI に型が出ない場合は、先に backend 側の `response_model` 整備を別タスクで完了させる。 + +## 関連リンク + +- 領域横断レビュー: `report/XR_report_20260529_1006.md`(Medium: DTO 二重定義のドリフト検知不在) +- [frontend/src/api/client.ts](../../frontend/src/api/client.ts) — fetch ラッパー(401/CSRF、変更対象外) +- [frontend/src/api/paths.ts](../../frontend/src/api/paths.ts) — API パス SSoT(codegen 対象外) +- [frontend/src/api/shared.ts](../../frontend/src/api/shared.ts) — Phase 1 パイロット対象 +- [backend/app/core/errors.py](../../backend/app/core/errors.py) — エラーコードの型縛り(DTO 検知機構の手本) +- openapi-typescript 公式ドキュメント: https://openapi-ts.dev/ diff --git a/docs/data-model.md b/docs/data-model.md index 70192bec..0b155550 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -42,6 +42,8 @@ make infra-validate nix develop --command bash -c "tofu -chdir=infra/environments/dev apply" ``` +> `tofu init` / `plan` / `apply` の一般的な実行手順と GCS backend の認証(`gcloud auth application-default login`)は [docs/deployment.md](./deployment.md)「OpenTofu」セクションを正本として参照。ここでは Turso DB 作成に固有の手順のみを記載する。 + `module.devforge_stack.module.turso.turso_database.this` が作成され、output `turso_database_url` に `libsql://devforge-dev-.turso.io` 形式の URL が記録されます。Cloud Run の env block には自動で同じ値が注入されます。 #### 4. auth token を発行して Secret Manager に投入 diff --git a/frontend/e2e/github-link.spec.ts b/frontend/e2e/github-link.spec.ts index 3f3532de..eeda1288 100644 --- a/frontend/e2e/github-link.spec.ts +++ b/frontend/e2e/github-link.spec.ts @@ -86,4 +86,135 @@ test.describe("GitHub 連携 - 検出フレームワーク表示", () => { page.getByRole("heading", { name: "Frameworks" }), ).not.toBeVisible(); }); + + test("コントリビューションカレンダーがあると Activity ヒートマップが表示される", async ({ + page, + }) => { + await page.route("**/api/github-link/cache", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "completed", + result: { + username: "e2e-test-user", + repos_analyzed: 2, + unique_skills: 3, + analyzed_at: "2026-04-24T00:00:00Z", + languages: { TypeScript: 100 }, + detected_frameworks: {}, + detected_devtools: {}, + detected_infras: {}, + contribution_calendar: { + total_contributions: 256, + weeks: [[{ date: "2025-01-06", count: 4, level: 2 }]], + }, + }, + }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + await expect( + page.getByRole("heading", { name: "Activity" }), + ).toBeVisible(); + await expect(page.getByText("年間コントリビュート")).toBeVisible(); + await expect(page.getByText("256")).toBeVisible(); + + // ページは表示専用。連携トリガーボタン(更新する/再連携)は無い + await expect( + page.getByRole("button", { name: "更新する" }), + ).toHaveCount(0); + await expect( + page.getByRole("button", { name: "再連携" }), + ).toHaveCount(0); + }); + + test("未連携時は空状態メッセージが表示され、ページに連携ボタンが無い", async ({ + page, + }) => { + await page.route("**/api/github-link/cache", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ result: null, status: null }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + await expect(page.getByText(/まだ連携データがありません/)).toBeVisible(); + // トリガーはサイドバーのみ。ページ本文に連携ボタンは無い + await expect( + page.getByRole("button", { name: "連携する" }), + ).toHaveCount(0); + }); + + test("サイドバーの ▾ でフォーク含むオプションが開閉する", async ({ + page, + }) => { + await page.route("**/api/github-link/cache", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ result: null, status: null }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + // デフォルトでは非表示 + await expect( + page.getByText("フォークしたリポジトリを含む"), + ).toHaveCount(0); + + await page + .getByRole("button", { name: "GitHub連携オプション" }) + .click(); + + await expect( + page.getByText("フォークしたリポジトリを含む"), + ).toBeVisible(); + }); + + test("サイドバーの GitHub連携 クリックで連携が実行されポーリング表示になる", async ({ + page, + }) => { + await page.route("**/api/github-link/cache", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ result: null, status: null }), + }), + ); + await page.route("**/api/github-link/run", (route) => + route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ status: "pending" }), + }), + ); + await page.route("**/api/github-link/cache/status", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "pending" }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + await page + .getByRole("button", { name: "GitHub連携", exact: true }) + .click(); + + await expect( + page.getByText("GitHubプロフィールを取得中..."), + ).toBeVisible(); + }); }); diff --git a/frontend/e2e/navigation.spec.ts b/frontend/e2e/navigation.spec.ts index b47bd2c2..26e9d5d8 100644 --- a/frontend/e2e/navigation.spec.ts +++ b/frontend/e2e/navigation.spec.ts @@ -16,7 +16,10 @@ test.describe("認証済みユーザーのナビゲーション", () => { await expect(page.getByText("DevForge")).toBeVisible(); await expect(page.getByRole("link", { name: "職務経歴書" })).toBeVisible(); - await expect(page.getByRole("link", { name: "GitHub連携" })).toBeVisible(); + // GitHub連携 は連携トリガーを兼ねるためボタン + await expect( + page.getByRole("button", { name: "GitHub連携", exact: true }), + ).toBeVisible(); await expect(page.getByRole("link", { name: "ブログ連携" })).toBeVisible(); }); diff --git a/frontend/src/App.module.css b/frontend/src/App.module.css index e6ba56fd..3b02c627 100644 --- a/frontend/src/App.module.css +++ b/frontend/src/App.module.css @@ -65,6 +65,67 @@ padding-left: calc(1.2rem - 3px); } +/* ── GitHub連携: 展開グループ(フォーク含むオプション)──────────── */ + +.sidebarItemGroup { + display: flex; + flex-direction: column; +} + +.sidebarItemRow { + display: flex; + align-items: stretch; +} + +.sidebarItemRow .sidebarItem { + flex: 1; + width: auto; +} + +.sidebarChevron { + display: flex; + align-items: center; + justify-content: center; + padding: 0 0.9rem; + background: none; + border: none; + color: var(--sidebar-text); + cursor: pointer; + transition: color 0.15s; +} + +.sidebarChevron:hover { + color: var(--sidebar-hover-text); + background: var(--sidebar-hover-bg); +} + +.sidebarChevron svg { + transition: transform 0.15s ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.sidebarSubPanel { + padding: 0.5rem 1.2rem 0.7rem; +} + +.sidebarCheckbox { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--sidebar-text); + font-size: 0.85rem; + cursor: pointer; +} + +.sidebarCheckbox input[type="checkbox"] { + width: auto; + margin: 0; + cursor: pointer; +} + .sidebarFooter { margin-top: auto; padding: 0.6rem 0; diff --git a/frontend/src/api/githubLink.ts b/frontend/src/api/githubLink.ts index 8b04c130..97b15e7f 100644 --- a/frontend/src/api/githubLink.ts +++ b/frontend/src/api/githubLink.ts @@ -14,6 +14,24 @@ 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; @@ -26,6 +44,8 @@ export interface GitHubLinkResponse { detected_devtools: Record; /** ルートファイルから検出したインフラツール名 → 使用リポジトリ数 */ detected_infras: Record; + /** 直近1年のコントリビューションカレンダー(取得失敗時は null) */ + contribution_calendar?: ContributionCalendar | null; } export interface CachedGitHubLinkResponse { diff --git a/frontend/src/components/AuthenticatedLayout.tsx b/frontend/src/components/AuthenticatedLayout.tsx index 1496b293..d6bd1781 100644 --- a/frontend/src/components/AuthenticatedLayout.tsx +++ b/frontend/src/components/AuthenticatedLayout.tsx @@ -1,9 +1,11 @@ -import { NavLink, Outlet } from "react-router-dom"; +import { useState } from "react"; +import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import type { AuthUser } from "../router/guards"; import type { Theme } from "../hooks/useTheme"; import { NotificationBell } from "./NotificationBell"; import { UserMenu } from "./UserMenu"; +import { ChevronDownIcon } from "./icons/ChevronDownIcon"; import shared from "../styles/shared.module.css"; import styles from "../App.module.css"; @@ -22,6 +24,26 @@ export function AuthenticatedLayout({ onToggleTheme: () => void; onLogout: () => void; }) { + const navigate = useNavigate(); + const location = useLocation(); + // GitHub 連携オプション(フォーク含む)の開閉とチェック状態。 + const [githubOptionsOpen, setGithubOptionsOpen] = useState(false); + const [includeForks, setIncludeForks] = useState(false); + + /** + * GitHub 連携を実行する。 + * 連携 API のトリガーはこのサイドバークリックのみ。 + * 実行意図を runNonce としてページへ渡し、ダッシュボード側で + * 連携実行(ポーリング)とエラー表示を担わせる。 + */ + const triggerGitHubLink = () => { + navigate("/github_link", { + state: { runNonce: Date.now(), includeForks }, + }); + }; + + const githubActive = location.pathname === "/github_link"; + return (
@@ -37,14 +59,40 @@ export function AuthenticatedLayout({ 職務経歴書 {user.isGitHubUser && ( - - `${styles.sidebarItem} ${isActive ? styles.active : ""}` - } - > - GitHub連携 - +
+
+ + +
+ {githubOptionsOpen && ( +
+ +
+ )} +
)} void; /** 取引先「取引先なし」切替ハンドラ */ onUpdateClientHasClient: (expIndex: number, clientIndex: number, value: boolean) => void; + /** 取引先「休暇」切替ハンドラ */ + onUpdateClientIsVacation: (expIndex: number, clientIndex: number, value: boolean) => void; + /** 休暇「継続中」切替ハンドラ */ + onUpdateClientVacationIsCurrent: (expIndex: number, clientIndex: number, value: boolean) => void; /** 取引先追加ハンドラ */ onAddClient: (expIndex: number) => void; /** 取引先削除ハンドラ */ @@ -57,6 +63,8 @@ export function CareerExperienceEditor({ onUpdateExperienceField, onUpdateClientField, onUpdateClientHasClient, + onUpdateClientIsVacation, + onUpdateClientVacationIsCurrent, onAddClient, onRemoveClient, onRemoveProject, @@ -181,7 +189,7 @@ export function CareerExperienceEditor({
- {/* 取引先 */} -
-

取引先

- {exp.clients.map((client, clientIndex) => { - const clientDirty = dirty?.clients?.[clientIndex]; - return ( -
-
- {client.has_client && ( - - )} - -
+ {/* IT企業かどうか(非ITは取引先を持たず詳細のみ) */} + - {/* プロジェクト一覧(サマリー表示) */} -
-

プロジェクト

- {client.projects.map((proj, projIndex) => { - const projDirty = clientDirty?.projects?.[projIndex]; - return ( -
- - {proj.name || "(未入力)"} - - - {projectSummary(proj)} -
- - -
-
- ); - })} - -
+ {!exp.is_it_company && ( +
+