| 1 | package edu.ucsb.cs.scaffold.jobs; | |
| 2 | ||
| 3 | import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | import com.fasterxml.jackson.databind.ObjectMapper; | |
| 5 | import edu.ucsb.cs.scaffold.entity.Course; | |
| 6 | import edu.ucsb.cs.scaffold.entity.PatCredential; | |
| 7 | import edu.ucsb.cs.scaffold.entity.PlAssessment; | |
| 8 | import edu.ucsb.cs.scaffold.entity.PlAssessmentQuestion; | |
| 9 | import edu.ucsb.cs.scaffold.entity.PlAssessmentSet; | |
| 10 | import edu.ucsb.cs.scaffold.entity.PlInstance; | |
| 11 | import edu.ucsb.cs.scaffold.entity.PlQuestion; | |
| 12 | import edu.ucsb.cs.scaffold.entity.PlRepo; | |
| 13 | import edu.ucsb.cs.scaffold.enums.PatPlatform; | |
| 14 | import edu.ucsb.cs.scaffold.errors.EntityNotFoundException; | |
| 15 | import edu.ucsb.cs.scaffold.repository.PatCredentialRepository; | |
| 16 | import edu.ucsb.cs.scaffold.repository.PlAssessmentQuestionRepository; | |
| 17 | import edu.ucsb.cs.scaffold.repository.PlAssessmentRepository; | |
| 18 | import edu.ucsb.cs.scaffold.repository.PlAssessmentSetRepository; | |
| 19 | import edu.ucsb.cs.scaffold.repository.PlInstanceRepository; | |
| 20 | import edu.ucsb.cs.scaffold.repository.PlQuestionRepository; | |
| 21 | import edu.ucsb.cs.scaffold.repository.PlRepoRepository; | |
| 22 | import edu.ucsb.cs.scaffold.repository.PlScaffoldAssessmentRepository; | |
| 23 | import edu.ucsb.cs.scaffold.services.GithubService; | |
| 24 | import edu.ucsb.cs.scaffold.services.GithubService.DirectoryEntry; | |
| 25 | import edu.ucsb.cs.scaffold.services.PatEncryptionService; | |
| 26 | import edu.ucsb.cs.scaffold.services.PrairieLearnService; | |
| 27 | import edu.ucsb.cs156.jobs.services.JobContext; | |
| 28 | import edu.ucsb.cs156.jobs.services.JobContextConsumer; | |
| 29 | import java.util.ArrayList; | |
| 30 | import java.util.LinkedHashMap; | |
| 31 | import java.util.LinkedHashSet; | |
| 32 | import java.util.List; | |
| 33 | import java.util.Map; | |
| 34 | import java.util.Objects; | |
| 35 | import java.util.Optional; | |
| 36 | import java.util.Set; | |
| 37 | import java.util.UUID; | |
| 38 | import lombok.Builder; | |
| 39 | import org.springframework.web.client.HttpClientErrorException; | |
| 40 | ||
| 41 | /** | |
| 42 | * Syncs one course's PrairieLearn state (issue #69), replacing the repo-wide SyncPlRepoJob. Takes a | |
| 43 | * course (rather than a PlRepo id) and uses the launching user's GitHub and PrairieLearn PATs. | |
| 44 | * | |
| 45 | * <p>Before doing any work, the job verifies that the user has both PATs, that the course is | |
| 46 | * associated with a GitHub repo and a PrairieLearn course instance, and that the PATs can actually | |
| 47 | * reach both — read/write access to the repo, and access to the course instance via the | |
| 48 | * PrairieLearn API. Any failure terminates the job with a log message telling the user where to fix | |
| 49 | * it (the /profile page for PATs, the PrairieLearn tab of the course settings for the | |
| 50 | * associations). | |
| 51 | * | |
| 52 | * <p>Unlike the old job, the PlInstance table is not repopulated from the repo's courseInstances | |
| 53 | * directory: the course's instance already exists, and its shortName/longName are just | |
| 54 | * sanity-checked (and corrected) against what PrairieLearn reports. Assessments are synced only for | |
| 55 | * the course's own instance. Questions are still traversed for the whole repo, exactly as before: | |
| 56 | * the {@code questions} directory is walked recursively; a directory containing an {@code | |
| 57 | * info.json} is a question whose questionId is its path relative to {@code questions}; {@code | |
| 58 | * __drafts__} directories are skipped; question directories are not traversed further; stale | |
| 59 | * PlQuestion rows are deleted (cascading to their PlScaffoldAssessments and join rows). | |
| 60 | * | |
| 61 | * <p>Assessment sets (issue #93): the {@code assessmentSets} array of the repo's top-level {@code | |
| 62 | * infoCourse.json} becomes the PlAssessmentSet rows of the repo (assessment sets are global to the | |
| 63 | * whole course, not per-instance), matched by abbreviation. Stale rows are deleted. | |
| 64 | * | |
| 65 | * <p>Assessments: each subdirectory of {@code courseInstances/<instance>/assessments} containing an | |
| 66 | * {@code infoAssessment.json} becomes a PlAssessment; the {@code zones} key is walked recursively | |
| 67 | * and every {@code "id"} entry links the assessment to a PlQuestion of the repo (the | |
| 68 | * PlAssessmentQuestion join table, in zone order, rewritten on change). Stale rows are deleted. | |
| 69 | * | |
| 70 | * <p>Finally the job asks the PrairieLearn API for the instance's assessments and copies the fields | |
| 71 | * from issue #71 (numeric id, number, order, title, and the assessment-set | |
| 72 | * abbreviation/number/heading/color) onto the matching PlAssessment rows, matched by name. | |
| 73 | * | |
| 74 | * <p>If the {@code questions} or per-instance {@code assessments} directory is missing (HTTP 404), | |
| 75 | * that step is skipped entirely — including deletions — because a 404 cannot distinguish "directory | |
| 76 | * removed" from "token cannot see the repo", and mass-deleting rows over a token problem would be | |
| 77 | * wrong. | |
| 78 | */ | |
| 79 | @Builder | |
| 80 | public class SyncCourseWithPlRepoJob implements JobContextConsumer { | |
| 81 | ||
| 82 | static final String COURSE_INSTANCES_PATH = "courseInstances"; | |
| 83 | static final String QUESTIONS_PATH = "questions"; | |
| 84 | static final String ASSESSMENTS_DIRECTORY = "assessments"; | |
| 85 | static final String DRAFTS_DIRECTORY = "__drafts__"; | |
| 86 | static final String INFO_JSON = "info.json"; | |
| 87 | static final String INFO_ASSESSMENT_JSON = "infoAssessment.json"; | |
| 88 | static final String INFO_COURSE_JSON = "infoCourse.json"; | |
| 89 | static final String ASSESSMENT_SETS_KEY = "assessmentSets"; | |
| 90 | ||
| 91 | private static final ObjectMapper MAPPER = new ObjectMapper(); | |
| 92 | ||
| 93 | private long userId; | |
| 94 | private Course course; | |
| 95 | private PatCredentialRepository patCredentialRepository; | |
| 96 | private PatEncryptionService patEncryptionService; | |
| 97 | private PlRepoRepository plRepoRepository; | |
| 98 | private PlInstanceRepository plInstanceRepository; | |
| 99 | private PlQuestionRepository plQuestionRepository; | |
| 100 | private PlScaffoldAssessmentRepository plScaffoldAssessmentRepository; | |
| 101 | private PlAssessmentRepository plAssessmentRepository; | |
| 102 | private PlAssessmentQuestionRepository plAssessmentQuestionRepository; | |
| 103 | private PlAssessmentSetRepository plAssessmentSetRepository; | |
| 104 | private GithubService githubService; | |
| 105 | private PrairieLearnService prairieLearnService; | |
| 106 | ||
| 107 | /** The uuid and title of a question, as read from its info.json. */ | |
| 108 | record QuestionInfo(UUID uuid, String title) {} | |
| 109 | ||
| 110 | @Override | |
| 111 | public String getScopeType() { | |
| 112 |
1
1. getScopeType : replaced return value with "" for edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::getScopeType → KILLED |
return "course"; |
| 113 | } | |
| 114 | ||
| 115 | @Override | |
| 116 | public Long getScopeId() { | |
| 117 |
1
1. getScopeId : replaced Long return value with 0L for edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::getScopeId → KILLED |
return course.getId(); |
| 118 | } | |
| 119 | ||
| 120 | @Override | |
| 121 | public void accept(JobContext ctx) throws Exception { | |
| 122 |
1
1. accept : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 123 | "Syncing course %d (%s) with PrairieLearn" | |
| 124 | .formatted(course.getId(), course.getCourseName())); | |
| 125 | ||
| 126 | // (1) Both PATs must be configured before anything else. | |
| 127 | Optional<PatCredential> githubCredential = | |
| 128 | patCredentialRepository.findByUserIdAndPlatform(userId, PatPlatform.GITHUB); | |
| 129 | Optional<PatCredential> plCredential = | |
| 130 | patCredentialRepository.findByUserIdAndPlatform(userId, PatPlatform.PRAIRIELEARN); | |
| 131 |
2
1. accept : negated conditional → KILLED 2. accept : negated conditional → KILLED |
if (githubCredential.isEmpty() || plCredential.isEmpty()) { |
| 132 | List<String> missing = new ArrayList<>(); | |
| 133 |
1
1. accept : negated conditional → KILLED |
if (githubCredential.isEmpty()) { |
| 134 | missing.add("GitHub PAT"); | |
| 135 | } | |
| 136 |
1
1. accept : negated conditional → KILLED |
if (plCredential.isEmpty()) { |
| 137 | missing.add("PrairieLearn PAT"); | |
| 138 | } | |
| 139 | throw new Exception( | |
| 140 | "Missing %s: set it up on the /profile page before running this job" | |
| 141 | .formatted(String.join(" and ", missing))); | |
| 142 | } | |
| 143 | ||
| 144 | // (2) The course must be associated with a repo and a course instance. | |
| 145 |
2
1. accept : negated conditional → KILLED 2. accept : negated conditional → KILLED |
if (course.getPlRepoId() == null || course.getPlInstanceId() == null) { |
| 146 | List<String> missing = new ArrayList<>(); | |
| 147 |
1
1. accept : negated conditional → KILLED |
if (course.getPlRepoId() == null) { |
| 148 | missing.add("a GitHub repo"); | |
| 149 | } | |
| 150 |
1
1. accept : negated conditional → KILLED |
if (course.getPlInstanceId() == null) { |
| 151 | missing.add("a PrairieLearn course instance"); | |
| 152 | } | |
| 153 | throw new Exception( | |
| 154 | "This course is not associated with %s yet; set that up on the PrairieLearn tab of the course settings page" | |
| 155 | .formatted(String.join(" or ", missing))); | |
| 156 | } | |
| 157 | PlRepo plRepo = | |
| 158 | plRepoRepository | |
| 159 | .findById(course.getPlRepoId()) | |
| 160 |
1
1. lambda$accept$0 : replaced return value with null for edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::lambda$accept$0 → KILLED |
.orElseThrow(() -> new EntityNotFoundException(PlRepo.class, course.getPlRepoId())); |
| 161 | PlInstance plInstance = | |
| 162 | plInstanceRepository | |
| 163 | .findById(course.getPlInstanceId()) | |
| 164 | .orElseThrow( | |
| 165 |
1
1. lambda$accept$1 : replaced return value with null for edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::lambda$accept$1 → KILLED |
() -> new EntityNotFoundException(PlInstance.class, course.getPlInstanceId())); |
| 166 |
1
1. accept : negated conditional → KILLED |
if (plInstance.getNumericId() == null) { |
| 167 | throw new Exception( | |
| 168 | "The course's PrairieLearn instance has no numeric id yet; re-associate it on the" | |
| 169 | + " PrairieLearn tab of the course settings page"); | |
| 170 | } | |
| 171 | ||
| 172 | String githubToken = | |
| 173 | patEncryptionService.decrypt( | |
| 174 | githubCredential.get().getCiphertext(), githubCredential.get().getKeyVersion()); | |
| 175 | String plToken = | |
| 176 | patEncryptionService.decrypt( | |
| 177 | plCredential.get().getCiphertext(), plCredential.get().getKeyVersion()); | |
| 178 | ||
| 179 | // (3) Sanity-check access with both PATs before touching any data. | |
| 180 | boolean canWrite; | |
| 181 | try { | |
| 182 | canWrite = githubService.hasWriteAccess(plRepo.getRepoName(), githubToken); | |
| 183 | } catch (HttpClientErrorException e) { | |
| 184 | throw new Exception( | |
| 185 | "The stored GitHub PAT cannot read repo %s (HTTP %d); check the token on the /profile page and the repo on the PrairieLearn tab" | |
| 186 | .formatted(plRepo.getRepoName(), e.getStatusCode().value())); | |
| 187 | } | |
| 188 |
1
1. accept : negated conditional → KILLED |
if (!canWrite) { |
| 189 | throw new Exception( | |
| 190 | "The stored GitHub PAT has read-only access to repo %s; read/write access is required" | |
| 191 | .formatted(plRepo.getRepoName())); | |
| 192 | } | |
| 193 | PrairieLearnService.CourseInstanceInfo instanceInfo; | |
| 194 | try { | |
| 195 | instanceInfo = prairieLearnService.getCourseInstance(plInstance.getNumericId(), plToken); | |
| 196 | } catch (HttpClientErrorException e) { | |
| 197 | throw new Exception( | |
| 198 | "The stored PrairieLearn PAT cannot access course instance %d (HTTP %d); check the token on the /profile page" | |
| 199 | .formatted(plInstance.getNumericId(), e.getStatusCode().value())); | |
| 200 | } | |
| 201 |
1
1. accept : negated conditional → KILLED |
if (instanceInfo == null) { |
| 202 | throw new Exception( | |
| 203 | "PrairieLearn returned no data for course instance %d" | |
| 204 | .formatted(plInstance.getNumericId())); | |
| 205 | } | |
| 206 |
1
1. accept : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 207 | "Access verified: repo %s (read/write) and PrairieLearn instance %d" | |
| 208 | .formatted(plRepo.getRepoName(), plInstance.getNumericId())); | |
| 209 | ||
| 210 | // (4) Sanity-check the instance metadata instead of repopulating the PlInstance table. | |
| 211 |
1
1. accept : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::sanityCheckInstance → KILLED |
sanityCheckInstance(ctx, plInstance, instanceInfo); |
| 212 | ||
| 213 | try { | |
| 214 |
1
1. accept : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::syncAssessmentSets → KILLED |
syncAssessmentSets(ctx, plRepo, githubToken); |
| 215 |
1
1. accept : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::syncQuestions → KILLED |
syncQuestions(ctx, plRepo, githubToken); |
| 216 |
1
1. accept : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::syncAssessments → KILLED |
syncAssessments(ctx, plRepo, plInstance, githubToken); |
| 217 | } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { | |
| 218 | throw new Exception( | |
| 219 | "GitHub rejected the stored PAT (HTTP %d). The token may be expired, revoked, or not approved for this repo; enter a new one (see docs/Github_PAT.md)" | |
| 220 | .formatted(e.getStatusCode().value())); | |
| 221 | } | |
| 222 | ||
| 223 | // (5) Copy the PrairieLearn-side assessment fields onto the matching rows. | |
| 224 |
1
1. accept : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::enrichAssessmentsFromPrairieLearn → KILLED |
enrichAssessmentsFromPrairieLearn(ctx, plRepo, plInstance, plToken); |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * Keeps the stored shortName/longName in line with what PrairieLearn reports for the instance's | |
| 229 | * numeric id — the association was verified when it was created, so a difference here means the | |
| 230 | * instance was renamed on the PrairieLearn side. | |
| 231 | */ | |
| 232 | private void sanityCheckInstance( | |
| 233 | JobContext ctx, PlInstance plInstance, PrairieLearnService.CourseInstanceInfo info) { | |
| 234 | boolean changed = false; | |
| 235 |
1
1. sanityCheckInstance : negated conditional → KILLED |
if (!Objects.equals(info.shortName(), plInstance.getShortName())) { |
| 236 |
1
1. sanityCheckInstance : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 237 | "Instance shortName changed on PrairieLearn: %s -> %s" | |
| 238 | .formatted(plInstance.getShortName(), info.shortName())); | |
| 239 |
1
1. sanityCheckInstance : removed call to edu/ucsb/cs/scaffold/entity/PlInstance::setShortName → KILLED |
plInstance.setShortName(info.shortName()); |
| 240 | changed = true; | |
| 241 | } | |
| 242 |
1
1. sanityCheckInstance : negated conditional → KILLED |
if (!Objects.equals(info.longName(), plInstance.getLongName())) { |
| 243 |
1
1. sanityCheckInstance : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 244 | "Instance longName changed on PrairieLearn: %s -> %s" | |
| 245 | .formatted(plInstance.getLongName(), info.longName())); | |
| 246 |
1
1. sanityCheckInstance : removed call to edu/ucsb/cs/scaffold/entity/PlInstance::setLongName → KILLED |
plInstance.setLongName(info.longName()); |
| 247 | changed = true; | |
| 248 | } | |
| 249 |
1
1. sanityCheckInstance : negated conditional → KILLED |
if (changed) { |
| 250 | plInstanceRepository.save(plInstance); | |
| 251 | } else { | |
| 252 |
1
1. sanityCheckInstance : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log("Instance %s metadata verified".formatted(plInstance.getShortName())); |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | /** | |
| 257 | * Syncs the repo's assessment sets (issue #93) from the {@code assessmentSets} array of the | |
| 258 | * top-level {@code infoCourse.json} — assessment sets are global to the whole course repo, not | |
| 259 | * per-instance. Each entry becomes a PlAssessmentSet row, matched by abbreviation; stale rows | |
| 260 | * (abbreviations no longer present) are deleted. If the file is missing (HTTP 404) or cannot be | |
| 261 | * parsed, the sync is skipped entirely so a token/access problem cannot mass-delete rows. | |
| 262 | */ | |
| 263 | private void syncAssessmentSets(JobContext ctx, PlRepo plRepo, String token) { | |
| 264 | String content; | |
| 265 | try { | |
| 266 | content = githubService.getFileContent(plRepo.getRepoName(), INFO_COURSE_JSON, token); | |
| 267 | } catch (HttpClientErrorException.NotFound e) { | |
| 268 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 269 | "Repo %s has no %s; skipping assessment set sync" | |
| 270 | .formatted(plRepo.getRepoName(), INFO_COURSE_JSON)); | |
| 271 | return; | |
| 272 | } | |
| 273 | ||
| 274 | JsonNode root; | |
| 275 | try { | |
| 276 | root = MAPPER.readTree(content); | |
| 277 | } catch (Exception e) { | |
| 278 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 279 | "Skipping assessment set sync for repo %s: could not parse %s" | |
| 280 | .formatted(plRepo.getRepoName(), INFO_COURSE_JSON)); | |
| 281 | return; | |
| 282 | } | |
| 283 | ||
| 284 | JsonNode assessmentSets = root.get(ASSESSMENT_SETS_KEY); | |
| 285 |
1
1. syncAssessmentSets : negated conditional → KILLED |
if (assessmentSets == null) { |
| 286 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 287 | "Repo %s's %s has no %s key; skipping assessment set sync" | |
| 288 | .formatted(plRepo.getRepoName(), INFO_COURSE_JSON, ASSESSMENT_SETS_KEY)); | |
| 289 | return; | |
| 290 | } | |
| 291 |
1
1. syncAssessmentSets : negated conditional → KILLED |
if (!assessmentSets.isArray()) { |
| 292 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 293 | "Repo %s's %s has a non-array %s value; skipping assessment set sync" | |
| 294 | .formatted(plRepo.getRepoName(), INFO_COURSE_JSON, ASSESSMENT_SETS_KEY)); | |
| 295 | return; | |
| 296 | } | |
| 297 | ||
| 298 | Map<String, PlAssessmentSet> existingByAbbreviation = new LinkedHashMap<>(); | |
| 299 | for (PlAssessmentSet set : plAssessmentSetRepository.findByPlRepoId(plRepo.getId())) { | |
| 300 | existingByAbbreviation.put(set.getAbbreviation(), set); | |
| 301 | } | |
| 302 | ||
| 303 | int added = 0; | |
| 304 | int updated = 0; | |
| 305 | int unchanged = 0; | |
| 306 | int skipped = 0; | |
| 307 | Set<String> foundAbbreviations = new LinkedHashSet<>(); | |
| 308 | for (JsonNode entry : assessmentSets) { | |
| 309 | String abbreviation = entry.path("abbreviation").asText(null); | |
| 310 | String name = entry.path("name").asText(null); | |
| 311 | String heading = entry.path("heading").asText(null); | |
| 312 | String color = entry.path("color").asText(null); | |
| 313 |
4
1. syncAssessmentSets : negated conditional → KILLED 2. syncAssessmentSets : negated conditional → KILLED 3. syncAssessmentSets : negated conditional → KILLED 4. syncAssessmentSets : negated conditional → KILLED |
if (abbreviation == null || name == null || heading == null || color == null) { |
| 314 |
1
1. syncAssessmentSets : Changed increment from 1 to -1 → KILLED |
skipped++; |
| 315 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 316 | "Skipping assessment set entry %s (repo %s): missing abbreviation, name, heading, or color" | |
| 317 | .formatted(entry, plRepo.getRepoName())); | |
| 318 | continue; | |
| 319 | } | |
| 320 | foundAbbreviations.add(abbreviation); | |
| 321 | ||
| 322 | PlAssessmentSet set = existingByAbbreviation.get(abbreviation); | |
| 323 |
1
1. syncAssessmentSets : negated conditional → KILLED |
if (set == null) { |
| 324 | plAssessmentSetRepository.save( | |
| 325 | PlAssessmentSet.builder() | |
| 326 | .plRepoId(plRepo.getId()) | |
| 327 | .abbreviation(abbreviation) | |
| 328 | .name(name) | |
| 329 | .heading(heading) | |
| 330 | .color(color) | |
| 331 | .build()); | |
| 332 |
1
1. syncAssessmentSets : Changed increment from 1 to -1 → KILLED |
added++; |
| 333 |
1
1. syncAssessmentSets : negated conditional → KILLED |
} else if (!Objects.equals(set.getName(), name) |
| 334 |
1
1. syncAssessmentSets : negated conditional → KILLED |
|| !Objects.equals(set.getHeading(), heading) |
| 335 |
1
1. syncAssessmentSets : negated conditional → KILLED |
|| !Objects.equals(set.getColor(), color)) { |
| 336 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs/scaffold/entity/PlAssessmentSet::setName → KILLED |
set.setName(name); |
| 337 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs/scaffold/entity/PlAssessmentSet::setHeading → KILLED |
set.setHeading(heading); |
| 338 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs/scaffold/entity/PlAssessmentSet::setColor → KILLED |
set.setColor(color); |
| 339 | plAssessmentSetRepository.save(set); | |
| 340 |
1
1. syncAssessmentSets : Changed increment from 1 to -1 → KILLED |
updated++; |
| 341 | } else { | |
| 342 |
1
1. syncAssessmentSets : Changed increment from 1 to -1 → KILLED |
unchanged++; |
| 343 | } | |
| 344 | } | |
| 345 | ||
| 346 | int deleted = 0; | |
| 347 | for (String abbreviation : existingByAbbreviation.keySet()) { | |
| 348 |
1
1. syncAssessmentSets : negated conditional → KILLED |
if (!foundAbbreviations.contains(abbreviation)) { |
| 349 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs/scaffold/repository/PlAssessmentSetRepository::delete → KILLED |
plAssessmentSetRepository.delete(existingByAbbreviation.get(abbreviation)); |
| 350 |
1
1. syncAssessmentSets : Changed increment from 1 to -1 → KILLED |
deleted++; |
| 351 | } | |
| 352 | } | |
| 353 | ||
| 354 |
1
1. syncAssessmentSets : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 355 | "Assessment sets: %d added, %d updated, %d deleted, %d unchanged, %d skipped" | |
| 356 | .formatted(added, updated, deleted, unchanged, skipped)); | |
| 357 | } | |
| 358 | ||
| 359 | private void syncQuestions(JobContext ctx, PlRepo plRepo, String token) { | |
| 360 | Map<String, QuestionInfo> foundQuestions = new LinkedHashMap<>(); | |
| 361 | try { | |
| 362 |
1
1. syncQuestions : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::walkQuestionsDirectory → KILLED |
walkQuestionsDirectory(ctx, plRepo, token, QUESTIONS_PATH, "", foundQuestions); |
| 363 | } catch (HttpClientErrorException.NotFound e) { | |
| 364 |
1
1. syncQuestions : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 365 | "Repo %s has no %s directory (or the token cannot see the repo); skipping question sync" | |
| 366 | .formatted(plRepo.getRepoName(), QUESTIONS_PATH)); | |
| 367 | return; | |
| 368 | } | |
| 369 | ||
| 370 | Map<String, PlQuestion> existingByQuestionId = new LinkedHashMap<>(); | |
| 371 | for (PlQuestion question : plQuestionRepository.findByPlRepoId(plRepo.getId())) { | |
| 372 | existingByQuestionId.put(question.getQuestionId(), question); | |
| 373 | } | |
| 374 | ||
| 375 | int added = 0; | |
| 376 | int updated = 0; | |
| 377 | int unchanged = 0; | |
| 378 | for (Map.Entry<String, QuestionInfo> entry : foundQuestions.entrySet()) { | |
| 379 | String questionId = entry.getKey(); | |
| 380 | QuestionInfo info = entry.getValue(); | |
| 381 | PlQuestion existing = existingByQuestionId.get(questionId); | |
| 382 |
1
1. syncQuestions : negated conditional → KILLED |
if (existing == null) { |
| 383 | plQuestionRepository.save( | |
| 384 | PlQuestion.builder() | |
| 385 | .plRepoId(plRepo.getId()) | |
| 386 | .questionId(questionId) | |
| 387 | .uuid(info.uuid()) | |
| 388 | .title(info.title()) | |
| 389 | .build()); | |
| 390 |
1
1. syncQuestions : Changed increment from 1 to -1 → KILLED |
added++; |
| 391 |
1
1. syncQuestions : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log("Added question %s (%s)".formatted(questionId, info.title())); |
| 392 |
1
1. syncQuestions : negated conditional → KILLED |
} else if (!existing.getUuid().equals(info.uuid()) |
| 393 |
1
1. syncQuestions : negated conditional → KILLED |
|| !existing.getTitle().equals(info.title())) { |
| 394 |
1
1. syncQuestions : removed call to edu/ucsb/cs/scaffold/entity/PlQuestion::setUuid → KILLED |
existing.setUuid(info.uuid()); |
| 395 |
1
1. syncQuestions : removed call to edu/ucsb/cs/scaffold/entity/PlQuestion::setTitle → KILLED |
existing.setTitle(info.title()); |
| 396 | plQuestionRepository.save(existing); | |
| 397 |
1
1. syncQuestions : Changed increment from 1 to -1 → KILLED |
updated++; |
| 398 |
1
1. syncQuestions : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log("Updated question %s (%s)".formatted(questionId, info.title())); |
| 399 | } else { | |
| 400 |
1
1. syncQuestions : Changed increment from 1 to -1 → KILLED |
unchanged++; |
| 401 | } | |
| 402 | } | |
| 403 | ||
| 404 | int deleted = 0; | |
| 405 | List<String> staleQuestionIds = | |
| 406 | existingByQuestionId.keySet().stream() | |
| 407 |
2
1. lambda$syncQuestions$2 : replaced boolean return with true for edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::lambda$syncQuestions$2 → KILLED 2. lambda$syncQuestions$2 : negated conditional → KILLED |
.filter(questionId -> !foundQuestions.containsKey(questionId)) |
| 408 | .sorted() | |
| 409 | .toList(); | |
| 410 | for (String questionId : staleQuestionIds) { | |
| 411 | PlQuestion stale = existingByQuestionId.get(questionId); | |
| 412 |
1
1. syncQuestions : removed call to edu/ucsb/cs/scaffold/repository/PlScaffoldAssessmentRepository::deleteByPlQuestionId → KILLED |
plScaffoldAssessmentRepository.deleteByPlQuestionId(stale.getId()); |
| 413 |
1
1. syncQuestions : removed call to edu/ucsb/cs/scaffold/repository/PlAssessmentQuestionRepository::deleteByPlQuestionId → KILLED |
plAssessmentQuestionRepository.deleteByPlQuestionId(stale.getId()); |
| 414 |
1
1. syncQuestions : removed call to edu/ucsb/cs/scaffold/repository/PlQuestionRepository::delete → KILLED |
plQuestionRepository.delete(stale); |
| 415 |
1
1. syncQuestions : Changed increment from 1 to -1 → KILLED |
deleted++; |
| 416 |
1
1. syncQuestions : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log("Deleted question %s (no longer on GitHub)".formatted(questionId)); |
| 417 | } | |
| 418 | ||
| 419 |
1
1. syncQuestions : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 420 | "Questions: %d added, %d updated, %d deleted, %d unchanged" | |
| 421 | .formatted(added, updated, deleted, unchanged)); | |
| 422 | } | |
| 423 | ||
| 424 | /** Syncs assessments from GitHub for the course's own instance only. */ | |
| 425 | private void syncAssessments(JobContext ctx, PlRepo plRepo, PlInstance instance, String token) { | |
| 426 | Map<String, PlQuestion> questionsByQuestionId = new LinkedHashMap<>(); | |
| 427 | for (PlQuestion question : plQuestionRepository.findByPlRepoId(plRepo.getId())) { | |
| 428 | questionsByQuestionId.put(question.getQuestionId(), question); | |
| 429 | } | |
| 430 | ||
| 431 | int added = 0; | |
| 432 | int deleted = 0; | |
| 433 | int unchanged = 0; | |
| 434 | String assessmentsPath = | |
| 435 | "%s/%s/%s".formatted(COURSE_INSTANCES_PATH, instance.getShortName(), ASSESSMENTS_DIRECTORY); | |
| 436 | List<DirectoryEntry> entries; | |
| 437 | try { | |
| 438 | entries = githubService.listDirectory(plRepo.getRepoName(), assessmentsPath, token); | |
| 439 | } catch (HttpClientErrorException.NotFound e) { | |
| 440 |
1
1. syncAssessments : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 441 | "Instance %s has no %s directory; skipping assessment sync" | |
| 442 | .formatted(instance.getShortName(), ASSESSMENTS_DIRECTORY)); | |
| 443 | return; | |
| 444 | } | |
| 445 | ||
| 446 | Map<String, PlAssessment> existingByName = new LinkedHashMap<>(); | |
| 447 | for (PlAssessment assessment : | |
| 448 | plAssessmentRepository.findByPlRepoIdAndPlInstanceId(plRepo.getId(), instance.getId())) { | |
| 449 | existingByName.put(assessment.getName(), assessment); | |
| 450 | } | |
| 451 | ||
| 452 | Set<String> foundNames = new LinkedHashSet<>(); | |
| 453 | for (DirectoryEntry entry : entries) { | |
| 454 |
1
1. syncAssessments : negated conditional → KILLED |
if (!"dir".equals(entry.type())) { |
| 455 | continue; | |
| 456 | } | |
| 457 | String assessmentPath = assessmentsPath + "/" + entry.name(); | |
| 458 | boolean hasInfoAssessment = | |
| 459 | githubService.listDirectory(plRepo.getRepoName(), assessmentPath, token).stream() | |
| 460 |
3
1. lambda$syncAssessments$3 : replaced boolean return with true for edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::lambda$syncAssessments$3 → KILLED 2. lambda$syncAssessments$3 : negated conditional → KILLED 3. lambda$syncAssessments$3 : negated conditional → KILLED |
.anyMatch(e -> INFO_ASSESSMENT_JSON.equals(e.name()) && "file".equals(e.type())); |
| 461 |
1
1. syncAssessments : negated conditional → KILLED |
if (!hasInfoAssessment) { |
| 462 | continue; | |
| 463 | } | |
| 464 | String content = | |
| 465 | githubService.getFileContent( | |
| 466 | plRepo.getRepoName(), assessmentPath + "/" + INFO_ASSESSMENT_JSON, token); | |
| 467 | JsonNode root; | |
| 468 | try { | |
| 469 | root = MAPPER.readTree(content); | |
| 470 | } catch (Exception e) { | |
| 471 |
1
1. syncAssessments : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 472 | "Skipping assessment %s (instance %s): could not parse %s" | |
| 473 | .formatted(entry.name(), instance.getShortName(), INFO_ASSESSMENT_JSON)); | |
| 474 | continue; | |
| 475 | } | |
| 476 | foundNames.add(entry.name()); | |
| 477 | ||
| 478 | PlAssessment assessment = existingByName.get(entry.name()); | |
| 479 |
1
1. syncAssessments : negated conditional → KILLED |
if (assessment == null) { |
| 480 | assessment = | |
| 481 | plAssessmentRepository.save( | |
| 482 | PlAssessment.builder() | |
| 483 | .plRepoId(plRepo.getId()) | |
| 484 | .plInstanceId(instance.getId()) | |
| 485 | .name(entry.name()) | |
| 486 | .build()); | |
| 487 |
1
1. syncAssessments : Changed increment from 1 to -1 → KILLED |
added++; |
| 488 |
1
1. syncAssessments : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 489 | "Added assessment %s (instance %s)".formatted(entry.name(), instance.getShortName())); | |
| 490 | } else { | |
| 491 |
1
1. syncAssessments : Changed increment from 1 to -1 → KILLED |
unchanged++; |
| 492 | } | |
| 493 | ||
| 494 | // get("zones") is null when the key is absent; collectQuestionIds treats that as no links | |
| 495 |
1
1. syncAssessments : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::syncAssessmentQuestions → KILLED |
syncAssessmentQuestions( |
| 496 | ctx, plRepo, instance, assessment, root.get("zones"), questionsByQuestionId); | |
| 497 | } | |
| 498 | ||
| 499 | List<String> staleNames = | |
| 500 | existingByName.keySet().stream() | |
| 501 |
2
1. lambda$syncAssessments$4 : negated conditional → KILLED 2. lambda$syncAssessments$4 : replaced boolean return with true for edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::lambda$syncAssessments$4 → KILLED |
.filter(name -> !foundNames.contains(name)) |
| 502 | .sorted() | |
| 503 | .toList(); | |
| 504 | for (String name : staleNames) { | |
| 505 | PlAssessment stale = existingByName.get(name); | |
| 506 |
1
1. syncAssessments : removed call to edu/ucsb/cs/scaffold/repository/PlAssessmentQuestionRepository::deleteByPlAssessmentId → KILLED |
plAssessmentQuestionRepository.deleteByPlAssessmentId(stale.getId()); |
| 507 |
1
1. syncAssessments : removed call to edu/ucsb/cs/scaffold/repository/PlAssessmentRepository::delete → KILLED |
plAssessmentRepository.delete(stale); |
| 508 |
1
1. syncAssessments : Changed increment from 1 to -1 → KILLED |
deleted++; |
| 509 |
1
1. syncAssessments : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 510 | "Deleted assessment %s (instance %s) (no longer on GitHub)" | |
| 511 | .formatted(name, instance.getShortName())); | |
| 512 | } | |
| 513 | ||
| 514 |
1
1. syncAssessments : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log("Assessments: %d added, %d deleted, %d unchanged".formatted(added, deleted, unchanged)); |
| 515 | } | |
| 516 | ||
| 517 | /** | |
| 518 | * Copies the PrairieLearn-side fields (issue #71) onto the PlAssessment rows of this instance, | |
| 519 | * matched by assessment name. A PrairieLearn assessment with no matching repo row (or the | |
| 520 | * reverse) is logged and left alone; the GitHub sync is the source of truth for which rows exist. | |
| 521 | */ | |
| 522 | private void enrichAssessmentsFromPrairieLearn( | |
| 523 | JobContext ctx, PlRepo plRepo, PlInstance plInstance, String plToken) { | |
| 524 | List<PrairieLearnService.AssessmentInfo> plAssessments; | |
| 525 | try { | |
| 526 | plAssessments = prairieLearnService.getAssessments(plInstance.getNumericId(), plToken); | |
| 527 | } catch (HttpClientErrorException e) { | |
| 528 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 529 | "Could not list assessments from PrairieLearn (HTTP %d); skipping assessment field updates" | |
| 530 | .formatted(e.getStatusCode().value())); | |
| 531 | return; | |
| 532 | } | |
| 533 | ||
| 534 | Map<String, PlAssessment> existingByName = new LinkedHashMap<>(); | |
| 535 | for (PlAssessment assessment : | |
| 536 | plAssessmentRepository.findByPlRepoIdAndPlInstanceId(plRepo.getId(), plInstance.getId())) { | |
| 537 | existingByName.put(assessment.getName(), assessment); | |
| 538 | } | |
| 539 | ||
| 540 | int updated = 0; | |
| 541 | int unmatched = 0; | |
| 542 | for (PrairieLearnService.AssessmentInfo info : plAssessments) { | |
| 543 | PlAssessment assessment = existingByName.get(info.assessmentName()); | |
| 544 |
1
1. enrichAssessmentsFromPrairieLearn : negated conditional → KILLED |
if (assessment == null) { |
| 545 |
1
1. enrichAssessmentsFromPrairieLearn : Changed increment from 1 to -1 → KILLED |
unmatched++; |
| 546 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 547 | "PrairieLearn assessment %s has no matching assessment directory in the repo; skipping" | |
| 548 | .formatted(info.assessmentName())); | |
| 549 | continue; | |
| 550 | } | |
| 551 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs/scaffold/entity/PlAssessment::setPlAssessmentId → KILLED |
assessment.setPlAssessmentId(info.assessmentId()); |
| 552 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs/scaffold/entity/PlAssessment::setPlAssessmentNumber → KILLED |
assessment.setPlAssessmentNumber(info.assessmentNumber()); |
| 553 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs/scaffold/entity/PlAssessment::setPlAssessmentOrder → KILLED |
assessment.setPlAssessmentOrder(info.assessmentOrderBy()); |
| 554 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs/scaffold/entity/PlAssessment::setPlAssessmentTitle → KILLED |
assessment.setPlAssessmentTitle(info.title()); |
| 555 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs/scaffold/entity/PlAssessment::setPlAssessmentSetAbbreviation → KILLED |
assessment.setPlAssessmentSetAbbreviation(info.assessmentSetAbbreviation()); |
| 556 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs/scaffold/entity/PlAssessment::setPlAssessmentSetNumber → KILLED |
assessment.setPlAssessmentSetNumber(info.assessmentSetNumber()); |
| 557 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs/scaffold/entity/PlAssessment::setPlAssessmentSetHeading → KILLED |
assessment.setPlAssessmentSetHeading(info.assessmentSetHeading()); |
| 558 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs/scaffold/entity/PlAssessment::setPlAssessmentSetColor → KILLED |
assessment.setPlAssessmentSetColor(info.assessmentSetColor()); |
| 559 | plAssessmentRepository.save(assessment); | |
| 560 |
1
1. enrichAssessmentsFromPrairieLearn : Changed increment from 1 to -1 → KILLED |
updated++; |
| 561 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log("Updated PrairieLearn fields for assessment %s".formatted(info.assessmentName())); |
| 562 | } | |
| 563 |
1
1. enrichAssessmentsFromPrairieLearn : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 564 | "PrairieLearn assessment fields: %d updated, %d without a matching repo assessment" | |
| 565 | .formatted(updated, unmatched)); | |
| 566 | } | |
| 567 | ||
| 568 | /** | |
| 569 | * Rewrites the assessment's question list (join rows, in zone order) to match the ids referenced | |
| 570 | * by the zones node of its infoAssessment.json. Ids that don't match any PlQuestion of this repo | |
| 571 | * are logged and skipped. If the list already matches, the rows are left untouched. | |
| 572 | */ | |
| 573 | private void syncAssessmentQuestions( | |
| 574 | JobContext ctx, | |
| 575 | PlRepo plRepo, | |
| 576 | PlInstance instance, | |
| 577 | PlAssessment assessment, | |
| 578 | JsonNode zones, | |
| 579 | Map<String, PlQuestion> questionsByQuestionId) { | |
| 580 | Set<String> referencedIds = new LinkedHashSet<>(); | |
| 581 |
1
1. syncAssessmentQuestions : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::collectQuestionIds → KILLED |
collectQuestionIds(zones, referencedIds); |
| 582 | ||
| 583 | List<Long> desiredQuestionRowIds = new ArrayList<>(); | |
| 584 | for (String questionId : referencedIds) { | |
| 585 | PlQuestion question = questionsByQuestionId.get(questionId); | |
| 586 |
1
1. syncAssessmentQuestions : negated conditional → KILLED |
if (question == null) { |
| 587 |
1
1. syncAssessmentQuestions : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 588 | "Assessment %s (instance %s) references unknown question id %s; skipping that link" | |
| 589 | .formatted(assessment.getName(), instance.getShortName(), questionId)); | |
| 590 | continue; | |
| 591 | } | |
| 592 | desiredQuestionRowIds.add(question.getId()); | |
| 593 | } | |
| 594 | ||
| 595 | List<Long> currentQuestionRowIds = | |
| 596 | plAssessmentQuestionRepository | |
| 597 | .findByPlAssessmentIdOrderByOrdinalAsc(assessment.getId()) | |
| 598 | .stream() | |
| 599 | .map(PlAssessmentQuestion::getPlQuestionId) | |
| 600 | .toList(); | |
| 601 |
1
1. syncAssessmentQuestions : negated conditional → KILLED |
if (currentQuestionRowIds.equals(desiredQuestionRowIds)) { |
| 602 | return; | |
| 603 | } | |
| 604 | ||
| 605 |
1
1. syncAssessmentQuestions : removed call to edu/ucsb/cs/scaffold/repository/PlAssessmentQuestionRepository::deleteByPlAssessmentId → KILLED |
plAssessmentQuestionRepository.deleteByPlAssessmentId(assessment.getId()); |
| 606 | // flush so the deletes hit the database before the re-inserts; Hibernate otherwise orders | |
| 607 | // inserts first within the transaction, violating the (assessment, question) unique constraint | |
| 608 |
1
1. syncAssessmentQuestions : removed call to edu/ucsb/cs/scaffold/repository/PlAssessmentQuestionRepository::flush → KILLED |
plAssessmentQuestionRepository.flush(); |
| 609 |
2
1. syncAssessmentQuestions : changed conditional boundary → KILLED 2. syncAssessmentQuestions : negated conditional → KILLED |
for (int i = 0; i < desiredQuestionRowIds.size(); i++) { |
| 610 | plAssessmentQuestionRepository.save( | |
| 611 | PlAssessmentQuestion.builder() | |
| 612 | .plRepoId(plRepo.getId()) | |
| 613 | .plAssessmentId(assessment.getId()) | |
| 614 | .plQuestionId(desiredQuestionRowIds.get(i)) | |
| 615 | .ordinal(i) | |
| 616 | .build()); | |
| 617 | } | |
| 618 |
1
1. syncAssessmentQuestions : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log( |
| 619 | "Linked %d question(s) to assessment %s (instance %s)" | |
| 620 | .formatted( | |
| 621 | desiredQuestionRowIds.size(), assessment.getName(), instance.getShortName())); | |
| 622 | } | |
| 623 | ||
| 624 | /** | |
| 625 | * Recursively collects the value of every {@code "id"} key in the JSON tree under the zones node | |
| 626 | * of an infoAssessment.json — each one references a question — preserving document order and | |
| 627 | * dropping duplicates. | |
| 628 | */ | |
| 629 | static void collectQuestionIds(JsonNode node, Set<String> ids) { | |
| 630 |
1
1. collectQuestionIds : negated conditional → KILLED |
if (node == null) { |
| 631 | return; | |
| 632 | } | |
| 633 |
2
1. collectQuestionIds : negated conditional → KILLED 2. collectQuestionIds : negated conditional → KILLED |
if (node.isObject() && node.hasNonNull("id")) { |
| 634 | ids.add(node.get("id").asText()); | |
| 635 | } | |
| 636 | for (JsonNode child : node) { | |
| 637 |
1
1. collectQuestionIds : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::collectQuestionIds → KILLED |
collectQuestionIds(child, ids); |
| 638 | } | |
| 639 | } | |
| 640 | ||
| 641 | /** | |
| 642 | * Recursively walks a directory under {@code questions}. {@code questionId} is the path relative | |
| 643 | * to the questions directory ("" for the questions directory itself, which is never a question). | |
| 644 | */ | |
| 645 | private void walkQuestionsDirectory( | |
| 646 | JobContext ctx, | |
| 647 | PlRepo plRepo, | |
| 648 | String token, | |
| 649 | String path, | |
| 650 | String questionId, | |
| 651 | Map<String, QuestionInfo> foundQuestions) { | |
| 652 | List<DirectoryEntry> entries = githubService.listDirectory(plRepo.getRepoName(), path, token); | |
| 653 | ||
| 654 | boolean hasInfoJson = | |
| 655 | entries.stream() | |
| 656 |
3
1. lambda$walkQuestionsDirectory$5 : negated conditional → KILLED 2. lambda$walkQuestionsDirectory$5 : replaced boolean return with true for edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::lambda$walkQuestionsDirectory$5 → KILLED 3. lambda$walkQuestionsDirectory$5 : negated conditional → KILLED |
.anyMatch(entry -> INFO_JSON.equals(entry.name()) && "file".equals(entry.type())); |
| 657 |
2
1. walkQuestionsDirectory : negated conditional → KILLED 2. walkQuestionsDirectory : negated conditional → KILLED |
if (hasInfoJson && !questionId.isEmpty()) { |
| 658 | String content = | |
| 659 | githubService.getFileContent(plRepo.getRepoName(), path + "/" + INFO_JSON, token); | |
| 660 | QuestionInfo info = parseInfoJson(ctx, questionId, content); | |
| 661 |
1
1. walkQuestionsDirectory : negated conditional → KILLED |
if (info != null) { |
| 662 | foundQuestions.put(questionId, info); | |
| 663 | } | |
| 664 | return; // a question directory's subdirectories belong to the question; don't recurse | |
| 665 | } | |
| 666 | ||
| 667 | for (DirectoryEntry entry : entries) { | |
| 668 |
1
1. walkQuestionsDirectory : negated conditional → KILLED |
if (!"dir".equals(entry.type())) { |
| 669 | continue; | |
| 670 | } | |
| 671 |
1
1. walkQuestionsDirectory : negated conditional → KILLED |
if (DRAFTS_DIRECTORY.equals(entry.name())) { |
| 672 |
1
1. walkQuestionsDirectory : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log("Skipping directory %s/%s".formatted(path, entry.name())); |
| 673 | continue; | |
| 674 | } | |
| 675 | String childQuestionId = | |
| 676 |
1
1. walkQuestionsDirectory : negated conditional → KILLED |
questionId.isEmpty() ? entry.name() : questionId + "/" + entry.name(); |
| 677 |
1
1. walkQuestionsDirectory : removed call to edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::walkQuestionsDirectory → KILLED |
walkQuestionsDirectory( |
| 678 | ctx, plRepo, token, path + "/" + entry.name(), childQuestionId, foundQuestions); | |
| 679 | } | |
| 680 | } | |
| 681 | ||
| 682 | /** Returns the parsed uuid/title, or null (with a log message) if info.json is unusable. */ | |
| 683 | private QuestionInfo parseInfoJson(JobContext ctx, String questionId, String content) { | |
| 684 | try { | |
| 685 | JsonNode node = MAPPER.readTree(content); | |
| 686 |
2
1. parseInfoJson : negated conditional → KILLED 2. parseInfoJson : negated conditional → KILLED |
if (!node.hasNonNull("uuid") || !node.hasNonNull("title")) { |
| 687 |
1
1. parseInfoJson : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log("Skipping question %s: info.json is missing uuid or title".formatted(questionId)); |
| 688 | return null; | |
| 689 | } | |
| 690 |
1
1. parseInfoJson : replaced return value with null for edu/ucsb/cs/scaffold/jobs/SyncCourseWithPlRepoJob::parseInfoJson → KILLED |
return new QuestionInfo( |
| 691 | UUID.fromString(node.get("uuid").asText()), node.get("title").asText()); | |
| 692 | } catch (Exception e) { | |
| 693 |
1
1. parseInfoJson : removed call to edu/ucsb/cs156/jobs/services/JobContext::log → KILLED |
ctx.log("Skipping question %s: could not parse info.json".formatted(questionId)); |
| 694 | return null; | |
| 695 | } | |
| 696 | } | |
| 697 | } | |
Mutations | ||
| 112 |
1.1 |
|
| 117 |
1.1 |
|
| 122 |
1.1 |
|
| 131 |
1.1 2.2 |
|
| 133 |
1.1 |
|
| 136 |
1.1 |
|
| 145 |
1.1 2.2 |
|
| 147 |
1.1 |
|
| 150 |
1.1 |
|
| 160 |
1.1 |
|
| 165 |
1.1 |
|
| 166 |
1.1 |
|
| 188 |
1.1 |
|
| 201 |
1.1 |
|
| 206 |
1.1 |
|
| 211 |
1.1 |
|
| 214 |
1.1 |
|
| 215 |
1.1 |
|
| 216 |
1.1 |
|
| 224 |
1.1 |
|
| 235 |
1.1 |
|
| 236 |
1.1 |
|
| 239 |
1.1 |
|
| 242 |
1.1 |
|
| 243 |
1.1 |
|
| 246 |
1.1 |
|
| 249 |
1.1 |
|
| 252 |
1.1 |
|
| 268 |
1.1 |
|
| 278 |
1.1 |
|
| 285 |
1.1 |
|
| 286 |
1.1 |
|
| 291 |
1.1 |
|
| 292 |
1.1 |
|
| 313 |
1.1 2.2 3.3 4.4 |
|
| 314 |
1.1 |
|
| 315 |
1.1 |
|
| 323 |
1.1 |
|
| 332 |
1.1 |
|
| 333 |
1.1 |
|
| 334 |
1.1 |
|
| 335 |
1.1 |
|
| 336 |
1.1 |
|
| 337 |
1.1 |
|
| 338 |
1.1 |
|
| 340 |
1.1 |
|
| 342 |
1.1 |
|
| 348 |
1.1 |
|
| 349 |
1.1 |
|
| 350 |
1.1 |
|
| 354 |
1.1 |
|
| 362 |
1.1 |
|
| 364 |
1.1 |
|
| 382 |
1.1 |
|
| 390 |
1.1 |
|
| 391 |
1.1 |
|
| 392 |
1.1 |
|
| 393 |
1.1 |
|
| 394 |
1.1 |
|
| 395 |
1.1 |
|
| 397 |
1.1 |
|
| 398 |
1.1 |
|
| 400 |
1.1 |
|
| 407 |
1.1 2.2 |
|
| 412 |
1.1 |
|
| 413 |
1.1 |
|
| 414 |
1.1 |
|
| 415 |
1.1 |
|
| 416 |
1.1 |
|
| 419 |
1.1 |
|
| 440 |
1.1 |
|
| 454 |
1.1 |
|
| 460 |
1.1 2.2 3.3 |
|
| 461 |
1.1 |
|
| 471 |
1.1 |
|
| 479 |
1.1 |
|
| 487 |
1.1 |
|
| 488 |
1.1 |
|
| 491 |
1.1 |
|
| 495 |
1.1 |
|
| 501 |
1.1 2.2 |
|
| 506 |
1.1 |
|
| 507 |
1.1 |
|
| 508 |
1.1 |
|
| 509 |
1.1 |
|
| 514 |
1.1 |
|
| 528 |
1.1 |
|
| 544 |
1.1 |
|
| 545 |
1.1 |
|
| 546 |
1.1 |
|
| 551 |
1.1 |
|
| 552 |
1.1 |
|
| 553 |
1.1 |
|
| 554 |
1.1 |
|
| 555 |
1.1 |
|
| 556 |
1.1 |
|
| 557 |
1.1 |
|
| 558 |
1.1 |
|
| 560 |
1.1 |
|
| 561 |
1.1 |
|
| 563 |
1.1 |
|
| 581 |
1.1 |
|
| 586 |
1.1 |
|
| 587 |
1.1 |
|
| 601 |
1.1 |
|
| 605 |
1.1 |
|
| 608 |
1.1 |
|
| 609 |
1.1 2.2 |
|
| 618 |
1.1 |
|
| 630 |
1.1 |
|
| 633 |
1.1 2.2 |
|
| 637 |
1.1 |
|
| 656 |
1.1 2.2 3.3 |
|
| 657 |
1.1 2.2 |
|
| 661 |
1.1 |
|
| 668 |
1.1 |
|
| 671 |
1.1 |
|
| 672 |
1.1 |
|
| 676 |
1.1 |
|
| 677 |
1.1 |
|
| 686 |
1.1 2.2 |
|
| 687 |
1.1 |
|
| 690 |
1.1 |
|
| 693 |
1.1 |