GithubService.java

package edu.ucsb.cs.scaffold.services;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

/**
 * Thin wrapper around the GitHub REST API, authenticated per call with a user's personal access
 * token. Callers handle GitHub's HTTP errors (401/403 = bad or unapproved token, 404 = missing repo
 * or path), which RestTemplate surfaces as HttpClientErrorException subclasses.
 */
@Service
public class GithubService {

  /** One entry of a directory listing: {@code type} is {@code "dir"}, {@code "file"}, etc. */
  public record DirectoryEntry(String name, String type) {}

  private final RestTemplate restTemplate;
  private final String githubApiBase;
  private final ApiRetryHelper retryHelper;

  public GithubService(
      RestTemplate restTemplate,
      @Value("${github.api.base:https://api.github.com}") String githubApiBase,
      @Value("${GITHUB_SERVICE_RETRY_INITIAL_SLEEP_SECONDS:8}") long retryInitialSleepSeconds,
      @Value("${GITHUB_SERVICE_RETRY_MAX:3}") int retryMax,
      @Value("${GITHUB_SERVICE_RATE_LIMIT_SLEEP_INITIAL_MS:1000}") long rateLimitSleepInitialMs) {
    this.restTemplate = restTemplate;
    this.githubApiBase = githubApiBase;
    this.retryHelper =
        new ApiRetryHelper(
            "GitHub",
            "GITHUB_SERVICE_RATE_LIMIT_SLEEP_INITIAL_MS",
            retryInitialSleepSeconds,
            retryMax,
            rateLimitSleepInitialMs);
  }

  /**
   * Lists the contents of a directory in the given repository, using the <a
   * href="https://docs.github.com/en/rest/repos/contents">Contents API</a>.
   *
   * @param repoName the repository in {@code owner/repo} form
   * @param path the directory path within the repository, e.g. {@code questions/foo}
   * @param token the user's PAT (plaintext)
   * @return the entries, in the order GitHub returns them (alphabetical)
   */
  public List<DirectoryEntry> listDirectory(String repoName, String path, String token) {
    String url = contentsUrl(repoName, path);
    ResponseEntity<List<Map<String, Object>>> response =
        retryHelper.execute(
            "GET " + url,
            () ->
                restTemplate.exchange(
                    url,
                    HttpMethod.GET,
                    authenticatedEntity(token),
                    new ParameterizedTypeReference<>() {}));

    List<Map<String, Object>> items = response.getBody();
    if (items == null) {
      return List.of();
    }
    return items.stream()
        .map(item -> new DirectoryEntry((String) item.get("name"), (String) item.get("type")))
        .toList();
  }

  /**
   * Lists the names of the subdirectories of {@code path} in the given repository. Files at that
   * path are ignored.
   */
  public List<String> listSubdirectories(String repoName, String path, String token) {
    return listDirectory(repoName, path, token).stream()
        .filter(entry -> "dir".equals(entry.type()))
        .map(DirectoryEntry::name)
        .toList();
  }

  /**
   * Fetches the content of a file in the given repository as a UTF-8 string, using the Contents API
   * (which returns file bodies base64-encoded).
   *
   * @param repoName the repository in {@code owner/repo} form
   * @param path the file path within the repository, e.g. {@code questions/foo/info.json}
   * @param token the user's PAT (plaintext)
   */
  public String getFileContent(String repoName, String path, String token) {
    String url = contentsUrl(repoName, path);
    ResponseEntity<Map<String, Object>> response =
        retryHelper.execute(
            "GET " + url,
            () ->
                restTemplate.exchange(
                    url,
                    HttpMethod.GET,
                    authenticatedEntity(token),
                    new ParameterizedTypeReference<>() {}));

    Map<String, Object> body = response.getBody();
    if (body == null || body.get("content") == null) {
      return "";
    }
    // GitHub base64-encodes file content with embedded newlines; the MIME decoder accepts them.
    byte[] decoded = Base64.getMimeDecoder().decode((String) body.get("content"));
    return new String(decoded, StandardCharsets.UTF_8);
  }

  /**
   * Reports whether the token has push (read/write) access to the repository, from the {@code
   * permissions} block of <a href="https://docs.github.com/en/rest/repos/repos">GET
   * /repos/{owner}/{repo}</a>. A successful call already proves read access; {@code push=true}
   * additionally proves write access — nothing is written to check this.
   *
   * @param repoName the repository in {@code owner/repo} form
   * @param token the user's PAT (plaintext)
   * @throws org.springframework.web.client.HttpClientErrorException when the token cannot read the
   *     repo at all (GitHub answers 404 for repos a token cannot see, or 401 for a bad token)
   */
  public boolean hasWriteAccess(String repoName, String token) {
    String url = repoUrl(repoName);
    ResponseEntity<Map<String, Object>> response =
        retryHelper.execute(
            "GET " + url,
            () ->
                restTemplate.exchange(
                    url,
                    HttpMethod.GET,
                    authenticatedEntity(token),
                    new ParameterizedTypeReference<>() {}));

    Map<String, Object> body = response.getBody();
    if (body == null || !(body.get("permissions") instanceof Map<?, ?> permissions)) {
      return false;
    }
    return Boolean.TRUE.equals(permissions.get("push"));
  }

  private String repoUrl(String repoName) {
    return UriComponentsBuilder.fromUriString(githubApiBase)
        .pathSegment("repos")
        .path("/" + repoName)
        .toUriString();
  }

  private String contentsUrl(String repoName, String path) {
    return UriComponentsBuilder.fromUriString(githubApiBase)
        .pathSegment("repos")
        .path("/" + repoName)
        .pathSegment("contents")
        .path("/" + path)
        .toUriString();
  }

  private HttpEntity<Void> authenticatedEntity(String token) {
    HttpHeaders headers = new HttpHeaders();
    headers.set("Authorization", "Bearer " + token);
    headers.set("Accept", "application/vnd.github+json");
    headers.set("X-GitHub-Api-Version", "2022-11-28");
    return new HttpEntity<>(headers);
  }
}