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.Branch;
import edu.ucsb.cs156.frontiers.entities.BranchId;
import edu.ucsb.cs156.frontiers.entities.Commit;
import edu.ucsb.cs156.frontiers.entities.Course;
import edu.ucsb.cs156.frontiers.errors.NoLinkedOrganizationException;
import edu.ucsb.cs156.frontiers.repositories.BranchRepository;
import edu.ucsb.cs156.frontiers.repositories.CommitRepository;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.graphql.GraphQlResponse;
import org.springframework.graphql.client.HttpSyncGraphQlClient;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
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 DateTimeProvider dateTimeProvider;
private final ObjectMapper jacksonObjectMapper;
private final CommitRepository commitRepository;
private final BranchRepository branchRepository;
private final RestClient client;
public GithubGraphQLService(
RestClient.Builder builder,
JwtService jwtService,
DateTimeProvider dateTimeProvider,
ObjectMapper jacksonObjectMapper,
CommitRepository commitRepository,
BranchRepository branchRepository) {
this.jwtService = jwtService;
this.graphQlClient =
HttpSyncGraphQlClient.builder(builder.baseUrl(githubBaseUrl).build())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build();
this.dateTimeProvider = dateTimeProvider;
this.jacksonObjectMapper = jacksonObjectMapper;
this.commitRepository = commitRepository;
this.branchRepository = branchRepository;
this.client = builder.baseUrl("https://api.github.com/").build();
}
/**
* 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);
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 {
log.info(
"getCommits called with course.getId(): {} owner: {}, repo: {}, branch: {}, first: {}, after: {}",
course.getId(),
owner,
repo,
branch,
first,
after);
String githubToken = jwtService.getInstallationToken(course);
String query =
"""
query GetBranchCommits($owner: String!, $repo: String!, $branch: String!, $first: Int!, $after: String) {
repository(owner: $owner, name: $repo) {
ref(qualifiedName: $branch) {
target {
... on Commit {
history(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
oid
url
messageHeadline
committedDate
author {
name
email
user {
login
}
}
parents {
totalCount
}
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", first)
.variable("after", after)
.executeSync();
Map<String, Object> data = response.getData();
String jsonData = jacksonObjectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(data);
return jsonData;
}
/**
* Loads the commit history for a specified repository branch. The method retrieves information
* about the branch from the database, determines if the branch information is up to date, and
* updates the commit history if necessary by fetching data from GitHub. If the branch does not
* exist in the database, it is created.
*
* @param course the course to be authenticated against
* @param branch the identifier of the branch to load commit history for
* @return the {@code Branch} object representing the branch with its latest commit history
* information
* @throws Exception if an error occurs while loading or updating the commit history
*/
@Transactional(propagation = Propagation.NESTED)
public Branch loadCommitHistory(Course course, BranchId branch) throws Exception {
Instant retrievedTime = Instant.from(dateTimeProvider.getNow().get());
HashMap<String, Commit> existingCommits = new HashMap<>(4000);
Branch selectedBranch;
Optional<Branch> existingBranch = branchRepository.findById(branch);
if (existingBranch.isPresent()) {
selectedBranch = existingBranch.get();
String currentHead = getMostRecentCommitSha(course, branch);
log.info("Branch {} already exists in database", branch);
if (commitRepository.existsByBranchAndSha(selectedBranch, currentHead)) {
log.info("Branch {} already exists in database and is up to date", branch);
selectedBranch.setRetrievedTime(retrievedTime);
branchRepository.save(selectedBranch);
return selectedBranch;
} else {
log.info("Branch {} already exists in database but is out of date, updating", branch);
commitRepository
.streamByBranch(selectedBranch)
.forEach(commit -> existingCommits.put(commit.getSha(), commit));
}
} else {
selectedBranch = Branch.builder().id(branch).build();
log.info("Branch {} does not exist in database, creating new branch", branch);
}
selectedBranch.setRetrievedTime(retrievedTime);
branchRepository.save(selectedBranch);
String pointer = null;
ArrayList<Commit> commitsToBeSaved = new ArrayList<>(4000);
boolean hasNextPage;
do {
JsonNode currentPage =
jacksonObjectMapper.readTree(
getCommits(course, branch.org(), branch.repo(), branch.branchName(), 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) {
String sha = node.get("node").get("oid").asText();
if (existingCommits.containsKey(sha)) {
continue;
} else {
Commit commit = jacksonObjectMapper.treeToValue(node.get("node"), Commit.class);
commit.setBranch(selectedBranch);
commitsToBeSaved.add(commit);
}
}
} while (hasNextPage);
commitRepository.saveAll(commitsToBeSaved);
return selectedBranch;
}
public String getMostRecentCommitSha(Course course, BranchId branch) throws Exception {
String token = jwtService.getInstallationToken(course);
ResponseEntity<JsonNode> response =
client
.get()
.uri(
"/repos/" + branch.org() + "/" + branch.repo() + "/branches/" + branch.branchName())
.header("Authorization", "Bearer " + token)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.retrieve()
.toEntity(JsonNode.class);
String commitSha = response.getBody().path("commit").path("sha").asText();
return commitSha;
}
public enum ValidationStatus {
EXISTS,
BRANCH_DOES_NOT_EXIST,
REPOSITORY_DOES_NOT_EXIST,
}
// language=GraphQL
final String individualBranchQuery =
"""
repo_%d: repository(owner: "%s", name: "%s") {
ref(qualifiedName: "refs/heads/%s") {
target {
... on Commit {
oid
}
}
}
}
""";
final String completeQueryWrap = """
query CompleteQuery {
%s
}
""";
public Map<BranchId, ValidationStatus> assertBranchesExist(Course course, List<BranchId> branches)
throws Exception {
StringBuilder query = new StringBuilder();
Map<BranchId, ValidationStatus> result = new HashMap<>();
for (int i = 0; i < branches.size(); i++) {
query.append(
String.format(
individualBranchQuery,
i,
branches.get(i).org(),
branches.get(i).repo(),
branches.get(i).branchName()));
}
String finalizedQuery = String.format(completeQueryWrap, query);
String githubToken = jwtService.getInstallationToken(course);
JsonNode response =
graphQlClient
.mutate()
.header("Authorization", "Bearer " + githubToken)
.build()
.document(finalizedQuery)
.executeSync()
.toEntity(JsonNode.class);
for (int i = 0; i < branches.size(); i++) {
JsonNode branchNode = response.path("repo_" + i);
if (branchNode.isNull()) {
result.put(branches.get(i), ValidationStatus.REPOSITORY_DOES_NOT_EXIST);
continue;
}
if (branchNode.path("ref").isNull()) {
result.put(branches.get(i), ValidationStatus.BRANCH_DOES_NOT_EXIST);
continue;
}
result.put(branches.get(i), ValidationStatus.EXISTS);
}
return result;
}
}