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
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ __pycache__/
.pytest_cache/
.ruff_cache/
/.codex
/profile/cache/bazel_registry_checkout/
/profile/cache/reference_integration_checkout/
/profile/cache/
/.cache
/_site/
3 changes: 2 additions & 1 deletion docs/repo-overview/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ rather than Markdown- or HTML-specific values.
- `collector/signal_detection.py` derives repository-local content signals.
- `collector/platform_docs.py` discovers and associates platform Sphinx
declarations after repository collection.
- `collector/git_checkout.py` owns shallow checkout synchronization and reads.
- `collector/git_checkout.py` delegates checkout synchronization to the shared
`repo_cache` package and provides repository reads and release-ref helpers.
- `collector/snapshot_io.py` serializes the normalized snapshot.
- `models.py` defines the collection/rendering boundary.
- `profile_readme.py` renders the organization profile.
Expand Down
15 changes: 8 additions & 7 deletions docs/repo-overview/collection-and-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Snapshot Cache

The default cache file is `.cache/repo_overview.json`.
Repository checkouts share the SCORE repository-policy cache:
Repository checkouts share the SCORE `repo_cache` cache:
`${XDG_CACHE_HOME:-~/.cache}/repo-cache/<owner>/<repository>`.

The cache is used in two ways:
Expand All @@ -17,8 +17,9 @@ Changing a renderer or template therefore requires no GitHub refresh.
## Incremental Collection

Collection still fetches current high-level repository state, including the
default branch. Git synchronizes each shallow partial checkout and supplies the
current commit SHA. The collector then chooses one of these paths:
default branch. The shared `repo_cache` package synchronizes each shallow
checkout through the authenticated GitHub CLI and supplies the current commit
SHA. The collector then chooses one of these paths:

- unchanged SHA and fresh volatile metrics: reuse the cached repository entry
- unchanged SHA and stale volatile metrics: refresh activity metrics only
Expand Down Expand Up @@ -50,10 +51,10 @@ Git supplies repository content and identity:
- Bazel and Sphinx declarations
- release `MODULE.bazel` and `.bazelversion`

Checkouts are disposable, shallow, single-branch partial clones. The generic
cache is shared with other repository tools such as `score-repo-policy-sync`.
Authentication is passed through a transient Git HTTP header and is not written
into the remote URL.
Checkouts are disposable, shallow, single-branch clones. The cache is shared
with other repository tools such as `score-repo-policy-sync` and
`score-repo-cache`. Checkout authentication is handled by `gh`; use
`GITHUB_TOKEN` or `gh auth login` before collecting.

For a repository without any commits, GitHub may report a default-branch name
even though that branch cannot be resolved. After a failed checkout, the
Expand Down
2 changes: 2 additions & 0 deletions docs/repo-overview/usage-and-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ uv sync --all-groups --frozen

Collection reads `GITHUB_TOKEN` and falls back to `gh auth token`. The token
must be able to read every configured organization and platform repository.
The shared `repo_cache` dependency uses the authenticated `gh` CLI for Git
checkout synchronization; a custom `--token-env` value is forwarded to it.
The policy report fetch uses `GH_TOKEN`/`GITHUB_TOKEN` through the installed
GitHub CLI (`gh auth login` is sufficient for local use).

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ description = "Collect cached GitHub organization overviews and render Markdown
requires-python = ">=3.12"
dependencies = [
"PyGithub",
"repo-cache @ git+https://github.com/eclipse-score/tools#subdirectory=repo_cache",
"tqdm",
]

Expand All @@ -26,6 +27,9 @@ dev = [
[tool.hatch.build.targets.wheel]
packages = ["src/generate_repo_overview"]

[tool.hatch.metadata]
allow-direct-references = true

[tool.uv]
package = true

Expand Down
26 changes: 17 additions & 9 deletions src/generate_repo_overview/collector/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ def collect_snapshot(
when="before collection",
status_prefix=status_prefix,
)
# repo_cache starts gh subprocesses for checkout synchronization. GH_TOKEN
# gives those subprocesses the same credential used by PyGithub, including
# when the caller selected a custom --token-env variable.
previous_gh_token = os.environ.get("GH_TOKEN")
os.environ["GH_TOKEN"] = token
try:
organization = github.get_organization(org_name)

Expand Down Expand Up @@ -197,7 +202,6 @@ def collect_snapshot(
repos,
github=github,
platform_repos=config.platform_repos,
github_token=token,
status_prefix=status_prefix,
)

Expand Down Expand Up @@ -228,6 +232,10 @@ def collect_snapshot(
print_status(f"Wrote snapshot to {cache_path}", prefix=status_prefix)
return snapshot
finally:
if previous_gh_token is None:
os.environ.pop("GH_TOKEN", None)
else:
os.environ["GH_TOKEN"] = previous_gh_token
print_rest_api_rate_limit(
github,
when="after collection",
Expand All @@ -240,7 +248,6 @@ def enrich_repositories_with_platform_docs(
*,
github: RepositoryResolverLike,
platform_repos: tuple[str, ...],
github_token: str | None = None,
status_prefix: str,
) -> list[RepoEntry]:
enriched = [
Expand All @@ -260,16 +267,14 @@ def enrich_repositories_with_platform_docs(
prefix=status_prefix,
)
repository = github.get_repo(full_name)
clone_url = cast("str | None", getattr(repository, "clone_url", None))
default_branch = cast("str | None", getattr(repository, "default_branch", None))
if clone_url is None or default_branch is None:
if default_branch is None:
raise RuntimeError(
f"Configured platform repository {full_name} has no clone metadata."
f"Configured platform repository {full_name} has no default branch."
)
checkout_path = sync_repository_checkout(
clone_url=clone_url,
repository=full_name,
default_branch=default_branch,
github_token=github_token,
checkout_path=DEFAULT_REPOSITORY_CHECKOUTS / full_name,
)
if checkout_path is None:
Expand Down Expand Up @@ -360,6 +365,10 @@ def fetch_repositories(
f"{repositories_with_custom_properties} repositories",
prefix=status_prefix,
)
resolved_registry_repository = cast(
"str | None", getattr(registry_repository, "full_name", None)
)
registry_repository_name = resolved_registry_repository or config.registry_repo

bazel_registry_metadata_by_repo: dict[
str, registry_metadata.RegistrySignalsPayload
Expand All @@ -373,7 +382,6 @@ def fetch_repositories(
registry_metadata.fetch_bazel_registry_metadata_by_repo(
bazel_registry_repository=registry_repository,
active_repository_names=set(active_repositories),
github_token=github_token,
)
)
print_status(
Expand All @@ -398,7 +406,7 @@ def fetch_repositories(
else None
),
active_repository_names=set(active_repositories),
github_token=github_token,
registry_repository=registry_repository_name,
org_name=config.org_name,
)
)
Expand Down
102 changes: 11 additions & 91 deletions src/generate_repo_overview/collector/git_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,109 +2,33 @@

import base64
import os
import shutil
import subprocess
from typing import TYPE_CHECKING

from repo_cache import RepoCacheError, sync_default_branch

if TYPE_CHECKING:
from pathlib import Path


def sync_repository_checkout(
*,
clone_url: str,
repository: str,
default_branch: str,
github_token: str | None,
checkout_path: Path,
) -> Path | None:
authenticated_url = build_authenticated_clone_url(clone_url, github_token)
checkout_path.parent.mkdir(parents=True, exist_ok=True)

if update_existing_checkout(
checkout_path,
default_branch,
github_token=github_token,
):
return checkout_path

if not clone_fresh_checkout(
authenticated_url=authenticated_url,
default_branch=default_branch,
checkout_path=checkout_path,
github_token=github_token,
):
"""Synchronize a GitHub checkout through the shared ``repo_cache`` package."""
try:
sync_default_branch(
repository=repository,
branch=default_branch,
destination=checkout_path,
)
except (OSError, RepoCacheError):
return None

return checkout_path


def update_existing_checkout(
checkout_path: Path,
default_branch: str,
*,
github_token: str | None = None,
) -> bool:
git_dir = checkout_path / ".git"
if not git_dir.exists():
return False

fetch_ok = run_git_command(
[
"git",
"-C",
str(checkout_path),
"fetch",
"--depth",
"1",
"origin",
default_branch,
],
github_token=github_token,
)
checkout_ok = run_git_command(
[
"git",
"-C",
str(checkout_path),
"checkout",
"--force",
"--detach",
"FETCH_HEAD",
]
)
if not (fetch_ok and checkout_ok):
return False

run_git_command(["git", "-C", str(checkout_path), "clean", "-fdx"])
return True


def clone_fresh_checkout(
*,
authenticated_url: str,
default_branch: str,
checkout_path: Path,
github_token: str | None = None,
) -> bool:
shutil.rmtree(checkout_path, ignore_errors=True)
return run_git_command(
[
"git",
"clone",
"--depth",
"1",
"--filter=blob:none",
"--single-branch",
"--no-tags",
"--branch",
default_branch,
authenticated_url,
str(checkout_path),
],
github_token=github_token,
)


def get_checkout_head_sha(checkout_path: Path) -> str | None:
try:
result = subprocess.run(
Expand Down Expand Up @@ -215,10 +139,6 @@ def run_git_command(
return True


def build_authenticated_clone_url(clone_url: str, github_token: str | None) -> str:
return clone_url


def _run_git_for_text(command: list[str]) -> str | None:
try:
result = subprocess.run(
Expand Down
34 changes: 19 additions & 15 deletions src/generate_repo_overview/collector/reference_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,17 @@
from typing import TYPE_CHECKING, cast
from urllib.parse import urlsplit

from repo_cache import default_cache_directory

from generate_repo_overview.org_config import is_safe_repository_path

from .git_checkout import sync_repository_checkout
from .registry_metadata import (
BAZEL_REGISTRY_LOCAL_CHECKOUT,
parse_bazel_registry_metadata,
)
from .registry_metadata import parse_bazel_registry_metadata
from .signal_detection import dedupe_preserving_order

if TYPE_CHECKING:
from collections.abc import Iterable

REFERENCE_INTEGRATION_LOCAL_CHECKOUT = Path(
"profile/cache/reference_integration_checkout"
)
ROOT_MODULE_PATH = Path("MODULE.bazel")
INCLUDE_PATTERN = re.compile(r'\binclude\s*\(\s*"(?P<label>[^"]+)"\s*\)')
BAZEL_DEP_PATTERN = re.compile(r"\bbazel_dep\s*\((?P<body>.*?)\)", re.DOTALL)
Expand All @@ -31,7 +29,7 @@ def fetch_reference_integration_repository_names(
*,
reference_integration_repository: object | None,
active_repository_names: set[str],
github_token: str | None,
registry_repository: str,
org_name: str,
) -> set[str]:
if reference_integration_repository is None:
Expand All @@ -41,17 +39,16 @@ def fetch_reference_integration_repository_names(
"str | None",
getattr(reference_integration_repository, "default_branch", None),
)
clone_url = cast(
"str | None", getattr(reference_integration_repository, "clone_url", None)
repository = cast(
"str | None", getattr(reference_integration_repository, "full_name", None)
)
if default_branch is None or clone_url is None:
if default_branch is None or repository is None:
return set()

checkout_path = sync_repository_checkout(
clone_url=clone_url,
repository=repository,
default_branch=default_branch,
github_token=github_token,
checkout_path=REFERENCE_INTEGRATION_LOCAL_CHECKOUT,
checkout_path=default_cache_directory() / repository,
)
if checkout_path is None:
return set()
Expand All @@ -65,6 +62,7 @@ def fetch_reference_integration_repository_names(
)
registry_repositories = get_bazel_registry_repositories_by_module(
active_repository_names=active_repository_names,
registry_repository=registry_repository,
)

repositories: list[str] = []
Expand Down Expand Up @@ -240,10 +238,16 @@ def parse_github_remote_repository_name(
def get_bazel_registry_repositories_by_module(
*,
active_repository_names: set[str],
registry_repository: str,
) -> dict[str, str]:
if not is_safe_repository_path(registry_repository):
return {}

repositories_by_module: dict[str, str] = {}
for metadata_path in sorted(
BAZEL_REGISTRY_LOCAL_CHECKOUT.glob("modules/*/metadata.json")
(default_cache_directory() / registry_repository).glob(
"modules/*/metadata.json"
)
Comment on lines 246 to +250

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is correct. The previous validation only required a slash, so values such as ../etc, absolute paths, and Windows-style paths could escape the cache root when joined with default_cache_directory(). Commit 727a3f9 now requires exactly two non-empty repository path components and rejects dot/dotdot, rooted/drive paths, and backslashes both during configuration loading and defensively in the metadata lookup. Regression tests cover these cases; the full suite passes with 171 tests.

):
try:
content = metadata_path.read_text(encoding="utf-8")
Expand Down
Loading