| 1 | package edu.ucsb.cs.scaffold.services; | |
| 2 | ||
| 3 | import java.nio.charset.StandardCharsets; | |
| 4 | import java.util.Base64; | |
| 5 | import java.util.List; | |
| 6 | import java.util.Map; | |
| 7 | import org.springframework.beans.factory.annotation.Value; | |
| 8 | import org.springframework.core.ParameterizedTypeReference; | |
| 9 | import org.springframework.http.HttpEntity; | |
| 10 | import org.springframework.http.HttpHeaders; | |
| 11 | import org.springframework.http.HttpMethod; | |
| 12 | import org.springframework.http.ResponseEntity; | |
| 13 | import org.springframework.stereotype.Service; | |
| 14 | import org.springframework.web.client.RestTemplate; | |
| 15 | import org.springframework.web.util.UriComponentsBuilder; | |
| 16 | ||
| 17 | /** | |
| 18 | * Thin wrapper around the GitHub REST API, authenticated per call with a user's personal access | |
| 19 | * token. Callers handle GitHub's HTTP errors (401/403 = bad or unapproved token, 404 = missing repo | |
| 20 | * or path), which RestTemplate surfaces as HttpClientErrorException subclasses. | |
| 21 | */ | |
| 22 | @Service | |
| 23 | public class GithubService { | |
| 24 | ||
| 25 | /** One entry of a directory listing: {@code type} is {@code "dir"}, {@code "file"}, etc. */ | |
| 26 | public record DirectoryEntry(String name, String type) {} | |
| 27 | ||
| 28 | private final RestTemplate restTemplate; | |
| 29 | private final String githubApiBase; | |
| 30 | private final ApiRetryHelper retryHelper; | |
| 31 | ||
| 32 | public GithubService( | |
| 33 | RestTemplate restTemplate, | |
| 34 | @Value("${github.api.base:https://api.github.com}") String githubApiBase, | |
| 35 | @Value("${GITHUB_SERVICE_RETRY_INITIAL_SLEEP_SECONDS:8}") long retryInitialSleepSeconds, | |
| 36 | @Value("${GITHUB_SERVICE_RETRY_MAX:3}") int retryMax, | |
| 37 | @Value("${GITHUB_SERVICE_RATE_LIMIT_SLEEP_INITIAL_MS:1000}") long rateLimitSleepInitialMs) { | |
| 38 | this.restTemplate = restTemplate; | |
| 39 | this.githubApiBase = githubApiBase; | |
| 40 | this.retryHelper = | |
| 41 | new ApiRetryHelper( | |
| 42 | "GitHub", | |
| 43 | "GITHUB_SERVICE_RATE_LIMIT_SLEEP_INITIAL_MS", | |
| 44 | retryInitialSleepSeconds, | |
| 45 | retryMax, | |
| 46 | rateLimitSleepInitialMs); | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * Lists the contents of a directory in the given repository, using the <a | |
| 51 | * href="https://docs.github.com/en/rest/repos/contents">Contents API</a>. | |
| 52 | * | |
| 53 | * @param repoName the repository in {@code owner/repo} form | |
| 54 | * @param path the directory path within the repository, e.g. {@code questions/foo} | |
| 55 | * @param token the user's PAT (plaintext) | |
| 56 | * @return the entries, in the order GitHub returns them (alphabetical) | |
| 57 | */ | |
| 58 | public List<DirectoryEntry> listDirectory(String repoName, String path, String token) { | |
| 59 | String url = contentsUrl(repoName, path); | |
| 60 | ResponseEntity<List<Map<String, Object>>> response = | |
| 61 | retryHelper.execute( | |
| 62 | "GET " + url, | |
| 63 | () -> | |
| 64 |
1
1. lambda$listDirectory$0 : replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::lambda$listDirectory$0 → KILLED |
restTemplate.exchange( |
| 65 | url, | |
| 66 | HttpMethod.GET, | |
| 67 | authenticatedEntity(token), | |
| 68 | new ParameterizedTypeReference<>() {})); | |
| 69 | ||
| 70 | List<Map<String, Object>> items = response.getBody(); | |
| 71 |
1
1. listDirectory : negated conditional → KILLED |
if (items == null) { |
| 72 | return List.of(); | |
| 73 | } | |
| 74 |
1
1. listDirectory : replaced return value with Collections.emptyList for edu/ucsb/cs/scaffold/services/GithubService::listDirectory → KILLED |
return items.stream() |
| 75 |
1
1. lambda$listDirectory$1 : replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::lambda$listDirectory$1 → KILLED |
.map(item -> new DirectoryEntry((String) item.get("name"), (String) item.get("type"))) |
| 76 | .toList(); | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Lists the names of the subdirectories of {@code path} in the given repository. Files at that | |
| 81 | * path are ignored. | |
| 82 | */ | |
| 83 | public List<String> listSubdirectories(String repoName, String path, String token) { | |
| 84 |
1
1. listSubdirectories : replaced return value with Collections.emptyList for edu/ucsb/cs/scaffold/services/GithubService::listSubdirectories → KILLED |
return listDirectory(repoName, path, token).stream() |
| 85 |
2
1. lambda$listSubdirectories$2 : replaced boolean return with true for edu/ucsb/cs/scaffold/services/GithubService::lambda$listSubdirectories$2 → KILLED 2. lambda$listSubdirectories$2 : replaced boolean return with false for edu/ucsb/cs/scaffold/services/GithubService::lambda$listSubdirectories$2 → KILLED |
.filter(entry -> "dir".equals(entry.type())) |
| 86 | .map(DirectoryEntry::name) | |
| 87 | .toList(); | |
| 88 | } | |
| 89 | ||
| 90 | /** | |
| 91 | * Fetches the content of a file in the given repository as a UTF-8 string, using the Contents API | |
| 92 | * (which returns file bodies base64-encoded). | |
| 93 | * | |
| 94 | * @param repoName the repository in {@code owner/repo} form | |
| 95 | * @param path the file path within the repository, e.g. {@code questions/foo/info.json} | |
| 96 | * @param token the user's PAT (plaintext) | |
| 97 | */ | |
| 98 | public String getFileContent(String repoName, String path, String token) { | |
| 99 | String url = contentsUrl(repoName, path); | |
| 100 | ResponseEntity<Map<String, Object>> response = | |
| 101 | retryHelper.execute( | |
| 102 | "GET " + url, | |
| 103 | () -> | |
| 104 |
1
1. lambda$getFileContent$3 : replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::lambda$getFileContent$3 → KILLED |
restTemplate.exchange( |
| 105 | url, | |
| 106 | HttpMethod.GET, | |
| 107 | authenticatedEntity(token), | |
| 108 | new ParameterizedTypeReference<>() {})); | |
| 109 | ||
| 110 | Map<String, Object> body = response.getBody(); | |
| 111 |
2
1. getFileContent : negated conditional → KILLED 2. getFileContent : negated conditional → KILLED |
if (body == null || body.get("content") == null) { |
| 112 | return ""; | |
| 113 | } | |
| 114 | // GitHub base64-encodes file content with embedded newlines; the MIME decoder accepts them. | |
| 115 | byte[] decoded = Base64.getMimeDecoder().decode((String) body.get("content")); | |
| 116 |
1
1. getFileContent : replaced return value with "" for edu/ucsb/cs/scaffold/services/GithubService::getFileContent → KILLED |
return new String(decoded, StandardCharsets.UTF_8); |
| 117 | } | |
| 118 | ||
| 119 | /** | |
| 120 | * Reports whether the token has push (read/write) access to the repository, from the {@code | |
| 121 | * permissions} block of <a href="https://docs.github.com/en/rest/repos/repos">GET | |
| 122 | * /repos/{owner}/{repo}</a>. A successful call already proves read access; {@code push=true} | |
| 123 | * additionally proves write access — nothing is written to check this. | |
| 124 | * | |
| 125 | * @param repoName the repository in {@code owner/repo} form | |
| 126 | * @param token the user's PAT (plaintext) | |
| 127 | * @throws org.springframework.web.client.HttpClientErrorException when the token cannot read the | |
| 128 | * repo at all (GitHub answers 404 for repos a token cannot see, or 401 for a bad token) | |
| 129 | */ | |
| 130 | public boolean hasWriteAccess(String repoName, String token) { | |
| 131 | String url = repoUrl(repoName); | |
| 132 | ResponseEntity<Map<String, Object>> response = | |
| 133 | retryHelper.execute( | |
| 134 | "GET " + url, | |
| 135 | () -> | |
| 136 |
1
1. lambda$hasWriteAccess$4 : replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::lambda$hasWriteAccess$4 → KILLED |
restTemplate.exchange( |
| 137 | url, | |
| 138 | HttpMethod.GET, | |
| 139 | authenticatedEntity(token), | |
| 140 | new ParameterizedTypeReference<>() {})); | |
| 141 | ||
| 142 | Map<String, Object> body = response.getBody(); | |
| 143 |
2
1. hasWriteAccess : negated conditional → KILLED 2. hasWriteAccess : negated conditional → KILLED |
if (body == null || !(body.get("permissions") instanceof Map<?, ?> permissions)) { |
| 144 |
1
1. hasWriteAccess : replaced boolean return with true for edu/ucsb/cs/scaffold/services/GithubService::hasWriteAccess → KILLED |
return false; |
| 145 | } | |
| 146 |
2
1. hasWriteAccess : replaced boolean return with true for edu/ucsb/cs/scaffold/services/GithubService::hasWriteAccess → KILLED 2. hasWriteAccess : replaced boolean return with false for edu/ucsb/cs/scaffold/services/GithubService::hasWriteAccess → KILLED |
return Boolean.TRUE.equals(permissions.get("push")); |
| 147 | } | |
| 148 | ||
| 149 | private String repoUrl(String repoName) { | |
| 150 |
1
1. repoUrl : replaced return value with "" for edu/ucsb/cs/scaffold/services/GithubService::repoUrl → KILLED |
return UriComponentsBuilder.fromUriString(githubApiBase) |
| 151 | .pathSegment("repos") | |
| 152 | .path("/" + repoName) | |
| 153 | .toUriString(); | |
| 154 | } | |
| 155 | ||
| 156 | private String contentsUrl(String repoName, String path) { | |
| 157 |
1
1. contentsUrl : replaced return value with "" for edu/ucsb/cs/scaffold/services/GithubService::contentsUrl → KILLED |
return UriComponentsBuilder.fromUriString(githubApiBase) |
| 158 | .pathSegment("repos") | |
| 159 | .path("/" + repoName) | |
| 160 | .pathSegment("contents") | |
| 161 | .path("/" + path) | |
| 162 | .toUriString(); | |
| 163 | } | |
| 164 | ||
| 165 | private HttpEntity<Void> authenticatedEntity(String token) { | |
| 166 | HttpHeaders headers = new HttpHeaders(); | |
| 167 |
1
1. authenticatedEntity : removed call to org/springframework/http/HttpHeaders::set → KILLED |
headers.set("Authorization", "Bearer " + token); |
| 168 |
1
1. authenticatedEntity : removed call to org/springframework/http/HttpHeaders::set → KILLED |
headers.set("Accept", "application/vnd.github+json"); |
| 169 |
1
1. authenticatedEntity : removed call to org/springframework/http/HttpHeaders::set → KILLED |
headers.set("X-GitHub-Api-Version", "2022-11-28"); |
| 170 |
1
1. authenticatedEntity : replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::authenticatedEntity → KILLED |
return new HttpEntity<>(headers); |
| 171 | } | |
| 172 | } | |
Mutations | ||
| 64 |
1.1 |
|
| 71 |
1.1 |
|
| 74 |
1.1 |
|
| 75 |
1.1 |
|
| 84 |
1.1 |
|
| 85 |
1.1 2.2 |
|
| 104 |
1.1 |
|
| 111 |
1.1 2.2 |
|
| 116 |
1.1 |
|
| 136 |
1.1 |
|
| 143 |
1.1 2.2 |
|
| 144 |
1.1 |
|
| 146 |
1.1 2.2 |
|
| 150 |
1.1 |
|
| 157 |
1.1 |
|
| 167 |
1.1 |
|
| 168 |
1.1 |
|
| 169 |
1.1 |
|
| 170 |
1.1 |