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
115 changes: 83 additions & 32 deletions api/integrations/github/client.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import logging
import typing
from enum import Enum
from typing import Any

import requests
from django.conf import settings
from github import Auth, Github
from rest_framework import status
from rest_framework.response import Response

from integrations.github.constants import (
GITHUB_API_CALLS_TIMEOUT,
GITHUB_API_URL,
GITHUB_API_VERSION,
)
from integrations.github.dataclasses import RepoQueryParams
from integrations.github.dataclasses import (
IssueQueryParams,
PaginatedQueryParams,
RepoQueryParams,
)
from integrations.github.models import GithubConfiguration

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


def build_paginated_response(
results: list[dict[str, Any]],
response: requests.Response,
total_count: int | None = None,
incomplete_results: bool | None = None,
) -> dict[str, Any]:
Copy link
Member

Choose a reason for hiding this comment

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

nit: It's not critical, but, since we fully control the creation of this dict, we could add a TypedDict return type to this function that would help introspection.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe Dataclass or would it be too much? Let's please discuss this. Merging as is FTM.

data: dict[str, Any] = {
"results": results,
}

if response.links.get("prev"):
data["previous"] = response.links.get("prev")

if response.links.get("next"):
data["next"] = response.links.get("next")

if total_count:
data["total_count"] = total_count

if incomplete_results:
data["incomplete_results"] = incomplete_results

return data


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 +116,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 @@ -135,22 +162,23 @@ def fetch_github_resource(
}
for i in json_response["items"]
]
data = {
"results": results,
"count": json_response["total_count"],
"incomplete_results": json_response["incomplete_results"],
}
if response.links.get("prev"):
data["previous"] = response.links.get("prev")

if response.links.get("next"):
data["next"] = response.links.get("next")

return data
return build_paginated_response(
results=results,
response=response,
total_count=json_response["total_count"],
incomplete_results=json_response["incomplete_results"],
)


def fetch_github_repositories(installation_id: str) -> Response:
url = f"{GITHUB_API_URL}installation/repositories"
def fetch_github_repositories(
installation_id: str,
params: PaginatedQueryParams,
) -> dict[str, Any]:
url = (
f"{GITHUB_API_URL}installation/repositories?"
+ f"&per_page={params.page_size}&page={params.page}"
)

headers: dict[str, str] = build_request_headers(installation_id)

Expand All @@ -165,15 +193,8 @@ def fetch_github_repositories(installation_id: str) -> Response:
}
for i in json_response["repositories"]
]
data = {
"repositories": results,
"total_count": json_response["total_count"],
}
return Response(
data=data,
content_type="application/json",
status=status.HTTP_200_OK,
)

return build_paginated_response(results, response, json_response["total_count"])


def get_github_issue_pr_title_and_state(
Expand All @@ -191,5 +212,35 @@ 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,
params: RepoQueryParams,
) -> dict[str, Any]:
installation_id = GithubConfiguration.objects.get(
organisation_id=organisation_id, deleted_at__isnull=True
).installation_id

url = (
f"{GITHUB_API_URL}repos/{params.repo_owner}/{params.repo_name}/contributors?"
+ f"&per_page={params.page_size}&page={params.page}"
)

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"],
"contributions": i["contributions"],
}
for i in json_response
]

return build_paginated_response(results, response)
37 changes: 24 additions & 13 deletions api/integrations/github/dataclasses.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,55 @@
import typing
from dataclasses import dataclass
from typing import Optional
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:
page: int = field(default=1, kw_only=True)
page_size: int = field(default=100, kw_only=True)

def __post_init__(self):
if self.page < 1:
raise ValueError("Page must be greater or equal than 1")
if self.page_size < 1 or self.page_size > 100:
raise ValueError("Page size must be an integer between 1 and 100")


@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":
return cls(**data_dict)
16 changes: 15 additions & 1 deletion api/integrations/github/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
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,
PaginatedQueryParams,
RepoQueryParams,
)
from integrations.github.models import GithubConfiguration, GithubRepository


Expand Down Expand Up @@ -30,8 +34,18 @@ class Meta:
)


class PaginatedQueryParamsSerializer(DataclassSerializer):
class Meta:
dataclass = PaginatedQueryParams


class RepoQueryParamsSerializer(DataclassSerializer):
class Meta:
dataclass = RepoQueryParams


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

search_in_body = serializers.BooleanField(required=False, default=True)
Loading