From 9d88cda2826224aab5cda15424453ea1cad75ae0 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sat, 11 Apr 2026 14:13:19 -0500 Subject: feat: auto-discover git repositories for actor polling --- API.md | 1 + README.md | 16 ++++---- src/srht_contrib/services/git.py | 83 ++++++++++++++++++++++++++++++++++++---- tests/test_ingestion.py | 61 +++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 15 deletions(-) diff --git a/API.md b/API.md index da38662..6703545 100644 --- a/API.md +++ b/API.md @@ -51,6 +51,7 @@ Repository names: - shorthand `repo-name` - canonical `~owner/repo-name` - Stored repository names are normalized to canonical `~owner/repo-name` form. +- Tracked repositories are optional force-includes for git polling; owned repositories are auto-discovered per actor. ## Health diff --git a/README.md b/README.md index bb4ac7c..8e4d32a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The current V1 is intentionally narrow and production-oriented: - SQLite-backed persistence - polling-based ingestion - complete `todo.sr.ht` ingestion path -- practical `git.sr.ht` commit ingestion for tracked repositories +- practical `git.sr.ht` commit ingestion with automatic repository discovery - public read-only contribution endpoints plus API-key protection for mutating/admin routes - Alembic-managed schema migrations @@ -51,7 +51,7 @@ Current normalized event types: - `ticket_closed` - `commit` -`todo.sr.ht` uses a feed-first strategy and falls back to crawling the authenticated user’s trackers, tickets, and ticket events when the top-level activity feed is empty. `git.sr.ht` polls tracked repositories for recent commits on the default branch. +`todo.sr.ht` uses a feed-first strategy and falls back to crawling the authenticated user’s trackers, tickets, and ticket events when the top-level activity feed is empty. `git.sr.ht` polls the actor's owned repositories for recent commits on the default branch and unions in any explicitly configured repositories. ## Canonical Event Model @@ -69,7 +69,7 @@ All ingestion services normalize external activity into this shape: The database enforces uniqueness on `(service, external_uid)` so polling is safe to repeat. -Tracked git repositories are persisted in the `tracked_repositories` table and stored in canonical `~owner/repo` form. The poller seeds that table from `GIT_TRACKED_REPOSITORIES`, and repositories can also be created, updated, and deleted through the API. +Tracked git repositories are persisted in the `tracked_repositories` table and stored in canonical `~owner/repo` form. They are optional overrides now: the poller auto-discovers an actor's owned repositories and unions in any configured or API-managed repositories. ## Configuration @@ -84,7 +84,7 @@ Environment variables: - `DEFAULT_ACTOR`: actor used by the scheduled poll job - `POLL_INTERVAL_SECONDS`: scheduler interval in seconds - `ACTOR_ALIASES_JSON`: optional JSON object for actor/email/display-name alias mapping -- `GIT_TRACKED_REPOSITORIES`: optional JSON array of repository names or `owner/repo` strings for git polling +- `GIT_TRACKED_REPOSITORIES`: optional JSON array of repository names or `owner/repo` strings to union into git polling Example `.env`: @@ -132,7 +132,7 @@ Set at least: - `API_KEY` - `SRHT_TOKEN` - `DEFAULT_ACTOR` -- `GIT_TRACKED_REPOSITORIES` if you want git commit ingestion +- `GIT_TRACKED_REPOSITORIES` if you want to force-include extra repositories beyond the actor's owned repos ### 3. Run database migrations @@ -167,7 +167,7 @@ Example response: Scheduled polling only runs when `ENABLE_SCHEDULER=true`. The scheduler seeds `DEFAULT_ACTOR` as an initial known actor, and public contribution reads register additional actors for later background polling. -For `git.sr.ht`, tracked repositories are configured via `GIT_TRACKED_REPOSITORIES`. Entries may be either: +For `git.sr.ht`, owned repositories are auto-discovered for the actor. `GIT_TRACKED_REPOSITORIES` can still be used to union in extra repositories. Entries may be either: - `"Hutch"` for a repository owned by `DEFAULT_ACTOR` - `"~your-user/your-site"` for an explicit owner/repository pair @@ -310,11 +310,11 @@ Covered areas: The SourceHut-specific assumptions are isolated to the service modules: - `src/srht_contrib/services/todo.py` uses the authenticated `events(cursor)` feed first, then falls back to tracker/ticket event traversal for reliable contribution discovery. -- `src/srht_contrib/services/git.py` uses the documented repository `log(cursor)` query against tracked repositories and attributes commits through the configured alias map. +- `src/srht_contrib/services/git.py` discovers owned repositories for an actor, polls each repository `log(cursor)`, and attributes commits through the configured alias map. ## Known Limitations -- `git.sr.ht` polling is limited to repositories listed in `GIT_TRACKED_REPOSITORIES` +- `git.sr.ht` polling assumes the actor's repositories are discoverable through the SourceHut GraphQL API - scheduled polling runs in-process, so it is not a distributed scheduler - newly requested actors are indexed asynchronously, so the first public read may be empty until a scheduler or manual poll runs - alias management is config-driven; there is no alias CRUD API yet diff --git a/src/srht_contrib/services/git.py b/src/srht_contrib/services/git.py index 20a18fe..9531709 100644 --- a/src/srht_contrib/services/git.py +++ b/src/srht_contrib/services/git.py @@ -47,6 +47,23 @@ query RepositoryLog($username: String!, $repoName: String!, $cursor: Cursor) { """.strip() +USER_REPOSITORIES_QUERY = """ +query UserRepositories($username: String!, $cursor: Cursor) { + user(username: $username) { + repositories(cursor: $cursor) { + results { + name + owner { + canonicalName + } + } + cursor + } + } +} +""".strip() + + @dataclass(slots=True) class GitPollResult: events: list[NormalizedEvent] @@ -54,7 +71,7 @@ class GitPollResult: class GitIngestionService: - """Polls tracked git.sr.ht repositories and normalizes commits for one actor.""" + """Polls git.sr.ht repositories and normalizes commits for one actor.""" service_name = "git" @@ -70,13 +87,13 @@ class GitIngestionService: repositories: list[str] | None = None, ) -> GitPollResult: since_dt = ensure_utc(since or (datetime.now(tz=UTC) - timedelta(days=30))) - tracked_repositories = repositories or self._tracked_repositories(actor) - if not tracked_repositories: - logger.info("git poll skipped for actor=%s because no tracked repositories are configured", actor) + discovered_repositories = repositories or self._repositories_for_actor(actor) + if not discovered_repositories: + logger.info("git poll skipped for actor=%s because no repositories were discovered", actor) return GitPollResult(events=[], cursor=datetime.now(tz=UTC).isoformat()) events: list[NormalizedEvent] = [] - for repository in tracked_repositories: + for repository in discovered_repositories: owner, repo_name = self._split_repository(actor, repository) repo_events = self._fetch_repository_commits(actor=actor, owner=owner, repo_name=repo_name, since=since_dt) events.extend(repo_events) @@ -84,10 +101,62 @@ class GitIngestionService: logger.info("git poll complete for actor=%s normalized_events=%s", actor, len(events)) return GitPollResult(events=events, cursor=datetime.now(tz=UTC).isoformat()) - def _tracked_repositories(self, actor: str) -> list[str]: - repositories = self.settings.git_tracked_repositories + def _repositories_for_actor(self, actor: str) -> list[str]: + configured = { + self._canonical_repository_name(actor, repository) + for repository in self.settings.git_tracked_repositories + } + discovered = set(self._discover_owned_repositories(actor)) + repositories = sorted(configured | discovered) + logger.info( + "git repositories selected for actor=%s count=%s configured=%s discovered=%s", + actor, + len(repositories), + len(configured), + len(discovered), + ) return repositories + def _discover_owned_repositories(self, actor: str) -> list[str]: + owner = actor.lstrip("~") + repositories: list[str] = [] + cursor: str | None = None + + for _ in range(50): + data = self.client.execute( + USER_REPOSITORIES_QUERY, + {"username": owner, "cursor": cursor}, + ) + user = data.get("user") or {} + repositories_page = user.get("repositories") or {} + results = repositories_page.get("results") or [] + cursor = repositories_page.get("cursor") + logger.info( + "git repository discovery actor=%s page_count=%s next_cursor=%s", + actor, + len(results), + bool(cursor), + ) + + for repository in results: + if not isinstance(repository, dict): + continue + name = repository.get("name") + repository_owner = ((repository.get("owner") or {}).get("canonicalName") or actor).strip() + if not name or not repository_owner: + continue + repositories.append(f"{repository_owner}/{name}") + + if not cursor: + break + + return repositories + + @staticmethod + def _canonical_repository_name(default_actor: str, repository: str) -> str: + owner, repo_name = GitIngestionService._split_repository(default_actor, repository) + return f"~{owner}/{repo_name}" + @staticmethod def _split_repository(default_actor: str, repository: str) -> tuple[str, str]: if "/" in repository: diff --git a/tests/test_ingestion.py b/tests/test_ingestion.py index 788ec81..3b7db16 100644 --- a/tests/test_ingestion.py +++ b/tests/test_ingestion.py @@ -310,6 +310,67 @@ def test_git_ingestion_normalizes_commit_aliases_and_repository_names(db_session assert tracked_repositories == ["~ccleberg/Hutch"] +def test_git_ingestion_auto_discovers_owned_repositories(db_session) -> None: + settings = make_settings( + ACTOR_ALIASES_JSON={"~ccleberg": ["cmc@example.com", "Chris Cleberg"]}, + GIT_TRACKED_REPOSITORIES=[], + ) + client = StubClient( + payloads_by_query={ + "query UserRepositories": { + "user": { + "repositories": { + "results": [ + {"name": "Hutch", "owner": {"canonicalName": "~ccleberg"}}, + ], + "cursor": None, + } + } + }, + "query RepositoryLog": { + "user": { + "repository": { + "name": "Hutch", + "owner": {"canonicalName": "~ccleberg"}, + "log": { + "results": [ + { + "id": "abc123", + "shortId": "abc123", + "author": { + "name": "Chris Cleberg", + "email": "cmc@example.com", + "time": "2026-03-30T12:00:00Z", + }, + "committer": { + "name": "Chris Cleberg", + "email": "cmc@example.com", + "time": "2026-03-30T12:00:00Z", + }, + "message": "Auto-discovered repo commit", + } + ], + "cursor": None, + }, + } + } + }, + } + ) + + todo_service = TodoIngestionService( + StubClient(payload={"me": {"canonicalName": "~ccleberg"}, "events": {"results": [], "cursor": None}}), + settings, + ) + git_service = GitIngestionService(client, settings) + poller = PollerService(todo_service=todo_service, git_service=git_service) + + inserted = poller.poll_all(db_session, "~ccleberg") + + assert inserted == 1 + assert any("query UserRepositories" in call[0] for call in client.calls) + + def test_sync_overlap_reuses_cursor_window_and_suppresses_duplicates(db_session) -> None: event = NormalizedEvent( service="todo", -- cgit v1.2.3