Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add endpoint to fetch GitHub repository contributors #4013

Merged
merged 8 commits into from
May 24, 2024
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
30 changes: 20 additions & 10 deletions api/integrations/github/dataclasses.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,54 @@
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)
Copy link
Member

Choose a reason for hiding this comment

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

  1. Why derive from ABC?
  2. Why init=False? What if we want to customise pagination?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  1. Because I forgot we need to use it for a request and thought we would only need it as a base class for others. Now I changed it to a regular class and it's in use by the get repos endpoint.
  2. Because derivating without either init=false or kw_only gives an error. I now changed to use kw_only to have a better result.
    Thanks for cathing this out!



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


@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)

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
72 changes: 72 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,75 @@ 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


# Add a unit test to cover the case when request params are no valid
def test_fetch_github_repo_contributors_with_invalid_query_params(
admin_client_new: APIClient,
organisation: Organisation,
github_configuration: GithubConfiguration,
github_repository: GithubRepository,
) -> None:
# Given
url = reverse(
viewname="api-v1:organisations:get-github-repo-contributors",
args=[organisation.id],
)

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

# Then
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {"error": {"repo_name": ["This field is required."]}}