GithubGraphQLService.java

package edu.ucsb.cs156.frontiers.services;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import edu.ucsb.cs156.frontiers.entities.Course;
import edu.ucsb.cs156.frontiers.entities.DownloadRequest;
import edu.ucsb.cs156.frontiers.entities.DownloadedCommit;
import edu.ucsb.cs156.frontiers.errors.NoLinkedOrganizationException;
import edu.ucsb.cs156.frontiers.repositories.DownloadedCommitRepository;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.graphql.GraphQlResponse;
import org.springframework.graphql.client.HttpSyncGraphQlClient;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

@Service
@Slf4j
public class GithubGraphQLService {

  private final HttpSyncGraphQlClient graphQlClient;

  private final JwtService jwtService;

  private final String githubBaseUrl = "https://api.github.com/graphql";

  private final ObjectMapper jacksonObjectMapper;
  private final DownloadedCommitRepository downloadedCommitRepository;

  public GithubGraphQLService(
      RestClient.Builder builder,
      JwtService jwtService,
      ObjectMapper jacksonObjectMapper,
      DownloadedCommitRepository downloadedCommitRepository) {
    this.jwtService = jwtService;
    this.graphQlClient =
        HttpSyncGraphQlClient.builder(builder.baseUrl(githubBaseUrl).build())
            .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
            .build();
    this.jacksonObjectMapper = jacksonObjectMapper;
    this.downloadedCommitRepository = downloadedCommitRepository;
  }

  /**
   * Retrieves the name of the default branch for a given GitHub repository.
   *
   * @param owner The owner (username or organization) of the repository.
   * @param repo The name of the repository.
   * @return A Mono emitting the default branch name, or an empty Mono if not found.
   */
  public String getDefaultBranchName(Course course, String owner, String repo)
      throws JsonProcessingException,
          NoSuchAlgorithmException,
          InvalidKeySpecException,
          NoLinkedOrganizationException {
    log.info(
        "getDefaultBranchName called with course.getId(): {} owner: {}, repo: {}",
        course.getId(),
        owner,
        repo);
    String githubToken = jwtService.getInstallationToken(course);

    // language=GraphQL
    String query =
        """
        query getDefaultBranch($owner: String!, $repo: String!) {
          repository(owner: $owner, name: $repo) {
            defaultBranchRef {
              name
            }
          }
        }
        """;

    return graphQlClient
        .mutate()
        .header("Authorization", "Bearer " + githubToken)
        .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
        .build()
        .document(query)
        .variable("owner", owner)
        .variable("repo", repo)
        .retrieveSync("repository.defaultBranchRef.name")
        .toEntity(String.class);
  }

  public String getCommits(
      Course course, String owner, String repo, String branch, int first, String after)
      throws JsonProcessingException,
          NoSuchAlgorithmException,
          InvalidKeySpecException,
          NoLinkedOrganizationException {
    return getCommits(course, owner, repo, branch, null, null, first, after);
  }

  /**
   * Retrieves the commit history for a specified branch of a GitHub repository within a given time
   * range.
   *
   * @param course The course entity, used to fetch the associated GitHub installation token.
   * @param owner The owner of the GitHub repository.
   * @param repo The name of the GitHub repository.
   * @param branch The branch of the repository for which the commit history is retrieved.
   * @param since The start time for fetching commits (inclusive). Optional. Can be null.
   * @param until The end time for fetching commits (exclusive). Optional. Can be null.
   * @param size The maximum number of commits to retrieve in one request.
   * @param cursor The pagination cursor pointing to the start of the commit history to fetch.
   *     Optional. Can be null.
   * @return A JSON string representing the commit history and associated metadata.
   * @throws NoLinkedOrganizationException If no linked organization exists for the specified
   *     course.
   */
  public String getCommits(
      Course course,
      String owner,
      String repo,
      String branch,
      Instant since,
      Instant until,
      int size,
      String cursor)
      throws JsonProcessingException,
          NoSuchAlgorithmException,
          InvalidKeySpecException,
          NoLinkedOrganizationException {
    String githubToken = jwtService.getInstallationToken(course);
    // language=GraphQL
    String query =
        """
            query GetBranchCommits($owner: String!, $repo: String!, $branch: String!, $first: Int!, $after: String, $since: GitTimestamp, $until: GitTimestamp) {
              repository(owner: $owner, name: $repo) {
                ref(qualifiedName: $branch) {
                  target {
                    ... on Commit {
                      history(first: $first, after: $after, since: $since, until: $until) {
                        pageInfo {
                          hasNextPage
                          endCursor
                        }
                        edges {
                          node {
                            oid
                            url
                            messageHeadline
                            committedDate
                            author {
                              name
                              email
                              user {
                                login
                              }
                            }
                            committer {
                              name
                              email
                              user {
                                login
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
            """;

    GraphQlResponse response =
        graphQlClient
            .mutate()
            .header("Authorization", "Bearer " + githubToken)
            .build()
            .document(query)
            .variable("owner", owner)
            .variable("repo", repo)
            .variable("branch", branch)
            .variable("first", size)
            .variable("after", cursor)
            .variable("since", since)
            .variable("until", until)
            .executeSync();

    Map<String, Object> data = response.getData();
    String jsonData = jacksonObjectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(data);
    return jsonData;
  }

  public void downloadCommitHistory(DownloadRequest downloadRequest)
      throws NoSuchAlgorithmException, InvalidKeySpecException, JsonProcessingException {
    String pointer = null;
    boolean hasNextPage;
    List<DownloadedCommit> downloadedCommits = new ArrayList<>(4000);
    do {
      JsonNode currentPage =
          jacksonObjectMapper.readTree(
              getCommits(
                  downloadRequest.getCourse(),
                  downloadRequest.getOrg(),
                  downloadRequest.getRepo(),
                  downloadRequest.getBranch(),
                  downloadRequest.getStartDate(),
                  downloadRequest.getEndDate(),
                  100,
                  pointer));
      pointer =
          currentPage
              .path("repository")
              .path("ref")
              .path("target")
              .path("history")
              .path("pageInfo")
              .path("endCursor")
              .asText();
      hasNextPage =
          currentPage
              .path("repository")
              .path("ref")
              .path("target")
              .path("history")
              .path("pageInfo")
              .path("hasNextPage")
              .asBoolean();
      JsonNode commits =
          currentPage.path("repository").path("ref").path("target").path("history").path("edges");
      for (JsonNode node : commits) {
        DownloadedCommit newCommit =
            jacksonObjectMapper.treeToValue(node.get("node"), DownloadedCommit.class);
        newCommit.setRequest(downloadRequest);
        downloadedCommits.add(newCommit);
      }

    } while (hasNextPage);
    downloadedCommitRepository.saveAll(downloadedCommits);
  }
}