From 821d41e02a95ab6f4ecf421c1e4ea7f2b33fec51 Mon Sep 17 00:00:00 2001 From: Adam Saimi Date: Wed, 10 Jun 2026 10:36:19 +0200 Subject: [PATCH 1/2] fix: sync repos created in GitHub orgs and GitLab groups/subgroups GitHub: - fetch_installation_repositories now paginates (was capped at 30 repos) - handle_repositories_added queues PR sync for newly added repos GitLab: - Handle project_create system hook: webhook enqueues event, consumer resolves namespace to org (direct DB lookup for root groups, one resolve_root_group API call for subgroups) then creates the repo and queues MR sync Co-Authored-By: Claude Sonnet 4.6 --- backend/api/plugins/github/plugin.py | 37 ++- .../api/routers/sources/gitlab/consumer.py | 142 +++++++++++ backend/api/routers/sources/gitlab/schemas.py | 11 + .../routers/webhooks/github/installations.py | 22 ++ .../api/routers/webhooks/gitlab/handlers.py | 42 +++- .../api/routers/webhooks/gitlab/schemas.py | 16 ++ .../tests/api/routers/test_github_webhooks.py | 67 +++++ .../tests/api/routers/test_gitlab_webhooks.py | 229 +++++++++++++++++- 8 files changed, 554 insertions(+), 12 deletions(-) diff --git a/backend/api/plugins/github/plugin.py b/backend/api/plugins/github/plugin.py index 9ba53e0..d107ac1 100644 --- a/backend/api/plugins/github/plugin.py +++ b/backend/api/plugins/github/plugin.py @@ -303,16 +303,39 @@ async def fetch_installation_repositories(self, installation_token: str) -> list "Accept": "application/vnd.github.v3+json", } - try: - response = await self.http.get("/installation/repositories", headers=headers) + all_repos: list[dict] = [] + page = 1 + per_page = 100 - if response.status_code != 200: - raise HTTPException( - status_code=response.status_code, detail=f"GitHub API error: {response.text}" + try: + while True: + response = await self.http.get( + "/installation/repositories", + headers=headers, + params={"page": page, "per_page": per_page}, ) - data = response.json() - return data.get("repositories", []) + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail=f"GitHub API error: {response.text}", + ) + + data = response.json() + repos = data.get("repositories", []) + + if not repos: + break + + all_repos.extend(repos) + + if len(repos) < per_page: + break + + page += 1 + + logger.info(f"Fetched {len(all_repos)} total repositories for installation") + return all_repos except HTTPException as e: raise e diff --git a/backend/api/routers/sources/gitlab/consumer.py b/backend/api/routers/sources/gitlab/consumer.py index acc71db..6834da4 100644 --- a/backend/api/routers/sources/gitlab/consumer.py +++ b/backend/api/routers/sources/gitlab/consumer.py @@ -4,6 +4,7 @@ - Sync repositories when a group token is added - Sync merge requests for each repository - Sync group members +- Handle project_create system hook events """ import logging @@ -16,7 +17,9 @@ from api.database import ( db_create_repository, db_create_repository_membership, + db_get_all_organizations, db_get_or_create_provider_identity, + db_get_organization_by_external_id, db_get_organization_by_id, db_get_repository_by_external_id, db_get_repository_by_id, @@ -26,6 +29,7 @@ from api.plugins.faststream import get_faststream_broker from api.plugins.faststream.config import STREAM_MAXLEN from api.routers.sources.gitlab.schemas import ( + GitLabProjectCreatedMessage, GitLabSyncGroupMessage, GitLabSyncGroupRepositoriesMessage, GitLabSyncMembersMessage, @@ -372,3 +376,141 @@ async def sync_group_members(message: GitLabSyncMembersMessage) -> None: ) logger.info(f"Synced {len(members)} members for group {group_id}") + + +@router.subscriber( + stream=StreamSub( + "reviewate.events.gitlab.project_created", group="reviewate", consumer="worker-1" + ) +) +async def handle_project_created(message: GitLabProjectCreatedMessage) -> None: + """Handle a GitLab project_create system hook event. + + The system hook payload includes project_namespace_id, which is the numeric + group ID — matching directly against external_org_id in the DB, no API call needed. + Projects in subgroups (where namespace_id != root group id) are skipped for now. + + Args: + message: Project created message from system hook + """ + project_id = str(message.project_id) + namespace_id = str(message.namespace_id) + path_with_namespace = message.path_with_namespace + + logger.info(f"Processing project_create for {path_with_namespace} (id={project_id})") + + app = get_current_app() + gitlab_plugin = app.gitlab + encryptor = get_encryptor() + + # Phase 1: Look up org by namespace_id. + # For root-group projects this matches directly (no API call). + # For subgroup projects we resolve the root group first using any available token. + with app.database.session() as db: + org = db_get_organization_by_external_id(db, namespace_id, provider="gitlab") + + if not org: + # Subgroup case: walk up to root using any available token + any_encrypted = next( + (o.gitlab_access_token_encrypted for o in db_get_all_organizations(db) + if o.provider == "gitlab" and o.gitlab_access_token_encrypted), + None, + ) + if not any_encrypted: + logger.debug("No GitLab token available to resolve subgroup — skipping") + return + resolve_token = encryptor.decrypt(any_encrypted) + + else: + resolve_token = None # not needed, already found + + if not org: + try: + root_group = await gitlab_plugin.resolve_root_group(resolve_token, namespace_id) + root_group_id = str(root_group["id"]) + except Exception as e: + logger.debug(f"Could not resolve root group for namespace {namespace_id}: {e}") + return + + with app.database.session() as db: + org = db_get_organization_by_external_id(db, root_group_id, provider="gitlab") + + if not org: + logger.debug(f"Namespace {namespace_id} not registered in Reviewate — skipping {path_with_namespace}") + return + + with app.database.session() as db: + existing = db_get_repository_by_external_id(db, project_id) + if existing: + logger.debug(f"Repository {path_with_namespace} (id={project_id}) already exists") + return + + if not org.gitlab_access_token_encrypted: + logger.warning(f"Org {org.id} has no GitLab token — cannot fetch project {path_with_namespace}") + return + + org_id = org.id + org_provider_url = org.provider_url or "https://gitlab.com" + access_token = encryptor.decrypt(org.gitlab_access_token_encrypted) + + # Phase 2: Fetch full project info (need web_url, avatar, etc.) + try: + project_info = await gitlab_plugin.fetch_project(access_token, project_id) + except Exception as e: + logger.error( + f"Failed to fetch project info for {path_with_namespace} (id={project_id}): {e}", + exc_info=True, + ) + return + + project_avatar_url = project_info.get("avatar_url") or project_info.get( + "namespace", {} + ).get("avatar_url") + + # Phase 3: Create repo record and publish SSE + with app.database.session() as db: + repository = db_create_repository( + db=db, + organization_id=org_id, + external_repo_id=project_id, + name=project_info["name"], + web_url=project_info["web_url"], + provider="gitlab", + provider_url=org_provider_url, + avatar_url=project_avatar_url, + ) + + logger.info(f"Created repository: {project_info['name']} ({repository.id})") + + try: + await publish_repository_event( + organization_id=str(org_id), + action="created", + repository={ + "id": str(repository.id), + "name": repository.name, + "external_repo_id": repository.external_repo_id, + "web_url": repository.web_url, + "provider": repository.provider, + "created_at": repository.created_at.isoformat(), + "updated_at": repository.updated_at.isoformat(), + }, + ) + except Exception as e: + logger.error(f"Failed to publish repository SSE event: {e}", exc_info=True) + + # Phase 4: Queue MR sync + try: + broker = get_faststream_broker() + sync_message = GitLabSyncRepositoryMRsMessage( + repository_id=str(repository.id), + organization_id=str(org_id), + ) + await broker.publish( + sync_message, + stream="reviewate.events.gitlab.sync_repository_mrs", + maxlen=STREAM_MAXLEN, + ) + logger.debug(f"Queued MR sync for new project {path_with_namespace}") + except Exception as e: + logger.error(f"Failed to queue MR sync for {path_with_namespace}: {e}") diff --git a/backend/api/routers/sources/gitlab/schemas.py b/backend/api/routers/sources/gitlab/schemas.py index 0369b2e..6bc879a 100644 --- a/backend/api/routers/sources/gitlab/schemas.py +++ b/backend/api/routers/sources/gitlab/schemas.py @@ -108,3 +108,14 @@ class GitLabSyncMembersMessage(BaseModel): default=None, description="Encrypted access token override (for subgroup tokens)", ) + + +class GitLabProjectCreatedMessage(BaseModel): + """Message schema for handling a GitLab project_create system hook event. + + Published when a GitLab system hook fires for a new project. + """ + + project_id: int = Field(description="GitLab project ID") + namespace_id: int = Field(description="Namespace (group) ID that owns the project") + path_with_namespace: str = Field(description="Full project path (e.g. myorg/myproject)") diff --git a/backend/api/routers/webhooks/github/installations.py b/backend/api/routers/webhooks/github/installations.py index 7a5f6e4..3112d1f 100644 --- a/backend/api/routers/webhooks/github/installations.py +++ b/backend/api/routers/webhooks/github/installations.py @@ -30,6 +30,7 @@ GitHubAppInstallationRepositoriesEvent, GitHubSyncInstallationMessage, GitHubSyncMembersMessage, + GitHubSyncRepositoryPRsMessage, ) logger = logging.getLogger(__name__) @@ -308,6 +309,27 @@ async def handle_repositories_added( except Exception as e: logger.error(f"Failed to publish repository SSE event: {e}", exc_info=True) + # Queue PR sync for the newly added repository + try: + full_name = repo_info.get("full_name", "") + owner, _, repo_name = full_name.partition("/") + if owner and repo_name: + broker = get_faststream_broker() + sync_message = GitHubSyncRepositoryPRsMessage( + repository_id=str(repository.id), + installation_id=installation_id, + owner=owner, + repo_name=repo_name, + ) + await broker.publish( + sync_message, + stream="reviewate.events.github.sync_repository_prs", + maxlen=STREAM_MAXLEN, + ) + logger.debug(f"Queued PR sync for repository {full_name}") + except Exception as e: + logger.error(f"Failed to queue PR sync for {repo_info.get('name')}: {e}") + return WebhookResponse( message=f"Added {added_count} repositories to organization {org.name}", processed=True, diff --git a/backend/api/routers/webhooks/gitlab/handlers.py b/backend/api/routers/webhooks/gitlab/handlers.py index c0604cc..06085e1 100644 --- a/backend/api/routers/webhooks/gitlab/handlers.py +++ b/backend/api/routers/webhooks/gitlab/handlers.py @@ -1,6 +1,6 @@ """GitLab webhook handlers. -This module handles all GitLab webhook events. +This module handles all GitLab webhook events, including system hooks. """ import json @@ -10,6 +10,9 @@ from sqlalchemy.orm import Session from api.database import get_session +from api.plugins.faststream import get_faststream_broker +from api.plugins.faststream.config import STREAM_MAXLEN +from api.routers.sources.gitlab.schemas import GitLabProjectCreatedMessage from ..utils import WebhookResponse from .dependencies import verify_gitlab_webhook @@ -18,6 +21,7 @@ from .schemas import ( GitLabMergeRequestEvent, GitLabNoteEvent, + GitLabProjectCreatedSystemHookEvent, ) logger = logging.getLogger(__name__) @@ -34,7 +38,8 @@ name="gitlab_webhook", summary="GitLab webhook router", description=( - "Unified webhook endpoint for all GitLab events. Currently handles merge request events." + "Unified webhook endpoint for all GitLab events. Handles merge request events, " + "note events, and system hook events (project_create)." ), response_model=WebhookResponse, status_code=202, @@ -45,6 +50,9 @@ async def gitlab_webhook( ) -> WebhookResponse: """Handle GitLab webhook events and route to appropriate handlers. + Supports both group/project webhooks (object_kind-based) and system hooks + (event_name-based). System hooks are admin-configured and fire globally. + Args: request: FastAPI request object db: Database session @@ -60,8 +68,34 @@ async def gitlab_webhook( # Parse event to determine type event_data = json.loads(body) object_kind = event_data.get("object_kind", "") + event_name = event_data.get("event_name", "") + + # System hooks use event_name instead of object_kind + if event_name == "project_create": + event = GitLabProjectCreatedSystemHookEvent(**event_data) + try: + broker = get_faststream_broker() + msg = GitLabProjectCreatedMessage( + project_id=event.project_id, + namespace_id=event.project_namespace_id, + path_with_namespace=event.path_with_namespace, + ) + await broker.publish( + msg, + stream="reviewate.events.gitlab.project_created", + maxlen=STREAM_MAXLEN, + ) + logger.info( + f"Queued project_create sync for {event.path_with_namespace} (id={event.project_id})" + ) + except Exception as e: + logger.error(f"Failed to queue project_create sync: {e}", exc_info=True) + return WebhookResponse( + message=f"Project creation queued for sync: {event.path_with_namespace}", + processed=True, + ) - # Route based on event type + # Group/project webhooks use object_kind if object_kind == "merge_request": event = GitLabMergeRequestEvent(**event_data) return await handle_merge_request_event(event, db) @@ -70,6 +104,6 @@ async def gitlab_webhook( return await handle_note_event(note_event, db) else: return WebhookResponse( - message=f"Event type '{object_kind}' not supported", + message=f"Event type '{object_kind or event_name}' not supported", processed=False, ) diff --git a/backend/api/routers/webhooks/gitlab/schemas.py b/backend/api/routers/webhooks/gitlab/schemas.py index db1cb53..f4aff6b 100644 --- a/backend/api/routers/webhooks/gitlab/schemas.py +++ b/backend/api/routers/webhooks/gitlab/schemas.py @@ -41,6 +41,22 @@ class GitLabNoteEvent(BaseModel): repository: dict[str, Any] | None = Field(description="Repository object", default=None) +class GitLabProjectCreatedSystemHookEvent(BaseModel): + """GitLab system hook payload for project_create events. + + Fired by GitLab system hooks (admin-configured) when a new project is created + anywhere on the instance. + """ + + event_name: str = Field(description="Event name (project_create)") + project_id: int = Field(description="GitLab project ID") + name: str = Field(description="Project name") + path: str = Field(description="Project path slug") + path_with_namespace: str = Field(description="Full path including namespace (e.g. myorg/myproject)") + project_namespace_id: int = Field(description="Namespace (group) ID that owns the project") + project_visibility: str | None = Field(default=None, description="Visibility level") + + class GitLabFeedbackSignalMessage(BaseModel): """Message schema for GitLab feedback signal processing.""" diff --git a/backend/tests/api/routers/test_github_webhooks.py b/backend/tests/api/routers/test_github_webhooks.py index e776f80..906e7ee 100644 --- a/backend/tests/api/routers/test_github_webhooks.py +++ b/backend/tests/api/routers/test_github_webhooks.py @@ -572,3 +572,70 @@ def _session(): prs = db_session.query(PullRequest).filter(PullRequest.repository_id == repo.id).all() assert len(prs) == 1 assert prs[0].id == existing_pr.id + + +# ============================================================================= +# installation_repositories webhook tests +# ============================================================================= + + +@patch("api.sse.publishers.get_faststream_broker") +@patch("api.routers.webhooks.github.installations.get_faststream_broker") +def test_repositories_added_webhook_queues_pr_sync( + mock_get_broker_handler, + mock_get_broker_sse, + client: TestClient, + create_organization: Organization, + db_session: Session, + mock_github_app_private_key, +): + """Test that installation_repositories.added creates repo and queues PR sync.""" + mock_broker = MagicMock() + mock_broker.publish = AsyncMock(return_value=None) + mock_get_broker_handler.return_value = mock_broker + mock_get_broker_sse.return_value = mock_broker + + create_organization.installation_id = "inst999" + create_organization.provider = "github" + db_session.commit() + + webhook_payload = { + "action": "added", + "installation": {"id": 999}, + "repositories_added": [ + { + "id": 555, + "name": "new-repo", + "full_name": "myorg/new-repo", + "html_url": "https://github.com/myorg/new-repo", + } + ], + "repositories_removed": [], + "sender": {"id": 1, "login": "sender"}, + } + + response = client.post( + "/webhooks/github", + json=webhook_payload, + headers={ + "x-github-event": "installation_repositories", + "x-hub-signature-256": "sha256=fake_signature", + }, + ) + + assert response.status_code == 202 + assert response.json()["processed"] is True + + # Repo created + repo = db_get_repository_by_external_id(db_session, "555") + assert repo is not None + assert repo.name == "new-repo" + + # SSE event + PR sync job published + assert mock_broker.publish.call_count == 2 + + pr_sync_call = mock_broker.publish.call_args_list[1] + pr_sync_msg = pr_sync_call[0][0] + assert pr_sync_msg.owner == "myorg" + assert pr_sync_msg.repo_name == "new-repo" + assert pr_sync_call[1]["stream"] == "reviewate.events.github.sync_repository_prs" diff --git a/backend/tests/api/routers/test_gitlab_webhooks.py b/backend/tests/api/routers/test_gitlab_webhooks.py index 962911d..fefcadd 100644 --- a/backend/tests/api/routers/test_gitlab_webhooks.py +++ b/backend/tests/api/routers/test_gitlab_webhooks.py @@ -3,11 +3,18 @@ Tests for GitLab MR webhook handlers at /webhooks/gitlab. """ +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest from fastapi.testclient import TestClient from sqlalchemy.orm import Session from api.database import db_create_pull_request, db_create_repository -from api.models import Organization, OrganizationMembership, PullRequest, User +from api.models import Organization, OrganizationMembership, PullRequest, Repository, User +from api.routers.sources.gitlab import consumer as gitlab_consumer +from api.routers.sources.gitlab.schemas import GitLabProjectCreatedMessage +from tests.utils.factories import OrganizationFactory, RepositoryFactory # ============================================================================= # GitLab MR Webhook Tests (POST /webhooks/gitlab) @@ -388,3 +395,223 @@ def test_gitlab_mr_webhook_repository_not_found( # Verify error response assert response.status_code == 404 assert "Repository not found" in response.json()["detail"] + + +# ============================================================================= +# GitLab system hook tests (project_create) +# ============================================================================= + + +@patch("api.routers.webhooks.gitlab.handlers.get_faststream_broker") +def test_gitlab_project_create_system_hook_queues_message( + mock_get_broker, + client: TestClient, + test_app, +): + """Test that project_create system hook publishes to the queue and returns 202.""" + mock_broker = MagicMock() + mock_broker.publish = AsyncMock(return_value=None) + mock_get_broker.return_value = mock_broker + + payload = { + "event_name": "project_create", + "project_id": 74, + "name": "MyProject", + "path": "myproject", + "path_with_namespace": "myorg/myproject", + "project_namespace_id": 42, + "project_visibility": "private", + } + + response = client.post( + "/webhooks/gitlab", + json=payload, + headers={"x-gitlab-token": "test-webhook-secret"}, + ) + + assert response.status_code == 202 + assert response.json()["processed"] is True + + mock_broker.publish.assert_called_once() + msg = mock_broker.publish.call_args[0][0] + assert msg.project_id == 74 + assert msg.namespace_id == 42 + assert mock_broker.publish.call_args[1]["stream"] == "reviewate.events.gitlab.project_created" + + +# ============================================================================= +# GitLab project_created consumer tests +# ============================================================================= + + +@pytest.mark.asyncio +@patch("api.sse.publishers.get_faststream_broker") +@patch("api.routers.sources.gitlab.consumer.get_faststream_broker") +@patch("api.routers.sources.gitlab.consumer.get_current_app") +async def test_handle_project_created_root_group( + mock_get_app, + mock_get_broker_consumer, + mock_get_broker_sse, + db_session: Session, +): + """Root-group project: namespace_id matches external_org_id directly — no resolve_root_group call.""" + org = OrganizationFactory.build( + external_org_id="42", + provider="gitlab", + provider_url="https://gitlab.com", + installation_id="gitlab-group-42", + gitlab_access_token_encrypted="encrypted-token", + ) + db_session.add(org) + db_session.commit() + + mock_app = MagicMock() + mock_gitlab = AsyncMock() + mock_gitlab.fetch_project.return_value = { + "id": 74, + "name": "MyProject", + "web_url": "https://gitlab.com/myorg/myproject", + "avatar_url": None, + "namespace": {"avatar_url": None}, + } + mock_app.gitlab = mock_gitlab + + @contextmanager + def _session(): + yield db_session + + mock_app.database.session = _session + mock_get_app.return_value = mock_app + + mock_broker = MagicMock() + mock_broker.publish = AsyncMock(return_value=None) + mock_get_broker_consumer.return_value = mock_broker + mock_get_broker_sse.return_value = mock_broker + + with patch("api.routers.sources.gitlab.consumer.get_encryptor") as mock_enc: + mock_enc.return_value.decrypt.return_value = "plain-token" + + message = GitLabProjectCreatedMessage( + project_id=74, + namespace_id=42, + path_with_namespace="myorg/myproject", + ) + await gitlab_consumer.handle_project_created(message) + + # resolve_root_group should NOT have been called (direct match) + mock_gitlab.resolve_root_group.assert_not_called() + + # Repository created + repo = db_session.query(Repository).filter(Repository.external_repo_id == "74").first() + assert repo is not None + assert repo.name == "MyProject" + assert repo.organization_id == org.id + + # SSE + MR sync queued + assert mock_broker.publish.call_count == 2 + + +@pytest.mark.asyncio +@patch("api.sse.publishers.get_faststream_broker") +@patch("api.routers.sources.gitlab.consumer.get_faststream_broker") +@patch("api.routers.sources.gitlab.consumer.get_current_app") +async def test_handle_project_created_subgroup( + mock_get_app, + mock_get_broker_consumer, + mock_get_broker_sse, + db_session: Session, +): + """Subgroup project: namespace_id doesn't match, resolve_root_group is called to find root.""" + org = OrganizationFactory.build( + external_org_id="10", # root group ID + provider="gitlab", + provider_url="https://gitlab.com", + installation_id="gitlab-group-10", + gitlab_access_token_encrypted="encrypted-token", + ) + db_session.add(org) + db_session.commit() + + mock_app = MagicMock() + mock_gitlab = AsyncMock() + # Subgroup namespace_id=99 does not exist in DB; resolve returns root id=10 + mock_gitlab.resolve_root_group.return_value = {"id": 10, "path": "myorg"} + mock_gitlab.fetch_project.return_value = { + "id": 74, + "name": "MyProject", + "web_url": "https://gitlab.com/myorg/sub/myproject", + "avatar_url": None, + "namespace": {"avatar_url": None}, + } + mock_app.gitlab = mock_gitlab + + @contextmanager + def _session(): + yield db_session + + mock_app.database.session = _session + mock_get_app.return_value = mock_app + + mock_broker = MagicMock() + mock_broker.publish = AsyncMock(return_value=None) + mock_get_broker_consumer.return_value = mock_broker + mock_get_broker_sse.return_value = mock_broker + + with patch("api.routers.sources.gitlab.consumer.get_encryptor") as mock_enc: + mock_enc.return_value.decrypt.return_value = "plain-token" + + message = GitLabProjectCreatedMessage( + project_id=74, + namespace_id=99, # subgroup ID, not in DB + path_with_namespace="myorg/sub/myproject", + ) + await gitlab_consumer.handle_project_created(message) + + mock_gitlab.resolve_root_group.assert_called_once_with("plain-token", "99") + + repo = db_session.query(Repository).filter(Repository.external_repo_id == "74").first() + assert repo is not None + assert repo.organization_id == org.id + + +@pytest.mark.asyncio +@patch("api.routers.sources.gitlab.consumer.get_current_app") +async def test_handle_project_created_unknown_namespace( + mock_get_app, + db_session: Session, +): + """Unknown namespace: neither direct nor resolved group matches — no repo created.""" + org = OrganizationFactory.build( + external_org_id="10", + provider="gitlab", + provider_url="https://gitlab.com", + installation_id="gitlab-group-10", + gitlab_access_token_encrypted="encrypted-token", + ) + db_session.add(org) + db_session.commit() + + mock_app = MagicMock() + mock_gitlab = AsyncMock() + mock_gitlab.resolve_root_group.return_value = {"id": 999, "path": "otherorg"} + mock_app.gitlab = mock_gitlab + + @contextmanager + def _session(): + yield db_session + + mock_app.database.session = _session + mock_get_app.return_value = mock_app + + with patch("api.routers.sources.gitlab.consumer.get_encryptor") as mock_enc: + mock_enc.return_value.decrypt.return_value = "plain-token" + + message = GitLabProjectCreatedMessage( + project_id=74, + namespace_id=55, # not in DB, resolves to unknown group + path_with_namespace="otherorg/project", + ) + await gitlab_consumer.handle_project_created(message) + + repo = db_session.query(Repository).filter(Repository.external_repo_id == "74").first() + assert repo is None From 0f3c39d9f411015aea689a2d08eda1eb114b0938 Mon Sep 17 00:00:00 2001 From: Adam Saimi Date: Wed, 10 Jun 2026 10:50:18 +0200 Subject: [PATCH 2/2] fix: derive web_url from full_name in installation_repositories.added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit repositories_added items are minimal repo objects — html_url is not included. Construct the URL as https://github.com/{full_name} instead of storing an empty string. Co-Authored-By: Claude Sonnet 4.6 --- backend/api/routers/webhooks/github/installations.py | 8 +++++--- backend/tests/api/routers/test_github_webhooks.py | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/backend/api/routers/webhooks/github/installations.py b/backend/api/routers/webhooks/github/installations.py index 3112d1f..8a951ba 100644 --- a/backend/api/routers/webhooks/github/installations.py +++ b/backend/api/routers/webhooks/github/installations.py @@ -265,7 +265,10 @@ async def handle_repositories_added( if existing_repo: continue # Skip if already exists - # Extract avatar URL from owner (repo's owner avatar) + # repositories_added items are minimal repo objects (id, name, full_name, private) + # — html_url and owner are not included, so we derive them from full_name. + full_name = repo_info.get("full_name", repo_info["name"]) + web_url = repo_info.get("html_url") or f"https://github.com/{full_name}" repo_avatar_url = repo_info.get("owner", {}).get("avatar_url") # Create repository @@ -274,7 +277,7 @@ async def handle_repositories_added( organization_id=org.id, external_repo_id=external_repo_id, name=repo_info["name"], - web_url=repo_info.get("html_url", ""), + web_url=web_url, provider="github", provider_url="https://github.com", avatar_url=repo_avatar_url, @@ -311,7 +314,6 @@ async def handle_repositories_added( # Queue PR sync for the newly added repository try: - full_name = repo_info.get("full_name", "") owner, _, repo_name = full_name.partition("/") if owner and repo_name: broker = get_faststream_broker() diff --git a/backend/tests/api/routers/test_github_webhooks.py b/backend/tests/api/routers/test_github_webhooks.py index 906e7ee..5dc1022 100644 --- a/backend/tests/api/routers/test_github_webhooks.py +++ b/backend/tests/api/routers/test_github_webhooks.py @@ -626,10 +626,11 @@ def test_repositories_added_webhook_queues_pr_sync( assert response.status_code == 202 assert response.json()["processed"] is True - # Repo created + # Repo created with correct URL derived from full_name (no html_url in minimal payload) repo = db_get_repository_by_external_id(db_session, "555") assert repo is not None assert repo.name == "new-repo" + assert repo.web_url == "https://github.com/myorg/new-repo" # SSE event + PR sync job published assert mock_broker.publish.call_count == 2