From 5c9251a809d463ce8cecc75093b01cf1bb8ce5e9 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Mon, 20 Jul 2026 20:33:18 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E3=82=B9=E3=82=AD=E3=83=AB?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E5=90=8D=E3=81=AE=E7=95=B3=E3=81=BF=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E7=A2=BA=E5=AE=9A=E3=82=92=E8=A7=A3=E9=99=A4=EF=BC=88?= =?UTF-8?q?=E3=83=90=E3=83=A9=E3=81=99=EF=BC=89=E3=81=A7=E3=81=8D=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88ADR-0016?= =?UTF-8?q?=20D11=20/=20#496=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一度確定した表示名・畳み込みグループを機械デフォルトへ戻す導線が無く、 upsert(削除しない)設計で確定行が残り続け完全リセットできなかったギャップを埋める。 Backend: - DELETE /skills/display-decisions を新設(identity 群指定で Layer 3 確定行を削除)。 authz は confirm と同一(当該ユーザーの検出済みスキルに属すること/存在しない 確定行は冪等に無視)。リセット後の最新スキル一覧を返す - repositories/skill.py に delete_by_identities を追加(user_id 固定・ORM 削除) - schemas に SkillDisplayResetRequest を追加、make codegen-types で generated.ts 再生成 Web: - utils/skillDisplay に isResettableGroup / buildResetIdentities を追加(TDD) - useGitHubSkills に reset / resetting を追加(TDD) - スキル一覧の確定済みチップに「解除」ボタンを追加。グループ全メンバーを まとめて解除するため畳み込みも解ける Docs: - ADR-0016 D11 に項 (g)(解除)と変更履歴を追記 テスト: backend 5 ケース / web util 5・hook 2 ケース / E2E 解除フロー 1 本を追加 Co-Authored-By: Claude Opus 4.8 --- backend/app/repositories/skill.py | 21 +++ backend/app/routers/github_link/endpoints.py | 30 ++++ backend/app/schemas/github_skill.py | 10 ++ backend/tests/test_skill_display_api.py | 144 ++++++++++++++++++ docs/adr/0016-github-skill-inference.md | 4 +- web/e2e/github-link.spec.ts | 82 ++++++++++ web/src/api/generated.ts | 55 ++++++- web/src/api/githubLink.ts | 15 ++ web/src/api/types.ts | 3 + .../SkillDisplaySection.module.css | 19 +++ .../github-link/SkillDisplaySection.tsx | 20 +++ web/src/constants/messages.ts | 7 + web/src/hooks/useGitHubSkills.test.ts | 49 ++++++ web/src/hooks/useGitHubSkills.ts | 27 +++- web/src/utils/skillDisplay.test.ts | 53 +++++++ web/src/utils/skillDisplay.ts | 22 +++ 16 files changed, 558 insertions(+), 3 deletions(-) diff --git a/backend/app/repositories/skill.py b/backend/app/repositories/skill.py index 2ea09030..a1fb9495 100644 --- a/backend/app/repositories/skill.py +++ b/backend/app/repositories/skill.py @@ -141,3 +141,24 @@ def upsert_many(self, decisions: list[DisplayDecisionInput]) -> None: ) ) self.db.commit() + + def delete_by_identities( + self, identities: list[tuple[str, str, str]] + ) -> int: + """指定 identity(kind + ecosystem + canonical_name)の確定行を削除する(#496)。 + + ユーザー自身の確定行だけを対象にし(``user_id`` 固定)、機械デフォルトへ戻す。 + 存在しない identity はスキップ(冪等)。削除件数を返す。畳み込みグループを解く場合は + 当該グループの全メンバー identity をまとめて渡す。 + """ + identity_set = set(identities) + if not identity_set: + return 0 + deleted = 0 + for decision in self.get_for_user(): + key = (decision.kind, decision.ecosystem, decision.canonical_name) + if key in identity_set: + self.db.delete(decision) + deleted += 1 + self.db.commit() + return deleted diff --git a/backend/app/routers/github_link/endpoints.py b/backend/app/routers/github_link/endpoints.py index 639cb53c..62928b63 100644 --- a/backend/app/routers/github_link/endpoints.py +++ b/backend/app/routers/github_link/endpoints.py @@ -35,6 +35,7 @@ SkillDisplayProposedGroup, SkillDisplayProposeRequest, SkillDisplayProposeResponse, + SkillDisplayResetRequest, SkillIdentityRef, ) from ...schemas.shared import TaskAcceptedResponse, TaskStatusResponse @@ -272,6 +273,35 @@ def confirm_skill_display_decisions( return _build_skills_response(db, user.id) +@router.delete("/skills/display-decisions", response_model=GitHubSkillsResponse) +def reset_skill_display_decisions( + body: SkillDisplayResetRequest, + user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> GitHubSkillsResponse: + """確定済みの表示名・畳み込みを解除(リセット)する(ADR-0016 D11 / #496)。 + + 指定 identity の Layer 3 確定行を削除し、機械デフォルト(機械 display_name > canonical) + へ戻す。畳み込みグループの全メンバー identity を渡せば畳み込みも解ける。identity は当該 + ユーザーの検出済みスキルに属していなければならない(confirm と同じ authz)。存在しない + 確定行の指定は冪等に無視する。リセット後の最新スキル一覧を返す。 + """ + skills = GitHubSkillRepository(db, user.id).list_for_user() + valid_identities = {(s.kind, s.ecosystem, s.canonical_name) for s in skills} + for ident in body.identities: + if (ident.kind, ident.ecosystem, ident.canonical_name) not in valid_identities: + raise_app_error( + status_code=422, + code=ErrorCode.VALIDATION_ERROR, + message=get_error("agent.skill_display_invalid_identity"), + ) + + GitHubSkillDisplayDecisionRepository(db, user.id).delete_by_identities( + [(i.kind, i.ecosystem, i.canonical_name) for i in body.identities] + ) + return _build_skills_response(db, user.id) + + @router.get("/cache/status", response_model=TaskStatusResponse) def get_cache_status( user: User = Depends(get_current_user), diff --git a/backend/app/schemas/github_skill.py b/backend/app/schemas/github_skill.py index ed879db7..98e57af2 100644 --- a/backend/app/schemas/github_skill.py +++ b/backend/app/schemas/github_skill.py @@ -143,3 +143,13 @@ class SkillDisplayConfirmRequest(BaseModel): """表示名確定(人間)のバッチリクエスト(ADR-0016 D11)。""" decisions: List[SkillDisplayDecisionInput] = Field(default_factory=list) + + +class SkillDisplayResetRequest(BaseModel): + """表示名確定の解除(リセット)リクエスト(ADR-0016 D11 / #496)。 + + 指定 identity の確定行(Layer 3)を削除し、機械デフォルト(機械 display_name / + canonical)へ完全に戻す。同一グループの全メンバー identity を渡せば畳み込みも解ける。 + """ + + identities: List[SkillIdentityRef] = Field(default_factory=list) diff --git a/backend/tests/test_skill_display_api.py b/backend/tests/test_skill_display_api.py index c9dcb1f4..12ebd15c 100644 --- a/backend/tests/test_skill_display_api.py +++ b/backend/tests/test_skill_display_api.py @@ -267,3 +267,147 @@ def test_decision_survives_relink_wipe(client) -> None: # スキルは作り直されたが、確定表示名は独立テーブルに残っているので復活する assert by_name["@aws-sdk/client-s3"]["confirmed_display_name"] == "Amazon S3" assert by_name["@aws-sdk/client-s3"]["group_id"] == "grp-aws" + + +# ---- reset(解除) ------------------------------------------------------- + + +def test_reset_requires_auth(client) -> None: + """未認証は 401。""" + resp = client.request( + "DELETE", + "/api/github-link/skills/display-decisions", + json={"identities": []}, + ) + assert resp.status_code == 401 + + +def test_reset_rejects_unknown_identity(client) -> None: + """当該ユーザーの検出済みスキルに無い identity のリセットは 422。""" + headers = auth_header(client, "disp_reset_badid") + uid = _user_id(client, "disp_reset_badid") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) + + resp = client.request( + "DELETE", + "/api/github-link/skills/display-decisions", + json={ + "identities": [ + {"kind": "package", "ecosystem": "npm", "canonical_name": "does-not-exist"} + ] + }, + headers=headers, + ) + assert resp.status_code == 422 + + +def test_reset_removes_decision_and_reverts_to_default(client) -> None: + """確定行を削除し、確定表示名・グループが機械デフォルト(null)へ戻ること(#496)。""" + headers = auth_header(client, "disp_reset") + uid = _user_id(client, "disp_reset") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) + GitHubSkillDisplayDecisionRepository(client._db_session, uid).upsert_many( + [DisplayDecisionInput("package", "npm", "@aws-sdk/client-s3", "Amazon S3", "grp-aws")] + ) + + resp = client.request( + "DELETE", + "/api/github-link/skills/display-decisions", + json={ + "identities": [ + {"kind": "package", "ecosystem": "npm", "canonical_name": "@aws-sdk/client-s3"} + ] + }, + headers=headers, + ) + assert resp.status_code == 200 + by_name = {s["canonical_name"]: s for s in resp.json()["skills"]} + s3 = by_name["@aws-sdk/client-s3"] + # 確定行が消え、確定フィールドは null(機械デフォルトへ完全リセット) + assert s3["confirmed_display_name"] is None + assert s3["group_id"] is None + assert s3["decision_source"] is None + assert s3["decision_reviewed"] is False + + # DB からも消えており、GET でも確定が復活しないこと + remaining = GitHubSkillDisplayDecisionRepository( + client._db_session, uid + ).get_for_user() + assert remaining == [] + + +def test_reset_ungroups_all_members(client) -> None: + """グループ全メンバーの identity を渡すと畳み込みが解ける(バラす / #496)。""" + headers = auth_header(client, "disp_reset_group") + uid = _user_id(client, "disp_reset_group") + # 2 つの package を同一 group_id で確定(N:1 畳み込み) + detected = [ + DetectedSkill( + kind="package", + canonical_name=name, + ecosystem="npm", + parent=None, + display_name=None, + evidence=[ + EvidenceRecord( + repo_full_name="u/a", + repo_url="https://github.com/u/a", + signal_source="manifest_declared", + confidence=0.6, + dependency_kind="direct", + ) + ], + ) + for name in ("@aws-sdk/client-s3", "@aws-sdk/client-eventbridge") + ] + GitHubSkillRepository(client._db_session, uid).replace_for_user(detected) + GitHubSkillDisplayDecisionRepository(client._db_session, uid).upsert_many( + [ + DisplayDecisionInput("package", "npm", "@aws-sdk/client-s3", "AWS", "grp-aws"), + DisplayDecisionInput( + "package", "npm", "@aws-sdk/client-eventbridge", "AWS", "grp-aws" + ), + ] + ) + + resp = client.request( + "DELETE", + "/api/github-link/skills/display-decisions", + json={ + "identities": [ + {"kind": "package", "ecosystem": "npm", "canonical_name": "@aws-sdk/client-s3"}, + { + "kind": "package", + "ecosystem": "npm", + "canonical_name": "@aws-sdk/client-eventbridge", + }, + ] + }, + headers=headers, + ) + assert resp.status_code == 200 + by_name = {s["canonical_name"]: s for s in resp.json()["skills"]} + for name in ("@aws-sdk/client-s3", "@aws-sdk/client-eventbridge"): + assert by_name[name]["confirmed_display_name"] is None + assert by_name[name]["group_id"] is None + + +def test_reset_is_idempotent_for_missing_decision(client) -> None: + """確定行が無い identity のリセットは 200・件数 0 で冪等(存在スキルなら 422 にしない)。""" + headers = auth_header(client, "disp_reset_idem") + uid = _user_id(client, "disp_reset_idem") + GitHubSkillRepository(client._db_session, uid).replace_for_user(_detected()) + + resp = client.request( + "DELETE", + "/api/github-link/skills/display-decisions", + json={ + "identities": [ + {"kind": "package", "ecosystem": "npm", "canonical_name": "@aws-sdk/client-s3"} + ] + }, + headers=headers, + ) + assert resp.status_code == 200 + by_name = {s["canonical_name"]: s for s in resp.json()["skills"]} + assert by_name["@aws-sdk/client-s3"]["confirmed_display_name"] is None diff --git a/docs/adr/0016-github-skill-inference.md b/docs/adr/0016-github-skill-inference.md index cd5091f9..4468e249 100644 --- a/docs/adr/0016-github-skill-inference.md +++ b/docs/adr/0016-github-skill-inference.md @@ -143,8 +143,9 @@ D3・D8 で「表示名・粒度の畳み込み(`@aws-sdk/client-eventbridge` - **(d) agent は提案のみ(D8 / 設計原則 P4 の責務分離を維持)**: 提案エンドポイントが実在スキル群を LLM に渡し、グループ(表示名 + メンバー canonical 群)を提案させる。**メンバーはリクエストごとの動的 enum で実在スキルに制約**し、存在しないスキルの捏造を構造的に排除する(ADR-0018 の `repo_full_name` 動的 enum と同手法)。LLM は確定しない・DB を書かない。提案は永続化せずレスポンスとして返すだけ。 - **(e) 人間が確定・永続化**: web でレビュー/編集した確定内容をバッチ upsert する確定エンドポイントを設ける。identity が当該ユーザーのスキルに属することを検証し(他者 identity の混入を拒否)、`source`("agent"=提案そのまま / "human"=編集)と `reviewed` を記録する。 - **(f) 機械制約はスキーマ・品質はプロンプト(ADR-0010 責務分離を厳守)**: 提案の JSON 構造・許可メンバー enum・表示名の文字数上限は構造化出力スキーマ(`output_schema.py`)に、畳み込みの品質基準(同一 SDK / 製品ファミリだけを保守的に畳む・不確実なら raw 維持・技術の捏造禁止)はプロンプト(`agent_skill_display.md`)に置く。提案 LLM は課金・プロバイダ抽象(ADR-0012・0013)を既存のまま流用する。 +- **(g) 確定の解除(リセット / バラす)を提供する(2026-07 追加 / #496)**: 一度確定した表示名・畳み込みを機械デフォルトへ戻す導線を設ける。`DELETE /skills/display-decisions`(body に identity 群)が当該 Layer 3 確定行を削除し、解決順は自動的に「機械 `display_name` > canonical」へ戻る。同一グループの全メンバー identity をまとめて渡せば畳み込みも解ける(バラす)。authz は confirm と同じ(identity は当該ユーザーの検出済みスキルに属すること/存在しない確定行の指定は冪等に無視)。upsert(削除しない)設計では確定行が残り続け完全リセットできなかったギャップ(#496)を埋める。web はスキル一覧の確定済みチップに「解除」ボタンを出す。 -**スコープ外(残課題)**: 確定値のバージョニング / 監査履歴、group をまたぐ evidence の重み再計算、agent による足切り(言語の上位 N 位)提案(D8 で別項)。動的 module 解決・Tier2 IaC は D10 の残課題のまま。 +**スコープ外(残課題)**: 確定値のバージョニング / 監査履歴、group をまたぐ evidence の重み再計算、agent による足切り(言語の上位 N 位)提案(D8 で別項)。動的 module 解決・Tier2 IaC は D10 の残課題のまま。確定単位での**メンバー個別の分割・除外**(グループから一部だけ外す)は未実装で、現状は「グループ丸ごと解除 → 再提案」で対応する。 実装で触る箇所: 新規 `models/skill.py`(`GitHubSkillDisplayDecision`)+ migration(`op.create_table`)/ `repositories/skill.py`(decision repo・serve join)/ 新規 `services/agent/skill_display/`(`output_schema.py`・`proposer.py`)/ 新規 `prompts/agent_skill_display.md` / `routers/github_link/endpoints.py`(propose / confirm / GET 拡張)/ `schemas/github_skill.py`(新規スキーマ + `GitHubSkillItem` 拡張、`make codegen-types`)/ web(スキル一覧ビュー + 提案レビュー UI 新規)。 @@ -235,6 +236,7 @@ D3・D8 で「表示名・粒度の畳み込み(`@aws-sdk/client-eventbridge` ## 改訂履歴 - **2026-07**: 「将来課題」「D10 スコープ外」だった**表示名・粒度の human-in-the-loop 畳み込みを D11 として採用・実装(#476)**。全 kind(package / infra / language)対象。人間の確定を Layer 1-2(機械・洗い替え対象)から切り離した独立 Layer 3 テーブル `github_skill_display_decision`(安定 identity キー)に保存し再連携の洗い替えに耐性を持たせた。agent は実在スキルの動的 enum で提案のみ行い(捏造防止・D8/P4 の提案のみ責務を維持)、人間が確定・永続化する。serve 時の解決順は確定値 > 機械 display_name(Linguist)> canonical。既存 `github_skills.display_name` は機械フォールバックとして温存。3 層モデル・D1〜D10 は不変。 +- **2026-07**: D11 に**確定の解除(リセット / 畳み込みをバラす)導線を追加(#496・D11 項 (g))**。`DELETE /skills/display-decisions`(identity 群指定)で Layer 3 確定行を削除し機械デフォルトへ完全に戻す(グループ全メンバー指定で畳み込みも解除)。upsert(削除しない)設計で確定行が残り続け完全リセットできなかったギャップを解消。web はスキル一覧の確定済みチップに「解除」ボタンを出す。schema 追加(`SkillDisplayResetRequest`)で `make codegen-types` 再生成。3 層モデル・D1〜D10 は不変。 - **2026-07**: D6/D9/D10 の**キャップ・サンプリング閾値を実データでチューニング(#478)**。連携本人の実データに収集コードを当てて打ち切り発生状況とキャップ反実仮想(値を変えた場合の検出数推移)を計測した。verify のソースキャップをグローバル 30 件 → **エコシステム別 50 件**へ変更(monorepo で先頭エコシステムが枠を使い切る押し出しを解消。順序戦略の変更は浅い順に勝てないことを確認)。IaC キャップを 30 → **60 件**へ引き上げ(30 では resource 20 種中 11 種を取りこぼし、50 で全量一致)。manifest キャップ(深さ 4 / 件数 20)と各深さ上限は実データで打ち切りゼロ・寄与ゼロを確認し据え置き。スキーマ・API 契約は不変。3 層モデル・D1〜D10 は不変。 - **2026-07**: verify(D6)の **import 名乖離の取りこぼしを低減(#477)**。pypi のみ、機械変換で当たらない配布名≠import名の乖離を内部マスタ(`skills/resources/pypi_import_aliases.json`)で補正。D3 テンションは `linguist_master.json` と同じ「ホットパス外で生成する暫定キュレーション・実行時は読むだけ」モデルで解消。既知の乖離 package を初期集合とし、`google.*` 等の汎用名前空間は false positive 回避のため除外。未収録は引き続き false negative 受容(過剰昇格なし)。schema / API 契約は不変(migration 不要)。3 層モデル・D1〜D10 は不変。 - **2026-07**: 「将来課題」だった IaC からのインフラリソース検出を **D10 として採用・実装**(Terraform/OpenTofu の `.tf` を対象に provider+service を抽出、kind=`infra` / signal_source=`infra_declared`、static resource ブロック限定、正規表現 parser で依存なし、D9 探索流用で `.terraform` 除外を追加、canonical=raw type で keep-all)。表示名の human-in-the-loop 畳み込み・動的 module 解決・Tier2 IaC・出現回数の量的シグナルは残課題。kind / signal_source / ecosystem は既存カラムの値域内のため migration 不要。3 層モデル・D1〜D9 は不変。 diff --git a/web/e2e/github-link.spec.ts b/web/e2e/github-link.spec.ts index e0b968f5..d48d79fd 100644 --- a/web/e2e/github-link.spec.ts +++ b/web/e2e/github-link.spec.ts @@ -377,4 +377,86 @@ test.describe("GitHub 連携 - コントリビューションヒートマップ" await page.getByRole("button", { name: "この内容で確定" }).click(); await expect(page.getByText("Amazon S3")).toBeVisible(); }); + + test("確定済みスキルの「解除」で機械デフォルト(canonical 名)に戻る(D11 / #496)", 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: 1, + unique_skills: 1, + analyzed_at: "2026-04-24T00:00:00Z", + languages: { TypeScript: 100 }, + }, + }), + }), + ); + // 初期一覧: 確定済み(confirmed_display_name あり)→ チップに確定名と「解除」が出る + await page.route("**/api/github-link/skills", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + skills: [ + { + kind: "package", + canonical_name: "@aws-sdk/client-s3", + ecosystem: "npm", + parent: null, + display_name: null, + confirmed_display_name: "Amazon S3", + group_id: null, + decision_source: "human", + decision_reviewed: true, + evidence: [], + proficiency: null, + }, + ], + }), + }), + ); + // 解除(DELETE)後の一覧: confirmed_display_name が null に戻る + await page.route("**/api/github-link/skills/display-decisions", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + skills: [ + { + kind: "package", + canonical_name: "@aws-sdk/client-s3", + ecosystem: "npm", + parent: null, + display_name: null, + confirmed_display_name: null, + group_id: null, + decision_source: null, + decision_reviewed: false, + evidence: [], + proficiency: null, + }, + ], + }), + }), + ); + + await page.goto("/github_link"); + await waitForAuthenticatedLayout(page); + + // 確定済みなので確定表示名がチップに出る + await expect(page.getByText("Amazon S3")).toBeVisible(); + + // 「解除」→ 機械デフォルト(canonical 名)に戻る + await page + .getByRole("button", { name: "Amazon S3 の表示名確定を解除" }) + .click(); + await expect(page.getByText("@aws-sdk/client-s3")).toBeVisible(); + await expect(page.getByText("Amazon S3")).toHaveCount(0); + }); }); diff --git a/web/src/api/generated.ts b/web/src/api/generated.ts index 1e1446d8..7c6ca5cc 100644 --- a/web/src/api/generated.ts +++ b/web/src/api/generated.ts @@ -552,7 +552,16 @@ export interface paths { */ put: operations["confirm_skill_display_decisions_api_github_link_skills_display_decisions_put"]; post?: never; - delete?: never; + /** + * Reset Skill Display Decisions + * @description 確定済みの表示名・畳み込みを解除(リセット)する(ADR-0016 D11 / #496)。 + * + * 指定 identity の Layer 3 確定行を削除し、機械デフォルト(機械 display_name > canonical) + * へ戻す。畳み込みグループの全メンバー identity を渡せば畳み込みも解ける。identity は当該 + * ユーザーの検出済みスキルに属していなければならない(confirm と同じ authz)。存在しない + * 確定行の指定は冪等に無視する。リセット後の最新スキル一覧を返す。 + */ + delete: operations["reset_skill_display_decisions_api_github_link_skills_display_decisions_delete"]; options?: never; head?: never; patch?: never; @@ -2096,6 +2105,17 @@ export interface components { */ members?: components["schemas"]["SkillIdentityRef"][]; }; + /** + * SkillDisplayResetRequest + * @description 表示名確定の解除(リセット)リクエスト(ADR-0016 D11 / #496)。 + * + * 指定 identity の確定行(Layer 3)を削除し、機械デフォルト(機械 display_name / + * canonical)へ完全に戻す。同一グループの全メンバー identity を渡せば畳み込みも解ける。 + */ + SkillDisplayResetRequest: { + /** Identities */ + identities?: components["schemas"]["SkillIdentityRef"][]; + }; /** * SkillEvidence * @description Layer 2: 技術×リポの根拠。 @@ -3019,6 +3039,39 @@ export interface operations { }; }; }; + reset_skill_display_decisions_api_github_link_skills_display_decisions_delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SkillDisplayResetRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GitHubSkillsResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; propose_skill_display_names_endpoint_api_github_link_skills_display_names_propose_post: { parameters: { query?: never; diff --git a/web/src/api/githubLink.ts b/web/src/api/githubLink.ts index ac66debb..a745a974 100644 --- a/web/src/api/githubLink.ts +++ b/web/src/api/githubLink.ts @@ -7,6 +7,7 @@ import type { SkillDisplayConfirmRequest, SkillDisplayProposeRequest, SkillDisplayProposeResponse, + SkillDisplayResetRequest, TaskAcceptedResponse, TaskStatusResponse, } from "./types"; @@ -96,3 +97,17 @@ export function confirmSkillDisplayDecisions( body: JSON.stringify(payload), }); } + +/** + * 確定済みの表示名・畳み込みを解除(リセット)します(D11 / #496)。 + * 指定 identity の確定行を削除し機械デフォルトへ戻す。リセット後の最新一覧を返します。 + * URL は確定と同じリソース(`/skills/display-decisions`)で、DELETE メソッドで区別します。 + */ +export function resetSkillDisplayDecisions( + payload: SkillDisplayResetRequest, +): Promise { + return request(PATHS.githubLink.skillsDisplayConfirm, { + method: "DELETE", + body: JSON.stringify(payload), + }); +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index f3c30bbe..c95674f0 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -66,6 +66,9 @@ export type SkillDisplayDecisionInput = Schemas["SkillDisplayDecisionInput"]; /** 表示名確定のバッチリクエスト。backend `SkillDisplayConfirmRequest`(D11)。 */ export type SkillDisplayConfirmRequest = Schemas["SkillDisplayConfirmRequest"]; +/** 表示名確定の解除(リセット)リクエスト。backend `SkillDisplayResetRequest`(D11 / #496)。 */ +export type SkillDisplayResetRequest = Schemas["SkillDisplayResetRequest"]; + // ── 認証(auth.py)──────────────────────────────────────────────────────── /** GitHub OAuth 認可 URL と CSRF 検証用 state。backend `schemas/auth.py:GitHubLoginUrlResponse`。 */ diff --git a/web/src/components/github-link/SkillDisplaySection.module.css b/web/src/components/github-link/SkillDisplaySection.module.css index fea33c4c..b4b4de0f 100644 --- a/web/src/components/github-link/SkillDisplaySection.module.css +++ b/web/src/components/github-link/SkillDisplaySection.module.css @@ -98,3 +98,22 @@ .secondaryButton:hover { background: var(--ghost-hover); } + +.resetButton { + border: none; + background: transparent; + color: var(--text-muted); + font-size: 0.72rem; + cursor: pointer; + padding: 0 2px; + text-decoration: underline; +} + +.resetButton:hover:not(:disabled) { + color: var(--text-primary); +} + +.resetButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/web/src/components/github-link/SkillDisplaySection.tsx b/web/src/components/github-link/SkillDisplaySection.tsx index 21763be8..00b4863e 100644 --- a/web/src/components/github-link/SkillDisplaySection.tsx +++ b/web/src/components/github-link/SkillDisplaySection.tsx @@ -2,6 +2,10 @@ import { useGitHubSkills } from "../../hooks/useGitHubSkills"; import { useAppErrorToast } from "../ui/toast"; import { InlineSpinner } from "../ui/InlineSpinner"; import { SKILL_DISPLAY_MESSAGES } from "../../constants/messages"; +import { + buildResetIdentities, + isResettableGroup, +} from "../../utils/skillDisplay"; import type { AgentModelAlias } from "../../api/types"; import dash from "./GitHubLinkDashboard.module.css"; import styles from "./SkillDisplaySection.module.css"; @@ -22,10 +26,12 @@ export function SkillDisplaySection({ model }: { model: AgentModelAlias }) { proposal, proposing, confirming, + resetting, propose, updateProposalName, discardProposal, confirm, + reset, } = useGitHubSkills(model); useAppErrorToast(error); @@ -51,6 +57,20 @@ export function SkillDisplaySection({ model }: { model: AgentModelAlias }) { {SKILL_DISPLAY_MESSAGES.memberCountLabel(group.skills.length)} )} + {/* 確定済みグループのみ、機械デフォルトへ戻す「解除」を出す(#496) */} + {isResettableGroup(group) && ( + + )} ))} diff --git a/web/src/constants/messages.ts b/web/src/constants/messages.ts index a5e41b38..0a1481f3 100644 --- a/web/src/constants/messages.ts +++ b/web/src/constants/messages.ts @@ -74,6 +74,7 @@ export const FALLBACK_MESSAGES = { SKILL_FETCH: "スキルの取得に失敗しました", SKILL_DISPLAY_PROPOSE: "表示名の提案に失敗しました", SKILL_DISPLAY_CONFIRM: "表示名の確定に失敗しました", + SKILL_DISPLAY_RESET: "表示名の解除に失敗しました", } as const; /** @@ -237,6 +238,12 @@ export const SKILL_DISPLAY_MESSAGES = { PROPOSE_EMPTY: "提案できる表示名がありませんでした。", /** 畳み込みメンバー数のラベル接尾(例: 「3 件内包」)。 */ memberCountLabel: (count: number): string => `${count} 件内包`, + /** 確定解除(リセット)ボタンのラベル。 */ + RESET: "解除", + /** 解除中のラベル。 */ + RESETTING: "解除中...", + /** 解除ボタンの aria-label(対象グループ名を含める)。 */ + resetAriaLabel: (label: string): string => `${label} の表示名確定を解除`, } as const; /** 年セレクトの選択肢表記「N年」。 */ diff --git a/web/src/hooks/useGitHubSkills.test.ts b/web/src/hooks/useGitHubSkills.test.ts index 32c51845..b4a34c62 100644 --- a/web/src/hooks/useGitHubSkills.test.ts +++ b/web/src/hooks/useGitHubSkills.test.ts @@ -7,12 +7,15 @@ import type { GitHubSkillItem } from "../api/types"; const getGitHubSkillsMock = vi.fn(); const proposeSkillDisplayNamesMock = vi.fn(); const confirmSkillDisplayDecisionsMock = vi.fn(); +const resetSkillDisplayDecisionsMock = vi.fn(); vi.mock("../api/githubLink", () => ({ getGitHubSkills: (...args: unknown[]) => getGitHubSkillsMock(...args), proposeSkillDisplayNames: (...args: unknown[]) => proposeSkillDisplayNamesMock(...args), confirmSkillDisplayDecisions: (...args: unknown[]) => confirmSkillDisplayDecisionsMock(...args), + resetSkillDisplayDecisions: (...args: unknown[]) => + resetSkillDisplayDecisionsMock(...args), })); function skill(overrides: Partial): GitHubSkillItem { @@ -36,6 +39,7 @@ beforeEach(() => { getGitHubSkillsMock.mockReset(); proposeSkillDisplayNamesMock.mockReset(); confirmSkillDisplayDecisionsMock.mockReset(); + resetSkillDisplayDecisionsMock.mockReset(); }); describe("useGitHubSkills", () => { @@ -136,4 +140,49 @@ describe("useGitHubSkills", () => { act(() => result.current.discardProposal()); expect(result.current.proposal).toBeNull(); }); + + it("reset で解除 API を呼び、返却された最新一覧で置き換える(success)", async () => { + getGitHubSkillsMock.mockResolvedValue({ + skills: [skill({ confirmed_display_name: "React", group_id: "g1" })], + }); + resetSkillDisplayDecisionsMock.mockResolvedValue({ + skills: [skill({ confirmed_display_name: null, group_id: null })], + }); + + const { result } = renderHook(() => useGitHubSkills("haiku")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.reset([ + { kind: "package", ecosystem: "npm", canonical_name: "react" }, + ]); + }); + + expect(resetSkillDisplayDecisionsMock).toHaveBeenCalledTimes(1); + const payload = resetSkillDisplayDecisionsMock.mock.calls[0][0]; + expect(payload.identities[0].canonical_name).toBe("react"); + // 解除後は確定フィールドが消えた最新一覧で置き換わる + expect(result.current.skills[0].confirmed_display_name).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("reset 失敗時は error にメッセージが入る(error)", async () => { + getGitHubSkillsMock.mockResolvedValue({ + skills: [skill({ confirmed_display_name: "React" })], + }); + resetSkillDisplayDecisionsMock.mockRejectedValue("boom"); + + const { result } = renderHook(() => useGitHubSkills("haiku")); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.reset([ + { kind: "package", ecosystem: "npm", canonical_name: "react" }, + ]); + }); + + expect(result.current.error).not.toBeNull(); + // 失敗しても元の確定一覧は保持される + expect(result.current.skills[0].confirmed_display_name).toBe("React"); + }); }); diff --git a/web/src/hooks/useGitHubSkills.ts b/web/src/hooks/useGitHubSkills.ts index 78d0060b..f0104762 100644 --- a/web/src/hooks/useGitHubSkills.ts +++ b/web/src/hooks/useGitHubSkills.ts @@ -4,9 +4,14 @@ import { confirmSkillDisplayDecisions, getGitHubSkills, proposeSkillDisplayNames, + resetSkillDisplayDecisions, } from "../api/githubLink"; import { toAppError, type AppErrorState } from "../api"; -import type { AgentModelAlias, GitHubSkillItem } from "../api/types"; +import type { + AgentModelAlias, + GitHubSkillItem, + SkillIdentityRef, +} from "../api/types"; import { FALLBACK_MESSAGES } from "../constants/messages"; import { buildDisplayDecisions, @@ -35,6 +40,7 @@ export function useGitHubSkills(model: AgentModelAlias) { const [proposal, setProposal] = useState(null); const [proposing, setProposing] = useState(false); const [confirming, setConfirming] = useState(false); + const [resetting, setResetting] = useState(false); const reload = useCallback(async () => { setLoading(true); @@ -101,6 +107,23 @@ export function useGitHubSkills(model: AgentModelAlias) { } }, [proposal]); + /** + * 確定済みの表示名・畳み込みを解除(リセット)し、返ってきた最新一覧で置き換える(#496)。 + * グループの全メンバー identity を渡せば畳み込みも解ける(機械デフォルトへ戻る)。 + */ + const reset = useCallback(async (identities: SkillIdentityRef[]) => { + setError(null); + setResetting(true); + try { + const res = await resetSkillDisplayDecisions({ identities }); + setSkills(res.skills ?? []); + } catch (e) { + setError(toAppError(e, FALLBACK_MESSAGES.SKILL_DISPLAY_RESET)); + } finally { + setResetting(false); + } + }, []); + const groups: DisplaySkillGroup[] = groupSkillsForDisplay(skills); return { @@ -111,10 +134,12 @@ export function useGitHubSkills(model: AgentModelAlias) { proposal, proposing, confirming, + resetting, propose, updateProposalName, discardProposal, confirm, + reset, reload, }; } diff --git a/web/src/utils/skillDisplay.test.ts b/web/src/utils/skillDisplay.test.ts index 77c6629e..291ba081 100644 --- a/web/src/utils/skillDisplay.test.ts +++ b/web/src/utils/skillDisplay.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect } from "vitest"; import { buildDisplayDecisions, + buildResetIdentities, effectiveSkillName, groupSkillsForDisplay, + isResettableGroup, type EditableProposalGroup, } from "./skillDisplay"; import type { GitHubSkillItem } from "../api/types"; @@ -100,3 +102,54 @@ describe("buildDisplayDecisions", () => { expect(decisions).toHaveLength(0); }); }); + +describe("isResettableGroup", () => { + it("確定表示名を持つメンバーがあれば解除可能", () => { + const group = groupSkillsForDisplay([ + skill({ canonical_name: "@aws-sdk/client-s3", group_id: "g1", confirmed_display_name: "AWS" }), + skill({ canonical_name: "@aws-sdk/client-sns", group_id: "g1", confirmed_display_name: "AWS" }), + ])[0]; + expect(isResettableGroup(group)).toBe(true); + }); + + it("単独の 1:1 確定スキルも解除可能", () => { + const group = groupSkillsForDisplay([ + skill({ canonical_name: "hcl", display_name: "Terraform", confirmed_display_name: "IaC" }), + ])[0]; + expect(isResettableGroup(group)).toBe(true); + }); + + it("未確定(機械デフォルトのみ)のグループは解除不可", () => { + const group = groupSkillsForDisplay([skill({ canonical_name: "react" })])[0]; + expect(isResettableGroup(group)).toBe(false); + }); +}); + +describe("buildResetIdentities", () => { + it("グループ全メンバーの identity を返す(畳み込みをバラす)", () => { + const group = groupSkillsForDisplay([ + skill({ canonical_name: "@aws-sdk/client-s3", group_id: "g1", confirmed_display_name: "AWS" }), + skill({ canonical_name: "@aws-sdk/client-sns", group_id: "g1", confirmed_display_name: "AWS" }), + ])[0]; + const identities = buildResetIdentities(group); + expect(identities.map((i) => i.canonical_name).sort()).toEqual([ + "@aws-sdk/client-s3", + "@aws-sdk/client-sns", + ]); + expect(identities.every((i) => i.kind === "package" && i.ecosystem === "npm")).toBe(true); + }); + + it("ecosystem が null のスキル(language)は空文字に正規化する", () => { + const group = groupSkillsForDisplay([ + skill({ + kind: "language", + canonical_name: "Python", + ecosystem: null, + confirmed_display_name: "Python3", + }), + ])[0]; + expect(buildResetIdentities(group)).toEqual([ + { kind: "language", ecosystem: "", canonical_name: "Python" }, + ]); + }); +}); diff --git a/web/src/utils/skillDisplay.ts b/web/src/utils/skillDisplay.ts index 448bd990..f59cda1f 100644 --- a/web/src/utils/skillDisplay.ts +++ b/web/src/utils/skillDisplay.ts @@ -58,6 +58,28 @@ export function groupSkillsForDisplay(skills: GitHubSkillItem[]): DisplaySkillGr return groups; } +/** + * グループが確定済み(Layer 3 の確定行を持つ)で「解除」可能かを返す(#496)。 + * 確定表示名を持つメンバーがあれば true(畳み込みグループ・1:1 リネームの両方)。 + * 機械デフォルト(未確定)のみのグループは解除対象が無いので false。 + */ +export function isResettableGroup(group: DisplaySkillGroup): boolean { + return group.skills.some((s) => Boolean(s.confirmed_display_name)); +} + +/** + * 解除(リセット)対象グループから、削除する identity 群を作る(#496)。 + * グループの全メンバーを返すため、N:1 の畳み込みはまとめてバラせる(機械デフォルトへ戻る)。 + * ``ecosystem`` は null(language)を空文字へ正規化して backend の identity と揃える。 + */ +export function buildResetIdentities(group: DisplaySkillGroup): SkillIdentityRef[] { + return group.skills.map((s) => ({ + kind: s.kind, + ecosystem: s.ecosystem ?? "", + canonical_name: s.canonical_name, + })); +} + /** ユーザーがレビュー・編集した提案グループ(確定前の編集状態)。 */ export interface EditableProposalGroup { /** 現在の(編集後の)表示名 */ From 97b01f7af620fbf017842b8ae8a5511d4fb0b844 Mon Sep 17 00:00:00 2001 From: Wada Yusuke Date: Mon, 20 Jul 2026 20:56:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(web):=20=E3=80=8C=E8=A7=A3=E9=99=A4?= =?UTF-8?q?=E4=B8=AD...=E3=80=8D=E8=A1=A8=E7=A4=BA=E3=82=92=E6=8A=BC?= =?UTF-8?q?=E4=B8=8B=E3=81=97=E3=81=9F=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E3=81=AE=E3=83=9C=E3=82=BF=E3=83=B3=E3=81=A0=E3=81=91=E3=81=AB?= =?UTF-8?q?=E9=99=90=E5=AE=9A=EF=BC=88=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=8C=87=E6=91=98=20/=20#496=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit global resetting は全解除ボタンの無効化に使い、押されたグループの区別は ローカルの resettingKey で行う。確定済みグループが複数ある場合に全ボタンへ 「解除中...」が出ていたのを、対象グループのボタンだけに出るよう修正。 Co-Authored-By: Claude Opus 4.8 --- .../github-link/SkillDisplaySection.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/web/src/components/github-link/SkillDisplaySection.tsx b/web/src/components/github-link/SkillDisplaySection.tsx index 00b4863e..625448bd 100644 --- a/web/src/components/github-link/SkillDisplaySection.tsx +++ b/web/src/components/github-link/SkillDisplaySection.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; + import { useGitHubSkills } from "../../hooks/useGitHubSkills"; import { useAppErrorToast } from "../ui/toast"; import { InlineSpinner } from "../ui/InlineSpinner"; @@ -5,6 +7,7 @@ import { SKILL_DISPLAY_MESSAGES } from "../../constants/messages"; import { buildResetIdentities, isResettableGroup, + type DisplaySkillGroup, } from "../../utils/skillDisplay"; import type { AgentModelAlias } from "../../api/types"; import dash from "./GitHubLinkDashboard.module.css"; @@ -34,6 +37,19 @@ export function SkillDisplaySection({ model }: { model: AgentModelAlias }) { reset, } = useGitHubSkills(model); + // 「解除中...」は押されたグループのボタンにだけ出す(global resetting は全ボタンの + // 無効化に使い、ローカルキーで対象グループを区別する / #496) + const [resettingKey, setResettingKey] = useState(null); + + const handleReset = async (group: DisplaySkillGroup) => { + setResettingKey(group.key); + try { + await reset(buildResetIdentities(group)); + } finally { + setResettingKey(null); + } + }; + useAppErrorToast(error); return ( @@ -62,11 +78,11 @@ export function SkillDisplaySection({ model }: { model: AgentModelAlias }) {