GithubGraphQLController.java
package edu.ucsb.cs156.frontiers.controllers;
import com.opencsv.bean.StatefulBeanToCsv;
import com.opencsv.exceptions.CsvFieldAssignmentException;
import edu.ucsb.cs156.frontiers.entities.Branch;
import edu.ucsb.cs156.frontiers.entities.BranchId;
import edu.ucsb.cs156.frontiers.entities.Course;
import edu.ucsb.cs156.frontiers.entities.Job;
import edu.ucsb.cs156.frontiers.errors.EntityNotFoundException;
import edu.ucsb.cs156.frontiers.jobs.LoadCommitHistoryJob;
import edu.ucsb.cs156.frontiers.models.CommitDto;
import edu.ucsb.cs156.frontiers.repositories.BranchRepository;
import edu.ucsb.cs156.frontiers.repositories.CommitRepository;
import edu.ucsb.cs156.frontiers.repositories.CourseRepository;
import edu.ucsb.cs156.frontiers.services.GithubGraphQLService;
import edu.ucsb.cs156.frontiers.services.GithubGraphQLService.ValidationStatus;
import edu.ucsb.cs156.frontiers.services.jobs.JobService;
import edu.ucsb.cs156.frontiers.utilities.StatefulBeanToCsvBuilderFactory;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.io.OutputStreamWriter;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@Tag(name = "GithubGraphQL")
@RequestMapping("/api/github/graphql/")
@RestController
@Slf4j
public class GithubGraphQLController extends ApiController {
private final GithubGraphQLService githubGraphQLService;
private final CourseRepository courseRepository;
private final JobService jobService;
private final CommitRepository commitRepository;
private final StatefulBeanToCsvBuilderFactory statefulBeanToCsvBuilderFactory;
private final BranchRepository branchRepository;
public GithubGraphQLController(
@Autowired GithubGraphQLService gitHubGraphQLService,
@Autowired CourseRepository courseRepository,
JobService jobService,
CommitRepository commitRepository,
StatefulBeanToCsvBuilderFactory statefulBeanToCsvBuilderFactory,
BranchRepository branchRepository) {
this.githubGraphQLService = gitHubGraphQLService;
this.courseRepository = courseRepository;
this.jobService = jobService;
this.statefulBeanToCsvBuilderFactory = statefulBeanToCsvBuilderFactory;
this.commitRepository = commitRepository;
this.branchRepository = branchRepository;
}
/**
* Return default branch name for a given repository.
*
* @param courseId the id of the course whose installation is being used for credentails
* @param owner the owner of the repository
* @param repo the name of the repository
* @return the default branch name
*/
@Operation(summary = "Get default branch name")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@GetMapping("defaultBranchName")
public String getDefaultBranchName(
@Parameter Long courseId, @Parameter String owner, @Parameter String repo) throws Exception {
log.info(
"getDefaultBranchName called with courseId: {}, owner: {}, repo: {}",
courseId,
owner,
repo);
Course course =
courseRepository
.findById(courseId)
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));
log.info("Found course: {}", course);
log.info("Current user is authorized to access course: {}", course.getId());
String result = this.githubGraphQLService.getDefaultBranchName(course, owner, repo);
log.info("Result from getDefaultBranchName: {}", result);
return result;
}
/**
* Return default branch name for a given repository.
*
* @param courseId the id of the course whose installation is being used for credentails
* @param owner the owner of the repository
* @param repo the name of the repository
* @return the default branch name
*/
@Operation(summary = "Get commits")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@GetMapping("commits")
public String getCommits(
@Parameter Long courseId,
@Parameter String owner,
@Parameter String repo,
@Parameter String branch,
@Parameter Integer first,
@RequestParam(name = "after", required = false) @Parameter String after)
throws Exception {
log.info(
"getCommits called with courseId: {}, owner: {}, repo: {}, branch: {}, first: {}, after: {} ",
courseId,
owner,
repo,
branch,
first,
after);
Course course =
courseRepository
.findById(courseId)
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));
log.info("Found course: {}", course);
log.info("Current user is authorized to access course: {}", course.getId());
String result = this.githubGraphQLService.getCommits(course, owner, repo, branch, first, after);
log.info("Result from getCommits: {}", result);
return result;
}
/**
* Returns a job to load the commit data of a number of branches
*
* @param courseId the id of the course whose installation is being used for credentials
* @param branches the list of branches to load
* @return the job identifier
*/
@Operation(summary = "Get commits", description = "Loads commit history for the given branches")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@PostMapping("history")
public Job loadCommitHistory(
@Parameter Long courseId, @Valid @RequestBody List<BranchId> branches) throws Exception {
log.debug(
"Commit History loader called with courseId {} for the following branches: {}",
courseId,
branches);
Course course =
courseRepository
.findById(courseId)
.orElseThrow(() -> new EntityNotFoundException(Course.class, courseId));
if (branches.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No branches specified");
}
Map<BranchId, ValidationStatus> validBranches =
githubGraphQLService.assertBranchesExist(course, branches);
List<Entry<BranchId, ValidationStatus>> invalidBranches =
validBranches.entrySet().stream()
.filter(entry -> entry.getValue() != ValidationStatus.EXISTS)
.toList();
if (!invalidBranches.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid branches specified");
}
LoadCommitHistoryJob job =
LoadCommitHistoryJob.builder()
.course(course)
.branches(branches)
.githubService(githubGraphQLService)
.build();
return jobService.runAsJob(job);
}
@Operation(
summary = "Get commits as a CSV",
description = "Returns preloaded commit history for the given branches as a CSV")
@PreAuthorize("@CourseSecurity.hasManagePermissions(#root, #courseId)")
@PostMapping(value = "csv", produces = "text/csv")
public ResponseEntity<StreamingResponseBody> getCommitsCsv(
@Parameter Long courseId,
@Parameter Instant start,
@Parameter Instant end,
@Parameter Boolean skipMergeCommits,
@Valid @RequestBody List<BranchId> branches)
throws Exception {
List<Branch> selectedBranches = branchRepository.findByIdIn(branches);
if (branches.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No branches specified");
}
if (start.isAfter(end)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Start time must be before end time.");
}
if (selectedBranches.size() != branches.size()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"One or more branches not found; Please load commit history for those branches first.");
}
if (selectedBranches.stream().anyMatch(branch -> branch.getRetrievedTime().isBefore(end))) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"One or more branches have not been updated since the requested end time; Please load commit history for those branches first.");
}
StreamingResponseBody stream =
(outputStream) -> {
try (var writer = new OutputStreamWriter(outputStream)) {
StatefulBeanToCsv<CommitDto> csvWriter = statefulBeanToCsvBuilderFactory.build(writer);
List<CommitDto> commits;
if (skipMergeCommits) {
commits =
commitRepository.findByBranchIdInAndCommitTimeBetweenAndIsMergeCommitEquals(
branches, start, end, false);
} else {
commits = commitRepository.findByBranchIdInAndCommitTimeBetween(branches, start, end);
}
try {
csvWriter.write(commits);
} catch (CsvFieldAssignmentException ignored) {
writer.write("Error writing CSV file");
}
}
};
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType("text/csv; charset=UTF-8"))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=commit_history.csv")
.header(HttpHeaders.CONTENT_TYPE, "text/csv; charset=UTF-8")
.header(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION)
.body(stream);
}
}