GithubService.java

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
Location : lambda$listDirectory$0
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:listDirectory_returns_an_empty_list_when_github_returns_no_body()]
replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::lambda$listDirectory$0 → KILLED

71

1.1
Location : listDirectory
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:listDirectory_returns_an_empty_list_when_github_returns_no_body()]
negated conditional → KILLED

74

1.1
Location : listDirectory
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:returns_only_directory_names_ignoring_files()]
replaced return value with Collections.emptyList for edu/ucsb/cs/scaffold/services/GithubService::listDirectory → KILLED

75

1.1
Location : lambda$listDirectory$1
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:returns_only_directory_names_ignoring_files()]
replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::lambda$listDirectory$1 → KILLED

84

1.1
Location : listSubdirectories
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:returns_only_directory_names_ignoring_files()]
replaced return value with Collections.emptyList for edu/ucsb/cs/scaffold/services/GithubService::listSubdirectories → KILLED

85

1.1
Location : lambda$listSubdirectories$2
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:returns_only_directory_names_ignoring_files()]
replaced boolean return with true for edu/ucsb/cs/scaffold/services/GithubService::lambda$listSubdirectories$2 → KILLED

2.2
Location : lambda$listSubdirectories$2
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:returns_only_directory_names_ignoring_files()]
replaced boolean return with false for edu/ucsb/cs/scaffold/services/GithubService::lambda$listSubdirectories$2 → KILLED

104

1.1
Location : lambda$getFileContent$3
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:getFileContent_returns_empty_string_when_github_returns_no_body()]
replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::lambda$getFileContent$3 → KILLED

111

1.1
Location : getFileContent
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:getFileContent_returns_empty_string_when_the_body_has_no_content_field()]
negated conditional → KILLED

2.2
Location : getFileContent
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:getFileContent_returns_empty_string_when_github_returns_no_body()]
negated conditional → KILLED

116

1.1
Location : getFileContent
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:getFileContent_decodes_the_base64_body_github_returns()]
replaced return value with "" for edu/ucsb/cs/scaffold/services/GithubService::getFileContent → KILLED

136

1.1
Location : lambda$hasWriteAccess$4
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:hasWriteAccess_is_false_when_the_body_is_null()]
replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::lambda$hasWriteAccess$4 → KILLED

143

1.1
Location : hasWriteAccess
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:hasWriteAccess_is_false_when_the_permissions_block_is_missing()]
negated conditional → KILLED

2.2
Location : hasWriteAccess
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:hasWriteAccess_is_false_when_the_body_is_null()]
negated conditional → KILLED

144

1.1
Location : hasWriteAccess
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:hasWriteAccess_is_false_when_the_body_is_null()]
replaced boolean return with true for edu/ucsb/cs/scaffold/services/GithubService::hasWriteAccess → KILLED

146

1.1
Location : hasWriteAccess
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:hasWriteAccess_is_false_when_the_permissions_block_reports_no_push()]
replaced boolean return with true for edu/ucsb/cs/scaffold/services/GithubService::hasWriteAccess → KILLED

2.2
Location : hasWriteAccess
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:hasWriteAccess_is_true_when_the_permissions_block_reports_push()]
replaced boolean return with false for edu/ucsb/cs/scaffold/services/GithubService::hasWriteAccess → KILLED

150

1.1
Location : repoUrl
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:hasWriteAccess_builds_the_repo_url_and_authorization_header_from_its_arguments()]
replaced return value with "" for edu/ucsb/cs/scaffold/services/GithubService::repoUrl → KILLED

157

1.1
Location : contentsUrl
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:builds_the_contents_url_and_authorization_header_from_its_arguments()]
replaced return value with "" for edu/ucsb/cs/scaffold/services/GithubService::contentsUrl → KILLED

167

1.1
Location : authenticatedEntity
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:hasWriteAccess_builds_the_repo_url_and_authorization_header_from_its_arguments()]
removed call to org/springframework/http/HttpHeaders::set → KILLED

168

1.1
Location : authenticatedEntity
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:builds_the_contents_url_and_authorization_header_from_its_arguments()]
removed call to org/springframework/http/HttpHeaders::set → KILLED

169

1.1
Location : authenticatedEntity
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:builds_the_contents_url_and_authorization_header_from_its_arguments()]
removed call to org/springframework/http/HttpHeaders::set → KILLED

170

1.1
Location : authenticatedEntity
Killed by : edu.ucsb.cs.scaffold.services.GithubServiceTests.[engine:junit-jupiter]/[class:edu.ucsb.cs.scaffold.services.GithubServiceTests]/[method:hasWriteAccess_is_false_when_the_body_is_null()]
replaced return value with null for edu/ucsb/cs/scaffold/services/GithubService::authenticatedEntity → KILLED

Active mutators

Tests examined


Report generated by PIT 1.17.0