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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""github_skill_evidence に manifest_path / partial_scan を追加する(ADR-0016 D9)

monorepo サブツリー探索(D9)で検出した manifest の相対パス(証跡 / D9(f))と、
走査が網羅でない部分スキャンだったか(D9(d))を Layer 2 根拠に保持する。

子テーブル(他から FK 参照されない)への素の ADD COLUMN のみ。libSQL は
``ALTER TABLE ADD COLUMN`` を直接サポートするため ``batch_alter_table`` は使わない。

Revision ID: 0046_add_manifest_path_to_github_skill_evidence
Revises: 0045_add_github_skill_tables
Create Date: 2026-06-26 00:00:00.000000
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op

revision: str = "0046_add_manifest_path_to_github_skill_evidence"
down_revision: Union[str, None] = "0045_add_github_skill_tables"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"github_skill_evidence",
sa.Column("manifest_path", sa.String(length=255), nullable=True),
)
op.add_column(
"github_skill_evidence",
sa.Column(
"partial_scan", sa.Boolean(), nullable=False, server_default="0"
),
)


def downgrade() -> None:
op.drop_column("github_skill_evidence", "partial_scan")
op.drop_column("github_skill_evidence", "manifest_path")
8 changes: 8 additions & 0 deletions backend/app/models/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ class GitHubSkillEvidence(Base):
dependency_kind: Mapped[str | None] = mapped_column(
String(20), nullable=True, default=None
)
# D9(f): manifest の相対パス(package 根拠のみ。例 backend/requirements.txt)
manifest_path: Mapped[str | None] = mapped_column(
String(255), nullable=True, default=None
)
# D9(d): 網羅でない部分スキャン由来か(truncated / cap 打ち切り。証跡の過信防止)
partial_scan: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=func.now(), server_default=func.now(), nullable=False
)
Expand Down
2 changes: 2 additions & 0 deletions backend/app/repositories/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ def replace_for_user(self, detected: list[DetectedSkill]) -> None:
confidence=ev.confidence,
language_bytes=ev.language_bytes,
dependency_kind=ev.dependency_kind,
manifest_path=ev.manifest_path,
partial_scan=ev.partial_scan,
)
for ev in item.evidence
]
Expand Down
2 changes: 2 additions & 0 deletions backend/app/routers/github_link.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ def _to_skill_item(skill) -> GitHubSkillItem:
confidence=ev.confidence,
language_bytes=ev.language_bytes,
dependency_kind=ev.dependency_kind,
manifest_path=ev.manifest_path,
partial_scan=ev.partial_scan,
)
for ev in skill.evidence
],
Expand Down
8 changes: 8 additions & 0 deletions backend/app/schemas/github_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class SkillEvidence(BaseModel):
default=None,
description="依存の種類(direct/dev/indirect/peer/build。言語では null)",
)
manifest_path: Optional[str] = Field(
default=None,
description="manifest の相対パス(package のみ。例 backend/requirements.txt。言語では null)",
)
partial_scan: bool = Field(
default=False,
description="網羅でない部分スキャン由来か(証跡の過信防止。言語では常に false)",
)


class SkillProficiency(BaseModel):
Expand Down
54 changes: 39 additions & 15 deletions backend/app/services/intelligence/github/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,29 +165,53 @@ async def fetch_languages(
return {}


async def fetch_root_filenames(
async def fetch_manifest_paths(
client: httpx.AsyncClient,
owner: str,
repo: str,
) -> List[str]:
"""リポジトリ直下のファイル名一覧を取得する(declare の manifest 探索用 / D7)。

manifest の取得はベストエフォート(1 リポの失敗で連携全体を落とさない)なので、
エラー時は空リストを返す。サブツリー探索は v1 では行わない(直下のみ)。
default_branch: str,
manifest_filenames: frozenset[str],
) -> tuple[List[str], bool]:
"""recursive Trees API でサブツリーを含む manifest パス一覧を取得する(declare / D7・D9)。

``GET /git/trees/{default_branch}?recursive=1`` を 1 回呼び、blob のうち basename が
``manifest_filenames`` に一致する相対パスだけを返す(1 リポ 1 コール / D9(a))。
第 2 戻り値は GitHub が木構造を打ち切ったか(``truncated`` / D9(d))。

除外リストや深さ・件数キャップといった探索ポリシーは呼び出し側(collector)の責務とし、
ここでは「API 呼び出し + basename フィルタ + truncated 返却」に留める。manifest 取得は
ベストエフォート(1 リポの失敗で連携全体を落とさない)。ただし tree 取得自体が失敗した場合
(非200 / 不正レスポンス / ``httpx.HTTPError``)は「依存ゼロ」と「走査不能」を区別するため、
第 2 戻り値の partial を ``True`` にして部分スキャンとして伝播する(D9(d))。不正 owner/repo は
実在リポではなく走査対象ですらないため ``([], False)`` のままとする。
"""
if not _is_valid_owner_repo(owner, repo):
return []
return [], False
branch = default_branch or "main"
try:
resp = await client.get(f"/repos/{owner}/{repo}/contents")
resp = await client.get(
f"/repos/{owner}/{repo}/git/trees/{branch}",
params={"recursive": "1"},
)
if resp.status_code != 200:
return []
entries = resp.json()
if not isinstance(entries, list):
return []
return [e["name"] for e in entries if e.get("type") == "file" and e.get("name")]
return [], True
data = resp.json()
if not isinstance(data, dict):
return [], True
tree = data.get("tree")
if not isinstance(tree, list):
return [], True
paths = [
entry["path"]
for entry in tree
if entry.get("type") == "blob"
and entry.get("path")
and entry["path"].rsplit("/", 1)[-1] in manifest_filenames
]
return paths, bool(data.get("truncated"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except httpx.HTTPError:
logger.warning("Failed to list root contents for %s/%s", owner, repo)
return []
logger.warning("Failed to fetch git tree for %s/%s", owner, repo)
return [], True


async def fetch_repo_file(
Expand Down
95 changes: 77 additions & 18 deletions backend/app/services/intelligence/github_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from typing import Dict, List, Optional

Expand All @@ -20,15 +20,38 @@
GITHUB_API,
GitHubUserNotFoundError,
fetch_languages,
fetch_manifest_paths,
fetch_repo_file,
fetch_repos_raw,
fetch_root_filenames,
)
from .skills.manifests import MANIFEST_FILENAMES, parse_manifest
from .skills.types import PackageDeclaration

logger = logging.getLogger(__name__)

# monorepo manifest 探索のヒューリスティック(ADR-0016 D9)。閾値は運用で調整しうるが、
# env_keys 同期コストに見合わないためモジュール定数として持つ(チューニングはコード変更)。
# D9(b): パスのいずれかのセグメントがこの集合に該当したら manifest 候補から除外する。
_MANIFEST_PATH_EXCLUDE_SEGMENTS = frozenset(
{
"node_modules",
"vendor",
".venv",
"venv",
"site-packages",
"bower_components",
"third_party",
"testdata",
"dist",
"build",
".git",
}
)
# D9(c): manifest パスのセグメント数上限(例: a/b/c/package.json = 4)。
_MANIFEST_MAX_DEPTH = 4
# D9(c): 1 リポあたり fetch する manifest 件数上限。
_MANIFEST_MAX_COUNT = 20

# このモジュールの公開 API。``GitHubUserNotFoundError`` は github_link_service が
# ``from .github_collector import GitHubUserNotFoundError`` で参照するため再エクスポートする。
__all__ = [
Expand All @@ -52,26 +75,59 @@ class RepoData:
fork: bool
stargazers_count: int
default_branch: str = field(default="main")
# declare ステージ: 直下 manifest が宣言する依存(D7)。未取得なら空。
# declare ステージ: manifest が宣言する依存(D7・D9。サブツリー含む)。未取得なら空。
package_declarations: List[PackageDeclaration] = field(default_factory=list)
# D9(d): manifest 走査が網羅的でない(truncated / cap で打ち切り)場合 True。
manifest_scan_partial: bool = False


def _is_excluded_manifest_path(path: str) -> bool:
"""パスのいずれかのセグメントが除外集合に該当するか(D9(b))。"""
return any(seg in _MANIFEST_PATH_EXCLUDE_SEGMENTS for seg in path.split("/"))


async def _collect_manifests(
client: httpx.AsyncClient, owner: str, repo: str
) -> List[PackageDeclaration]:
"""リポジトリ直下の manifest を取得・解析して依存宣言を返す(declare / D7)。
client: httpx.AsyncClient, owner: str, repo: str, default_branch: str
) -> tuple[List[PackageDeclaration], bool]:
"""サブツリーを含む manifest を取得・解析して依存宣言を返す(declare / D7・D9)。

直下のファイル一覧と既知 manifest 名の積集合だけを取得する。取得・解析失敗は
ベストエフォートで握りつぶす(1 リポの失敗で連携全体を落とさない)。
recursive Trees API で候補パスを列挙し、除外セグメント(D9(b))・深さ/件数キャップ
(D9(c))でフィルタしてから本文を取得する。取得・解析失敗はベストエフォートで握りつぶす
(1 リポの失敗で連携全体を落とさない)。第 2 戻り値は走査が部分的だったか(D9(d))。
"""
filenames = await fetch_root_filenames(client, owner, repo)
targets = [name for name in filenames if name in MANIFEST_FILENAMES]
paths, truncated = await fetch_manifest_paths(
client, owner, repo, default_branch, MANIFEST_FILENAMES
)
# D9(b): 除外セグメントを含むパスを捨てる(取得済みツリーの in-memory フィルタ)。
candidates = [p for p in paths if not _is_excluded_manifest_path(p)]
# D9(c): 浅い順に並べ、深さ上限超を落とし、件数上限で打ち切る。
candidates.sort(key=lambda p: (p.count("/"), p))
within_depth = [p for p in candidates if p.count("/") + 1 <= _MANIFEST_MAX_DEPTH]
depth_dropped = len(within_depth) < len(candidates)
selected = within_depth[:_MANIFEST_MAX_COUNT]
count_dropped = len(within_depth) > _MANIFEST_MAX_COUNT
partial = bool(truncated or depth_dropped or count_dropped)
if partial:
logger.warning(
"manifest 探索が部分的: %s/%s (truncated=%s, depth_dropped=%s, count_dropped=%s)",
owner,
repo,
truncated,
depth_dropped,
count_dropped,
)

declarations: List[PackageDeclaration] = []
for filename in targets:
content = await fetch_repo_file(client, owner, repo, filename)
if content:
declarations.extend(parse_manifest(filename, content))
return declarations
for path in selected:
content = await fetch_repo_file(client, owner, repo, path)
if not content:
continue
filename = path.rsplit("/", 1)[-1]
# D9(f): 検出した相対パスを証跡として各宣言に付与する。
declarations.extend(
replace(decl, source_path=path) for decl in parse_manifest(filename, content)
)
return declarations, partial


def _passes_filter(raw: dict, include_forks: bool, cutoff_date_str: str) -> bool:
Expand Down Expand Up @@ -135,10 +191,12 @@ async def collect_repos(

languages = await fetch_languages(client, owner_login, repo_name)

default_branch = raw.get("default_branch", "main")
declarations: List[PackageDeclaration] = []
manifest_partial = False
if collect_manifests:
declarations = await _collect_manifests(
client, owner_login, repo_name
declarations, manifest_partial = await _collect_manifests(
client, owner_login, repo_name, default_branch
)

repos.append(
Expand All @@ -152,8 +210,9 @@ async def collect_repos(
pushed_at=raw.get("pushed_at", ""),
fork=raw.get("fork", False),
stargazers_count=raw.get("stargazers_count", 0),
default_branch=raw.get("default_branch", "main"),
default_branch=default_branch,
package_declarations=declarations,
manifest_scan_partial=manifest_partial,
)
)

Expand Down
1 change: 1 addition & 0 deletions backend/app/services/intelligence/github_link_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ async def _on_repo_fetched(done: int, total: int) -> None:
url=f"https://github.com/{repo.owner}/{repo.name}",
languages=repo.languages,
package_declarations=repo.package_declarations,
manifest_scan_partial=repo.manifest_scan_partial,
)
for repo in repos
]
Expand Down
10 changes: 10 additions & 0 deletions backend/app/services/intelligence/skills/aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ class EvidenceRecord:
confidence: float
language_bytes: int | None = None
dependency_kind: str | None = None
# D9(f): manifest の相対パス(package 根拠のみ。language では None)。
manifest_path: str | None = None
# D9(d): 部分スキャン由来か(manifest 根拠のみ True になりうる。language は常に False)。
partial_scan: bool = False


@dataclass
Expand All @@ -76,6 +80,8 @@ class RepoSkillInput:
url: str
languages: dict[str, int]
package_declarations: list[PackageDeclaration] = field(default_factory=list)
# D9(d): このリポの manifest 走査が部分的だったか。package 根拠へ伝播する。
manifest_scan_partial: bool = False


def aggregate_skills(repos: list[RepoSkillInput]) -> list[DetectedSkill]:
Expand Down Expand Up @@ -182,6 +188,10 @@ def _collect_packages(
signal_source=_SIGNAL_MANIFEST_DECLARED,
confidence=_confidence(decl.dependency_kind),
dependency_kind=decl.dependency_kind,
# D9(f): 採用した宣言の manifest パスを証跡として残す。
manifest_path=decl.source_path,
# D9(d): partial は manifest 根拠にのみ立てる(language には立てない)。
partial_scan=repo.manifest_scan_partial,
)
)

Expand Down
2 changes: 2 additions & 0 deletions backend/app/services/intelligence/skills/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ class PackageDeclaration:
name: str # エコシステム内で一意な package ID
dependency_kind: str # DEPENDENCY_KINDS のいずれか
version_spec: str | None = None # バージョン制約(生文字列。解釈はしない)
# D9(f): manifest の相対パス(例: backend/requirements.txt)。証跡用。直下なら "package.json" 等。
source_path: str | None = None
Loading
Loading