GithubTeamService.java

package edu.ucsb.cs156.frontiers.services;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
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.Team;
import edu.ucsb.cs156.frontiers.enums.TeamStatus;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.web.client.RestTemplateBuilder;
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.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;

@Slf4j
@Service
public class GithubTeamService {

  public record GithubTeamInfo(Integer id, String name, String slug) {
    public GithubTeamInfo(Integer id, String name) {
      this(id, name, null);
    }
  }

  public record GithubTeamMemberInfo(String login) {}

  private final JwtService jwtService;
  private final ObjectMapper objectMapper;
  private final RestTemplate restTemplate;

  public GithubTeamService(
      JwtService jwtService, ObjectMapper objectMapper, RestTemplateBuilder builder) {
    this.jwtService = jwtService;
    this.objectMapper = objectMapper;
    this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    this.restTemplate = builder.build();
  }

  /**
   * Creates a team on GitHub if it doesn't exist, or returns the existing team ID.
   *
   * @param team The team to create
   * @param course The course containing the organization
   * @return The GitHub team ID
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public Integer createOrGetTeamId(Team team, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    GithubTeamInfo teamInfo = createOrGetTeamInfo(team, course);
    return teamInfo == null ? null : teamInfo.id();
  }

  /**
   * Creates a team on GitHub if it doesn't exist, or returns the existing team info.
   *
   * @param team The team to create
   * @param course The course containing the organization
   * @return The GitHub team info
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public GithubTeamInfo createOrGetTeamInfo(Team team, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    GithubTeamInfo existingTeamInfo = null;
    if (team.getGithubTeamSlug() != null && !team.getGithubTeamSlug().isBlank()) {
      existingTeamInfo = getTeamInfo(team.getGithubTeamSlug(), course);
    }
    if (existingTeamInfo == null) {
      existingTeamInfo = getTeamInfoByName(team.getName(), course);
    }
    if (existingTeamInfo != null) {
      return existingTeamInfo;
    }

    // Create the team if it doesn't exist
    return createTeamInfo(team.getName(), course);
  }

  /**
   * Get the org id, given the org name.
   *
   * <p>Note: in the future, it would be better to cache this value in the Course row in the
   * database at the time the Github App is linked to the org, since it doesn't change.
   *
   * @param orgName
   * @param course
   * @return
   * @throws JsonProcessingException
   * @throws NoSuchAlgorithmException
   * @throws InvalidKeySpecException
   */
  public Integer getOrgId(String orgName, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    String endpoint = "https://api.github.com/orgs/" + orgName;
    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");
    HttpEntity<String> entity = new HttpEntity<>(headers);

    ResponseEntity<String> response =
        restTemplate.exchange(endpoint, HttpMethod.GET, entity, String.class);
    JsonNode responseJson = objectMapper.readTree(response.getBody());
    return responseJson.get("id").asInt();
  }

  /**
   * Gets the team ID for a team slug, returns null if team doesn't exist.
   *
   * @param teamSlug The slug of the team
   * @param course The course containing the organization
   * @return The GitHub team ID or null if not found
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public Integer getTeamId(String teamSlug, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    GithubTeamInfo teamInfo = getTeamInfo(teamSlug, course);
    return teamInfo == null ? null : teamInfo.id();
  }

  /**
   * Gets the team info for a team slug, returns null if team doesn't exist.
   *
   * @param teamSlug The slug of the team
   * @param course The course containing the organization
   * @return The GitHub team info or null if not found
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public GithubTeamInfo getTeamInfo(String teamSlug, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    String endpoint = "https://api.github.com/orgs/" + course.getOrgName() + "/teams/" + teamSlug;
    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");
    HttpEntity<String> entity = new HttpEntity<>(headers);

    try {
      ResponseEntity<String> response =
          restTemplate.exchange(endpoint, HttpMethod.GET, entity, String.class);
      return objectMapper.readValue(response.getBody(), GithubTeamInfo.class);
    } catch (HttpClientErrorException e) {
      if (e.getStatusCode().value() == 404) {
        return null; // Team doesn't exist
      }
      throw e;
    }
  }

  /**
   * Gets the team info for a team ID, returns null if team doesn't exist.
   *
   * @param orgId The GitHub organization ID
   * @param teamId The GitHub team ID
   * @param course The course containing the organization
   * @return The GitHub team info or null if not found
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public GithubTeamInfo getTeamInfoById(Integer orgId, Integer teamId, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    String endpoint = "https://api.github.com/organizations/" + orgId + "/team/" + teamId;
    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");
    HttpEntity<String> entity = new HttpEntity<>(headers);

    try {
      ResponseEntity<String> response =
          restTemplate.exchange(endpoint, HttpMethod.GET, entity, String.class);
      return objectMapper.readValue(response.getBody(), GithubTeamInfo.class);
    } catch (HttpClientErrorException e) {
      if (e.getStatusCode().value() == 404) {
        return null;
      }
      throw e;
    }
  }

  /**
   * Finds team info by display name by searching the organization's teams.
   *
   * @param teamName The display name of the team
   * @param course The course containing the organization
   * @return The GitHub team info or null if not found
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public GithubTeamInfo getTeamInfoByName(String teamName, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    for (GithubTeamInfo teamInfo : getAllTeams(course)) {
      if (teamName.equals(teamInfo.name())) {
        return teamInfo;
      }
    }
    return null;
  }

  /**
   * Creates a new team on GitHub.
   *
   * @param teamName The name of the team to create
   * @param course The course containing the organization
   * @return The GitHub team ID
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public Integer createTeam(String teamName, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    GithubTeamInfo teamInfo = createTeamInfo(teamName, course);
    return teamInfo == null ? null : teamInfo.id();
  }

  /**
   * Creates a new team on GitHub.
   *
   * @param teamName The name of the team to create
   * @param course The course containing the organization
   * @return The GitHub team info
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public GithubTeamInfo createTeamInfo(String teamName, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    String endpoint = "https://api.github.com/orgs/" + course.getOrgName() + "/teams";
    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");

    Map<String, Object> body = new HashMap<>();
    body.put("name", teamName);
    body.put("privacy", "closed"); // Teams are private by default
    String bodyAsJson = objectMapper.writeValueAsString(body);
    HttpEntity<String> entity = new HttpEntity<>(bodyAsJson, headers);

    ResponseEntity<String> response =
        restTemplate.exchange(endpoint, HttpMethod.POST, entity, String.class);
    GithubTeamInfo teamInfo = objectMapper.readValue(response.getBody(), GithubTeamInfo.class);
    log.info(
        "Created team '{}' with ID {} in organization {}",
        teamName,
        teamInfo.id(),
        course.getOrgName());
    return teamInfo;
  }

  /**
   * Deletes a team on GitHub.
   *
   * @param orgId The ID of the organization
   * @param githubTeamId The ID of the team to delete
   * @param course The course containing the organization
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public void deleteGithubTeam(Integer orgId, Integer teamId, Course course)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    String endpoint = "https://api.github.com/organizations/" + orgId + "/team/" + teamId;
    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");
    HttpEntity<String> entity = new HttpEntity<>(headers);

    restTemplate.exchange(endpoint, HttpMethod.DELETE, entity, String.class);
    log.info("Deleted team with ID {} in organization {}", teamId, course.getOrgName());
  }

  /**
   * Gets the current team membership status for a user.
   *
   * @param githubLogin The GitHub login of the user
   * @param teamId The GitHub team ID
   * @param course The course containing the organization
   * @param orgId The GitHub organization ID
   * @return The team status of the user
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public TeamStatus getTeamMembershipStatus(
      String githubLogin, Integer teamId, Course course, Integer orgId)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    if (githubLogin == null) {
      return TeamStatus.NO_GITHUB_ID;
    }

    String endpoint =
        "https://api.github.com/organizations/"
            + orgId
            + "/team/"
            + teamId
            + "/memberships/"
            + githubLogin;
    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");
    HttpEntity<String> entity = new HttpEntity<>(headers);

    try {
      ResponseEntity<String> response =
          restTemplate.exchange(endpoint, HttpMethod.GET, entity, String.class);
      JsonNode responseJson = objectMapper.readTree(response.getBody());
      String role = responseJson.get("role").asText();
      return "maintainer".equalsIgnoreCase(role)
          ? TeamStatus.TEAM_MAINTAINER
          : TeamStatus.TEAM_MEMBER;
    } catch (HttpClientErrorException e) {
      if (e.getStatusCode().value() == 404) {
        return TeamStatus.NOT_ORG_MEMBER; // User is not a member of the team
      }
      throw e;
    }
  }

  /**
   * Adds a member to a GitHub team.
   *
   * @param githubLogin The GitHub login of the user to add
   * @param teamId The GitHub team ID
   * @param role The role to assign ("member" or "maintainer")
   * @param course The course containing the organization
   * @return The resulting team status
   * @throws JsonProcessingException if there is an error processing JSON
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   */
  public TeamStatus addMemberToGithubTeam(
      String githubLogin, Integer teamId, String role, Course course, Integer orgId)
      throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeySpecException {
    String endpoint =
        "https://api.github.com/organizations/"
            + orgId
            + "/team/"
            + teamId
            + "/memberships/"
            + githubLogin;
    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");

    Map<String, Object> body = new HashMap<>();
    body.put("role", role);
    String bodyAsJson = objectMapper.writeValueAsString(body);
    HttpEntity<String> entity = new HttpEntity<>(bodyAsJson, headers);

    ResponseEntity<String> response =
        restTemplate.exchange(endpoint, HttpMethod.PUT, entity, String.class);
    JsonNode responseJson = objectMapper.readTree(response.getBody());
    String resultRole = responseJson.get("role").asText();
    log.info("Added user '{}' to team ID {} with role '{}'", githubLogin, teamId, resultRole);
    return "maintainer".equalsIgnoreCase(resultRole)
        ? TeamStatus.TEAM_MAINTAINER
        : TeamStatus.TEAM_MEMBER;
  }

  /**
   * Removes a member from a GitHub team
   *
   * @param orgId
   * @param githubLogin
   * @param teamId
   * @param course
   * @throws NoSuchAlgorithmException
   * @throws InvalidKeySpecException
   * @throws JsonProcessingException
   */
  public void removeMemberFromGithubTeam(
      Integer orgId, String githubLogin, Integer teamId, Course course)
      throws NoSuchAlgorithmException, InvalidKeySpecException, JsonProcessingException {

    String endpoint =
        "https://api.github.com/organizations/"
            + orgId
            + "/team/"
            + teamId
            + "/memberships/"
            + githubLogin;
    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");
    HttpEntity<String> entity = new HttpEntity<>(headers);

    restTemplate.exchange(endpoint, HttpMethod.DELETE, entity, String.class);
    log.info("Successfully removed member {} from team ID {}", githubLogin, teamId);
  }

  /**
   * Returns all team members for a GitHub team.
   *
   * @param teamSlug The GitHub team slug
   * @param course The course containing the organization
   * @return A map of github login to TeamStatus
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   * @throws JsonProcessingException if there is an error processing JSON
   */
  public Map<String, TeamStatus> getTeamMemberships(String teamSlug, Course course)
      throws NoSuchAlgorithmException, InvalidKeySpecException, JsonProcessingException {
    if (teamSlug == null || teamSlug.isBlank()) {
      throw new IllegalArgumentException("teamSlug must be provided");
    }
    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");
    HttpEntity<String> entity = new HttpEntity<>(headers);

    Map<String, TeamStatus> memberships = new HashMap<>();
    String endpointPrefix =
        "https://api.github.com/orgs/" + course.getOrgName() + "/teams/" + teamSlug + "/members";
    addMembershipsByRole(endpointPrefix, "member", TeamStatus.TEAM_MEMBER, entity, memberships);
    addMembershipsByRole(
        endpointPrefix, "maintainer", TeamStatus.TEAM_MAINTAINER, entity, memberships);

    return memberships;
  }

  private void addMembershipsByRole(
      String endpointPrefix,
      String role,
      TeamStatus status,
      HttpEntity<String> entity,
      Map<String, TeamStatus> memberships)
      throws JsonProcessingException {
    String endpoint = endpointPrefix + "?per_page=100&role=" + role;
    Pattern pattern = Pattern.compile("(?<=<)([\\S]*)(?=>; rel=\"next\")");

    ResponseEntity<String> response =
        restTemplate.exchange(endpoint, HttpMethod.GET, entity, String.class);
    List<String> responseLinks = response.getHeaders().getOrEmpty("link");

    while (!responseLinks.isEmpty() && responseLinks.getFirst().contains("next")) {
      for (GithubTeamMemberInfo member :
          objectMapper.convertValue(
              objectMapper.readTree(response.getBody()),
              new TypeReference<List<GithubTeamMemberInfo>>() {})) {
        memberships.put(member.login(), status);
      }

      Matcher matcher = pattern.matcher(responseLinks.getFirst());
      matcher.find();
      response = restTemplate.exchange(matcher.group(0), HttpMethod.GET, entity, String.class);
      responseLinks = response.getHeaders().getOrEmpty("link");
    }

    for (GithubTeamMemberInfo member :
        objectMapper.convertValue(
            objectMapper.readTree(response.getBody()),
            new TypeReference<List<GithubTeamMemberInfo>>() {})) {
      memberships.put(member.login(), status);
    }
  }

  /**
   * Returns all teams for an organization, following pagination links when present.
   *
   * @param course The course containing the organization
   * @return A list of GitHub teams with id and name
   * @throws NoSuchAlgorithmException if there is an algorithm error
   * @throws InvalidKeySpecException if there is a key specification error
   * @throws JsonProcessingException if there is an error processing JSON
   */
  public List<GithubTeamInfo> getAllTeams(Course course)
      throws NoSuchAlgorithmException, InvalidKeySpecException, JsonProcessingException {
    String endpoint = "https://api.github.com/orgs/" + course.getOrgName() + "/teams?per_page=100";
    Pattern pattern = Pattern.compile("(?<=<)([\\S]*)(?=>; rel=\"next\")");

    HttpHeaders headers = new HttpHeaders();
    String token = jwtService.getInstallationToken(course);
    headers.add("Authorization", "Bearer " + token);
    headers.add("Accept", "application/vnd.github+json");
    headers.add("X-GitHub-Api-Version", "2022-11-28");
    HttpEntity<String> entity = new HttpEntity<>(headers);

    ResponseEntity<String> response =
        restTemplate.exchange(endpoint, HttpMethod.GET, entity, String.class);
    List<String> responseLinks = response.getHeaders().getOrEmpty("link");
    List<GithubTeamInfo> teams = new ArrayList<>();

    while (!responseLinks.isEmpty() && responseLinks.getFirst().contains("next")) {
      teams.addAll(
          objectMapper.convertValue(
              objectMapper.readTree(response.getBody()),
              new TypeReference<List<GithubTeamInfo>>() {}));

      Matcher matcher = pattern.matcher(responseLinks.getFirst());
      matcher.find();
      response = restTemplate.exchange(matcher.group(0), HttpMethod.GET, entity, String.class);
      responseLinks = response.getHeaders().getOrEmpty("link");
    }

    teams.addAll(
        objectMapper.convertValue(
            objectMapper.readTree(response.getBody()),
            new TypeReference<List<GithubTeamInfo>>() {}));

    return teams;
  }
}