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;
}
}