Skip to content

Commit

Permalink
feat: Add endpoint to fetch GitHub repository contributors
Browse files Browse the repository at this point in the history
  • Loading branch information
novakzaballa committed May 22, 2024
1 parent 59ddfba commit f46182a
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 32 deletions.
40 changes: 32 additions & 8 deletions api/integrations/github/client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import logging
import typing
from enum import Enum
from typing import Any

import requests
from django.conf import settings
Expand All @@ -13,7 +13,7 @@
GITHUB_API_URL,
GITHUB_API_VERSION,
)
from integrations.github.dataclasses import RepoQueryParams
from integrations.github.dataclasses import IssueQueryParams
from integrations.github.models import GithubConfiguration

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -68,7 +68,7 @@ def generate_jwt_token(app_id: int) -> str: # pragma: no cover

def post_comment_to_github(
installation_id: str, owner: str, repo: str, issue: str, body: str
) -> dict[str, typing.Any]:
) -> dict[str, Any]:

url = f"{GITHUB_API_URL}repos/{owner}/{repo}/issues/{issue}/comments"
headers = build_request_headers(installation_id)
Expand All @@ -89,11 +89,11 @@ def delete_github_installation(installation_id: str) -> requests.Response:
return response


def fetch_github_resource(
def fetch_search_github_resource(
resource_type: ResourceType,
organisation_id: int,
params: RepoQueryParams,
) -> dict[str, typing.Any]:
params: IssueQueryParams,
) -> dict[str, Any]:
github_configuration = GithubConfiguration.objects.get(
organisation_id=organisation_id, deleted_at__isnull=True
)
Expand Down Expand Up @@ -191,5 +191,29 @@ def get_github_issue_pr_title_and_state(
headers = build_request_headers(installation_id)
response = requests.get(url, headers=headers, timeout=GITHUB_API_CALLS_TIMEOUT)
response.raise_for_status()
response_json = response.json()
return {"title": response_json["title"], "state": response_json["state"]}
json_response = response.json()
return {"title": json_response["title"], "state": json_response["state"]}


def fetch_github_repo_contributors(
organisation_id: int, owner: str, repo: str
) -> list[dict[str, Any]]:
installation_id = GithubConfiguration.objects.get(
organisation_id=organisation_id, deleted_at__isnull=True
).installation_id

url = f"{GITHUB_API_URL}repos/{owner}/{repo}/contributors"
headers = build_request_headers(installation_id)
response = requests.get(url, headers=headers, timeout=GITHUB_API_CALLS_TIMEOUT)
response.raise_for_status()
json_response = response.json()

results = [
{
"login": i["login"],
"avatar_url": i["avatar_url"],
}
for i in json_response
]

return results
34 changes: 24 additions & 10 deletions api/integrations/github/dataclasses.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,58 @@
import typing
from dataclasses import dataclass
from typing import Optional
from abc import ABC
from dataclasses import dataclass, field
from typing import Any, Optional


# Base Dataclasses
@dataclass
class GithubData:
installation_id: str
feature_id: int
feature_name: str
type: str
feature_states: list[dict[str, typing.Any]] | None = None
feature_states: list[dict[str, Any]] | None = None
url: str | None = None
project_id: int | None = None
segment_name: str | None = None

@classmethod
def from_dict(cls, data_dict: dict[str, typing.Any]) -> "GithubData":
def from_dict(cls, data_dict: dict[str, Any]) -> "GithubData":
return cls(**data_dict)


@dataclass
class CallGithubData:
event_type: str
github_data: GithubData
feature_external_resources: list[dict[str, typing.Any]]
feature_external_resources: list[dict[str, Any]]


# Dataclasses for external calls to GitHub API
@dataclass
class RepoQueryParams:
class PaginatedQueryParams(ABC):
page: int = field(default=1, init=False)
page_size: int = field(default=100, init=False)


@dataclass
class RepoQueryParams(PaginatedQueryParams):
repo_owner: str
repo_name: str

@classmethod
def from_dict(cls, data_dict: dict[str, Any]) -> "RepoQueryParams":
return cls(**data_dict)

Check warning on line 44 in api/integrations/github/dataclasses.py

View check run for this annotation

Codecov / codecov/patch

api/integrations/github/dataclasses.py#L44

Added line #L44 was not covered by tests


@dataclass
class IssueQueryParams(RepoQueryParams):
search_text: Optional[str] = None
page: Optional[int] = 1
page_size: Optional[int] = 100
state: Optional[str] = "open"
author: Optional[str] = None
assignee: Optional[str] = None
search_in_body: Optional[bool] = True
search_in_comments: Optional[bool] = False

@classmethod
def from_dict(cls, data_dict: dict[str, typing.Any]) -> "RepoQueryParams":
def from_dict(cls, data_dict: dict[str, Any]) -> "IssueQueryParams":
return cls(**data_dict)
7 changes: 6 additions & 1 deletion api/integrations/github/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from rest_framework.serializers import ModelSerializer
from rest_framework_dataclasses.serializers import DataclassSerializer

from integrations.github.dataclasses import RepoQueryParams
from integrations.github.dataclasses import IssueQueryParams, RepoQueryParams
from integrations.github.models import GithubConfiguration, GithubRepository


Expand Down Expand Up @@ -34,4 +34,9 @@ class RepoQueryParamsSerializer(DataclassSerializer):
class Meta:
dataclass = RepoQueryParams


class IssueQueryParamsSerializer(DataclassSerializer):
class Meta:
dataclass = IssueQueryParams

search_in_body = serializers.BooleanField(required=False, default=True)
45 changes: 32 additions & 13 deletions api/integrations/github/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@
from integrations.github.client import (
ResourceType,
delete_github_installation,
fetch_github_repo_contributors,
fetch_github_repositories,
fetch_github_resource,
fetch_search_github_resource,
)
from integrations.github.dataclasses import RepoQueryParams
from integrations.github.exceptions import DuplicateGitHubIntegration
from integrations.github.helpers import github_webhook_payload_is_valid
from integrations.github.models import GithubConfiguration, GithubRepository
from integrations.github.permissions import HasPermissionToGithubConfiguration
from integrations.github.serializers import (
GithubConfigurationSerializer,
GithubRepositorySerializer,
IssueQueryParamsSerializer,
RepoQueryParamsSerializer,
)
from organisations.permissions.permissions import GithubIsAdminOrganisation
Expand Down Expand Up @@ -146,17 +147,15 @@ def create(self, request, *args, **kwargs):
@permission_classes([IsAuthenticated, HasPermissionToGithubConfiguration])
@github_auth_required
@github_api_call_error_handler(error="Failed to retrieve GitHub pull requests.")
def fetch_pull_requests(request, organisation_pk) -> Response | None:
query_serializer = RepoQueryParamsSerializer(data=request.query_params)
def fetch_pull_requests(request, organisation_pk) -> Response:
query_serializer = IssueQueryParamsSerializer(data=request.query_params)
if not query_serializer.is_valid():
return Response({"error": query_serializer.errors}, status=400)

query_params = RepoQueryParams.from_dict(query_serializer.validated_data.__dict__)

data = fetch_github_resource(
data = fetch_search_github_resource(
resource_type=ResourceType.PULL_REQUESTS,
organisation_id=organisation_pk,
params=query_params,
params=query_serializer.validated_data,
)
return Response(
data=data,
Expand All @@ -170,16 +169,14 @@ def fetch_pull_requests(request, organisation_pk) -> Response | None:
@github_auth_required
@github_api_call_error_handler(error="Failed to retrieve GitHub pull requests.")
def fetch_issues(request, organisation_pk) -> Response | None:
query_serializer = RepoQueryParamsSerializer(data=request.query_params)
query_serializer = IssueQueryParamsSerializer(data=request.query_params)
if not query_serializer.is_valid():
return Response({"error": query_serializer.errors}, status=400)

query_params = RepoQueryParams.from_dict(query_serializer.validated_data.__dict__)

data = fetch_github_resource(
data = fetch_search_github_resource(
resource_type=ResourceType.ISSUES,
organisation_id=organisation_pk,
params=query_params,
params=query_serializer.validated_data,
)
return Response(
data=data,
Expand Down Expand Up @@ -226,3 +223,25 @@ def github_webhook(request) -> Response:
return Response({"detail": "Event bypassed"}, status=200)
else:
return Response({"error": "Invalid signature"}, status=400)


@api_view(["GET"])
@permission_classes([IsAuthenticated, HasPermissionToGithubConfiguration])
@github_auth_required
@github_api_call_error_handler(error="Failed to retrieve GitHub pull requests.")
def fetch_repo_contributors(request, organisation_pk) -> Response:
query_serializer = RepoQueryParamsSerializer(data=request.query_params)
if not query_serializer.is_valid():
return Response({"error": query_serializer.errors}, status=400)

Check warning on line 235 in api/integrations/github/views.py

View check run for this annotation

Codecov / codecov/patch

api/integrations/github/views.py#L235

Added line #L235 was not covered by tests

response = fetch_github_repo_contributors(
organisation_id=organisation_pk,
owner=query_serializer.validated_data.repo_owner,
repo=query_serializer.validated_data.repo_name,
)

return Response(
data=response,
content_type="application/json",
status=status.HTTP_200_OK,
)
6 changes: 6 additions & 0 deletions api/organisations/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
GithubRepositoryViewSet,
fetch_issues,
fetch_pull_requests,
fetch_repo_contributors,
fetch_repositories,
)
from metadata.views import MetaDataModelFieldViewSet
Expand Down Expand Up @@ -124,6 +125,11 @@
fetch_issues,
name="get-github-issues",
),
path(
"<int:organisation_pk>/github/repo-contributors/",
fetch_repo_contributors,
name="get-github-repo-contributors",
),
path(
"<int:organisation_pk>/github/pulls/",
fetch_pull_requests,
Expand Down
46 changes: 46 additions & 0 deletions api/tests/unit/integrations/github/test_unit_github_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,3 +776,49 @@ def test_cannot_fetch_repositories_when_there_is_no_installation_id(
# Then
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {"detail": "Missing installation_id parameter"}


@responses.activate
def test_fetch_github_repo_contributors(
admin_client_new: APIClient,
organisation: Organisation,
github_configuration: GithubConfiguration,
github_repository: GithubRepository,
mocker: MockerFixture,
) -> None:
# Given
url = reverse(
viewname="api-v1:organisations:get-github-repo-contributors",
args=[organisation.id],
)
contributors_data = [
{"login": "contributor1", "avatar_url": "https://example.com/avatar1"},
{"login": "contributor2", "avatar_url": "https://example.com/avatar2"},
{"login": "contributor3", "avatar_url": "https://example.com/avatar3"},
]

mock_generate_token = mocker.patch(
"integrations.github.client.generate_token",
)
mock_generate_token.return_value = "mocked_token"

# Add response for endpoint being tested
responses.add(
responses.GET,
f"{GITHUB_API_URL}repos/{github_repository.repository_owner}/{github_repository.repository_name}/contributors",
json=contributors_data,
status=200,
)

# When
response = admin_client_new.get(
path=url,
data={
"repo_owner": github_repository.repository_owner,
"repo_name": github_repository.repository_name,
},
)

# Then
assert response.status_code == status.HTTP_200_OK
assert response.json() == contributors_data

0 comments on commit f46182a

Please sign in to comment.