SyncCourseWithPlRepoJob.java
package edu.ucsb.cs.scaffold.jobs;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import edu.ucsb.cs.scaffold.entity.Course;
import edu.ucsb.cs.scaffold.entity.PatCredential;
import edu.ucsb.cs.scaffold.entity.PlAssessment;
import edu.ucsb.cs.scaffold.entity.PlAssessmentQuestion;
import edu.ucsb.cs.scaffold.entity.PlAssessmentSet;
import edu.ucsb.cs.scaffold.entity.PlInstance;
import edu.ucsb.cs.scaffold.entity.PlQuestion;
import edu.ucsb.cs.scaffold.entity.PlRepo;
import edu.ucsb.cs.scaffold.enums.PatPlatform;
import edu.ucsb.cs.scaffold.errors.EntityNotFoundException;
import edu.ucsb.cs.scaffold.repository.PatCredentialRepository;
import edu.ucsb.cs.scaffold.repository.PlAssessmentQuestionRepository;
import edu.ucsb.cs.scaffold.repository.PlAssessmentRepository;
import edu.ucsb.cs.scaffold.repository.PlAssessmentSetRepository;
import edu.ucsb.cs.scaffold.repository.PlInstanceRepository;
import edu.ucsb.cs.scaffold.repository.PlQuestionRepository;
import edu.ucsb.cs.scaffold.repository.PlRepoRepository;
import edu.ucsb.cs.scaffold.repository.PlScaffoldAssessmentRepository;
import edu.ucsb.cs.scaffold.services.GithubService;
import edu.ucsb.cs.scaffold.services.GithubService.DirectoryEntry;
import edu.ucsb.cs.scaffold.services.PatEncryptionService;
import edu.ucsb.cs.scaffold.services.PrairieLearnService;
import edu.ucsb.cs156.jobs.services.JobContext;
import edu.ucsb.cs156.jobs.services.JobContextConsumer;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import lombok.Builder;
import org.springframework.web.client.HttpClientErrorException;
/**
* Syncs one course's PrairieLearn state (issue #69), replacing the repo-wide SyncPlRepoJob. Takes a
* course (rather than a PlRepo id) and uses the launching user's GitHub and PrairieLearn PATs.
*
* <p>Before doing any work, the job verifies that the user has both PATs, that the course is
* associated with a GitHub repo and a PrairieLearn course instance, and that the PATs can actually
* reach both — read/write access to the repo, and access to the course instance via the
* PrairieLearn API. Any failure terminates the job with a log message telling the user where to fix
* it (the /profile page for PATs, the PrairieLearn tab of the course settings for the
* associations).
*
* <p>Unlike the old job, the PlInstance table is not repopulated from the repo's courseInstances
* directory: the course's instance already exists, and its shortName/longName are just
* sanity-checked (and corrected) against what PrairieLearn reports. Assessments are synced only for
* the course's own instance. Questions are still traversed for the whole repo, exactly as before:
* the {@code questions} directory is walked recursively; a directory containing an {@code
* info.json} is a question whose questionId is its path relative to {@code questions}; {@code
* __drafts__} directories are skipped; question directories are not traversed further; stale
* PlQuestion rows are deleted (cascading to their PlScaffoldAssessments and join rows).
*
* <p>Assessment sets (issue #93): the {@code assessmentSets} array of the repo's top-level {@code
* infoCourse.json} becomes the PlAssessmentSet rows of the repo (assessment sets are global to the
* whole course, not per-instance), matched by abbreviation. Stale rows are deleted.
*
* <p>Assessments: each subdirectory of {@code courseInstances/<instance>/assessments} containing an
* {@code infoAssessment.json} becomes a PlAssessment; the {@code zones} key is walked recursively
* and every {@code "id"} entry links the assessment to a PlQuestion of the repo (the
* PlAssessmentQuestion join table, in zone order, rewritten on change). Stale rows are deleted.
*
* <p>Finally the job asks the PrairieLearn API for the instance's assessments and copies the fields
* from issue #71 (numeric id, number, order, title, and the assessment-set
* abbreviation/number/heading/color) onto the matching PlAssessment rows, matched by name.
*
* <p>If the {@code questions} or per-instance {@code assessments} directory is missing (HTTP 404),
* that step is skipped entirely — including deletions — because a 404 cannot distinguish "directory
* removed" from "token cannot see the repo", and mass-deleting rows over a token problem would be
* wrong.
*/
@Builder
public class SyncCourseWithPlRepoJob implements JobContextConsumer {
static final String COURSE_INSTANCES_PATH = "courseInstances";
static final String QUESTIONS_PATH = "questions";
static final String ASSESSMENTS_DIRECTORY = "assessments";
static final String DRAFTS_DIRECTORY = "__drafts__";
static final String INFO_JSON = "info.json";
static final String INFO_ASSESSMENT_JSON = "infoAssessment.json";
static final String INFO_COURSE_JSON = "infoCourse.json";
static final String ASSESSMENT_SETS_KEY = "assessmentSets";
private static final ObjectMapper MAPPER = new ObjectMapper();
private long userId;
private Course course;
private PatCredentialRepository patCredentialRepository;
private PatEncryptionService patEncryptionService;
private PlRepoRepository plRepoRepository;
private PlInstanceRepository plInstanceRepository;
private PlQuestionRepository plQuestionRepository;
private PlScaffoldAssessmentRepository plScaffoldAssessmentRepository;
private PlAssessmentRepository plAssessmentRepository;
private PlAssessmentQuestionRepository plAssessmentQuestionRepository;
private PlAssessmentSetRepository plAssessmentSetRepository;
private GithubService githubService;
private PrairieLearnService prairieLearnService;
/** The uuid and title of a question, as read from its info.json. */
record QuestionInfo(UUID uuid, String title) {}
@Override
public String getScopeType() {
return "course";
}
@Override
public Long getScopeId() {
return course.getId();
}
@Override
public void accept(JobContext ctx) throws Exception {
ctx.log(
"Syncing course %d (%s) with PrairieLearn"
.formatted(course.getId(), course.getCourseName()));
// (1) Both PATs must be configured before anything else.
Optional<PatCredential> githubCredential =
patCredentialRepository.findByUserIdAndPlatform(userId, PatPlatform.GITHUB);
Optional<PatCredential> plCredential =
patCredentialRepository.findByUserIdAndPlatform(userId, PatPlatform.PRAIRIELEARN);
if (githubCredential.isEmpty() || plCredential.isEmpty()) {
List<String> missing = new ArrayList<>();
if (githubCredential.isEmpty()) {
missing.add("GitHub PAT");
}
if (plCredential.isEmpty()) {
missing.add("PrairieLearn PAT");
}
throw new Exception(
"Missing %s: set it up on the /profile page before running this job"
.formatted(String.join(" and ", missing)));
}
// (2) The course must be associated with a repo and a course instance.
if (course.getPlRepoId() == null || course.getPlInstanceId() == null) {
List<String> missing = new ArrayList<>();
if (course.getPlRepoId() == null) {
missing.add("a GitHub repo");
}
if (course.getPlInstanceId() == null) {
missing.add("a PrairieLearn course instance");
}
throw new Exception(
"This course is not associated with %s yet; set that up on the PrairieLearn tab of the course settings page"
.formatted(String.join(" or ", missing)));
}
PlRepo plRepo =
plRepoRepository
.findById(course.getPlRepoId())
.orElseThrow(() -> new EntityNotFoundException(PlRepo.class, course.getPlRepoId()));
PlInstance plInstance =
plInstanceRepository
.findById(course.getPlInstanceId())
.orElseThrow(
() -> new EntityNotFoundException(PlInstance.class, course.getPlInstanceId()));
if (plInstance.getNumericId() == null) {
throw new Exception(
"The course's PrairieLearn instance has no numeric id yet; re-associate it on the"
+ " PrairieLearn tab of the course settings page");
}
String githubToken =
patEncryptionService.decrypt(
githubCredential.get().getCiphertext(), githubCredential.get().getKeyVersion());
String plToken =
patEncryptionService.decrypt(
plCredential.get().getCiphertext(), plCredential.get().getKeyVersion());
// (3) Sanity-check access with both PATs before touching any data.
boolean canWrite;
try {
canWrite = githubService.hasWriteAccess(plRepo.getRepoName(), githubToken);
} catch (HttpClientErrorException e) {
throw new Exception(
"The stored GitHub PAT cannot read repo %s (HTTP %d); check the token on the /profile page and the repo on the PrairieLearn tab"
.formatted(plRepo.getRepoName(), e.getStatusCode().value()));
}
if (!canWrite) {
throw new Exception(
"The stored GitHub PAT has read-only access to repo %s; read/write access is required"
.formatted(plRepo.getRepoName()));
}
PrairieLearnService.CourseInstanceInfo instanceInfo;
try {
instanceInfo = prairieLearnService.getCourseInstance(plInstance.getNumericId(), plToken);
} catch (HttpClientErrorException e) {
throw new Exception(
"The stored PrairieLearn PAT cannot access course instance %d (HTTP %d); check the token on the /profile page"
.formatted(plInstance.getNumericId(), e.getStatusCode().value()));
}
if (instanceInfo == null) {
throw new Exception(
"PrairieLearn returned no data for course instance %d"
.formatted(plInstance.getNumericId()));
}
ctx.log(
"Access verified: repo %s (read/write) and PrairieLearn instance %d"
.formatted(plRepo.getRepoName(), plInstance.getNumericId()));
// (4) Sanity-check the instance metadata instead of repopulating the PlInstance table.
sanityCheckInstance(ctx, plInstance, instanceInfo);
try {
syncAssessmentSets(ctx, plRepo, githubToken);
syncQuestions(ctx, plRepo, githubToken);
syncAssessments(ctx, plRepo, plInstance, githubToken);
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
throw new Exception(
"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)"
.formatted(e.getStatusCode().value()));
}
// (5) Copy the PrairieLearn-side assessment fields onto the matching rows.
enrichAssessmentsFromPrairieLearn(ctx, plRepo, plInstance, plToken);
}
/**
* Keeps the stored shortName/longName in line with what PrairieLearn reports for the instance's
* numeric id — the association was verified when it was created, so a difference here means the
* instance was renamed on the PrairieLearn side.
*/
private void sanityCheckInstance(
JobContext ctx, PlInstance plInstance, PrairieLearnService.CourseInstanceInfo info) {
boolean changed = false;
if (!Objects.equals(info.shortName(), plInstance.getShortName())) {
ctx.log(
"Instance shortName changed on PrairieLearn: %s -> %s"
.formatted(plInstance.getShortName(), info.shortName()));
plInstance.setShortName(info.shortName());
changed = true;
}
if (!Objects.equals(info.longName(), plInstance.getLongName())) {
ctx.log(
"Instance longName changed on PrairieLearn: %s -> %s"
.formatted(plInstance.getLongName(), info.longName()));
plInstance.setLongName(info.longName());
changed = true;
}
if (changed) {
plInstanceRepository.save(plInstance);
} else {
ctx.log("Instance %s metadata verified".formatted(plInstance.getShortName()));
}
}
/**
* Syncs the repo's assessment sets (issue #93) from the {@code assessmentSets} array of the
* top-level {@code infoCourse.json} — assessment sets are global to the whole course repo, not
* per-instance. Each entry becomes a PlAssessmentSet row, matched by abbreviation; stale rows
* (abbreviations no longer present) are deleted. If the file is missing (HTTP 404) or cannot be
* parsed, the sync is skipped entirely so a token/access problem cannot mass-delete rows.
*/
private void syncAssessmentSets(JobContext ctx, PlRepo plRepo, String token) {
String content;
try {
content = githubService.getFileContent(plRepo.getRepoName(), INFO_COURSE_JSON, token);
} catch (HttpClientErrorException.NotFound e) {
ctx.log(
"Repo %s has no %s; skipping assessment set sync"
.formatted(plRepo.getRepoName(), INFO_COURSE_JSON));
return;
}
JsonNode root;
try {
root = MAPPER.readTree(content);
} catch (Exception e) {
ctx.log(
"Skipping assessment set sync for repo %s: could not parse %s"
.formatted(plRepo.getRepoName(), INFO_COURSE_JSON));
return;
}
JsonNode assessmentSets = root.get(ASSESSMENT_SETS_KEY);
if (assessmentSets == null) {
ctx.log(
"Repo %s's %s has no %s key; skipping assessment set sync"
.formatted(plRepo.getRepoName(), INFO_COURSE_JSON, ASSESSMENT_SETS_KEY));
return;
}
if (!assessmentSets.isArray()) {
ctx.log(
"Repo %s's %s has a non-array %s value; skipping assessment set sync"
.formatted(plRepo.getRepoName(), INFO_COURSE_JSON, ASSESSMENT_SETS_KEY));
return;
}
Map<String, PlAssessmentSet> existingByAbbreviation = new LinkedHashMap<>();
for (PlAssessmentSet set : plAssessmentSetRepository.findByPlRepoId(plRepo.getId())) {
existingByAbbreviation.put(set.getAbbreviation(), set);
}
int added = 0;
int updated = 0;
int unchanged = 0;
int skipped = 0;
Set<String> foundAbbreviations = new LinkedHashSet<>();
for (JsonNode entry : assessmentSets) {
String abbreviation = entry.path("abbreviation").asText(null);
String name = entry.path("name").asText(null);
String heading = entry.path("heading").asText(null);
String color = entry.path("color").asText(null);
if (abbreviation == null || name == null || heading == null || color == null) {
skipped++;
ctx.log(
"Skipping assessment set entry %s (repo %s): missing abbreviation, name, heading, or color"
.formatted(entry, plRepo.getRepoName()));
continue;
}
foundAbbreviations.add(abbreviation);
PlAssessmentSet set = existingByAbbreviation.get(abbreviation);
if (set == null) {
plAssessmentSetRepository.save(
PlAssessmentSet.builder()
.plRepoId(plRepo.getId())
.abbreviation(abbreviation)
.name(name)
.heading(heading)
.color(color)
.build());
added++;
} else if (!Objects.equals(set.getName(), name)
|| !Objects.equals(set.getHeading(), heading)
|| !Objects.equals(set.getColor(), color)) {
set.setName(name);
set.setHeading(heading);
set.setColor(color);
plAssessmentSetRepository.save(set);
updated++;
} else {
unchanged++;
}
}
int deleted = 0;
for (String abbreviation : existingByAbbreviation.keySet()) {
if (!foundAbbreviations.contains(abbreviation)) {
plAssessmentSetRepository.delete(existingByAbbreviation.get(abbreviation));
deleted++;
}
}
ctx.log(
"Assessment sets: %d added, %d updated, %d deleted, %d unchanged, %d skipped"
.formatted(added, updated, deleted, unchanged, skipped));
}
private void syncQuestions(JobContext ctx, PlRepo plRepo, String token) {
Map<String, QuestionInfo> foundQuestions = new LinkedHashMap<>();
try {
walkQuestionsDirectory(ctx, plRepo, token, QUESTIONS_PATH, "", foundQuestions);
} catch (HttpClientErrorException.NotFound e) {
ctx.log(
"Repo %s has no %s directory (or the token cannot see the repo); skipping question sync"
.formatted(plRepo.getRepoName(), QUESTIONS_PATH));
return;
}
Map<String, PlQuestion> existingByQuestionId = new LinkedHashMap<>();
for (PlQuestion question : plQuestionRepository.findByPlRepoId(plRepo.getId())) {
existingByQuestionId.put(question.getQuestionId(), question);
}
int added = 0;
int updated = 0;
int unchanged = 0;
for (Map.Entry<String, QuestionInfo> entry : foundQuestions.entrySet()) {
String questionId = entry.getKey();
QuestionInfo info = entry.getValue();
PlQuestion existing = existingByQuestionId.get(questionId);
if (existing == null) {
plQuestionRepository.save(
PlQuestion.builder()
.plRepoId(plRepo.getId())
.questionId(questionId)
.uuid(info.uuid())
.title(info.title())
.build());
added++;
ctx.log("Added question %s (%s)".formatted(questionId, info.title()));
} else if (!existing.getUuid().equals(info.uuid())
|| !existing.getTitle().equals(info.title())) {
existing.setUuid(info.uuid());
existing.setTitle(info.title());
plQuestionRepository.save(existing);
updated++;
ctx.log("Updated question %s (%s)".formatted(questionId, info.title()));
} else {
unchanged++;
}
}
int deleted = 0;
List<String> staleQuestionIds =
existingByQuestionId.keySet().stream()
.filter(questionId -> !foundQuestions.containsKey(questionId))
.sorted()
.toList();
for (String questionId : staleQuestionIds) {
PlQuestion stale = existingByQuestionId.get(questionId);
plScaffoldAssessmentRepository.deleteByPlQuestionId(stale.getId());
plAssessmentQuestionRepository.deleteByPlQuestionId(stale.getId());
plQuestionRepository.delete(stale);
deleted++;
ctx.log("Deleted question %s (no longer on GitHub)".formatted(questionId));
}
ctx.log(
"Questions: %d added, %d updated, %d deleted, %d unchanged"
.formatted(added, updated, deleted, unchanged));
}
/** Syncs assessments from GitHub for the course's own instance only. */
private void syncAssessments(JobContext ctx, PlRepo plRepo, PlInstance instance, String token) {
Map<String, PlQuestion> questionsByQuestionId = new LinkedHashMap<>();
for (PlQuestion question : plQuestionRepository.findByPlRepoId(plRepo.getId())) {
questionsByQuestionId.put(question.getQuestionId(), question);
}
int added = 0;
int deleted = 0;
int unchanged = 0;
String assessmentsPath =
"%s/%s/%s".formatted(COURSE_INSTANCES_PATH, instance.getShortName(), ASSESSMENTS_DIRECTORY);
List<DirectoryEntry> entries;
try {
entries = githubService.listDirectory(plRepo.getRepoName(), assessmentsPath, token);
} catch (HttpClientErrorException.NotFound e) {
ctx.log(
"Instance %s has no %s directory; skipping assessment sync"
.formatted(instance.getShortName(), ASSESSMENTS_DIRECTORY));
return;
}
Map<String, PlAssessment> existingByName = new LinkedHashMap<>();
for (PlAssessment assessment :
plAssessmentRepository.findByPlRepoIdAndPlInstanceId(plRepo.getId(), instance.getId())) {
existingByName.put(assessment.getName(), assessment);
}
Set<String> foundNames = new LinkedHashSet<>();
for (DirectoryEntry entry : entries) {
if (!"dir".equals(entry.type())) {
continue;
}
String assessmentPath = assessmentsPath + "/" + entry.name();
boolean hasInfoAssessment =
githubService.listDirectory(plRepo.getRepoName(), assessmentPath, token).stream()
.anyMatch(e -> INFO_ASSESSMENT_JSON.equals(e.name()) && "file".equals(e.type()));
if (!hasInfoAssessment) {
continue;
}
String content =
githubService.getFileContent(
plRepo.getRepoName(), assessmentPath + "/" + INFO_ASSESSMENT_JSON, token);
JsonNode root;
try {
root = MAPPER.readTree(content);
} catch (Exception e) {
ctx.log(
"Skipping assessment %s (instance %s): could not parse %s"
.formatted(entry.name(), instance.getShortName(), INFO_ASSESSMENT_JSON));
continue;
}
foundNames.add(entry.name());
PlAssessment assessment = existingByName.get(entry.name());
if (assessment == null) {
assessment =
plAssessmentRepository.save(
PlAssessment.builder()
.plRepoId(plRepo.getId())
.plInstanceId(instance.getId())
.name(entry.name())
.build());
added++;
ctx.log(
"Added assessment %s (instance %s)".formatted(entry.name(), instance.getShortName()));
} else {
unchanged++;
}
// get("zones") is null when the key is absent; collectQuestionIds treats that as no links
syncAssessmentQuestions(
ctx, plRepo, instance, assessment, root.get("zones"), questionsByQuestionId);
}
List<String> staleNames =
existingByName.keySet().stream()
.filter(name -> !foundNames.contains(name))
.sorted()
.toList();
for (String name : staleNames) {
PlAssessment stale = existingByName.get(name);
plAssessmentQuestionRepository.deleteByPlAssessmentId(stale.getId());
plAssessmentRepository.delete(stale);
deleted++;
ctx.log(
"Deleted assessment %s (instance %s) (no longer on GitHub)"
.formatted(name, instance.getShortName()));
}
ctx.log("Assessments: %d added, %d deleted, %d unchanged".formatted(added, deleted, unchanged));
}
/**
* Copies the PrairieLearn-side fields (issue #71) onto the PlAssessment rows of this instance,
* matched by assessment name. A PrairieLearn assessment with no matching repo row (or the
* reverse) is logged and left alone; the GitHub sync is the source of truth for which rows exist.
*/
private void enrichAssessmentsFromPrairieLearn(
JobContext ctx, PlRepo plRepo, PlInstance plInstance, String plToken) {
List<PrairieLearnService.AssessmentInfo> plAssessments;
try {
plAssessments = prairieLearnService.getAssessments(plInstance.getNumericId(), plToken);
} catch (HttpClientErrorException e) {
ctx.log(
"Could not list assessments from PrairieLearn (HTTP %d); skipping assessment field updates"
.formatted(e.getStatusCode().value()));
return;
}
Map<String, PlAssessment> existingByName = new LinkedHashMap<>();
for (PlAssessment assessment :
plAssessmentRepository.findByPlRepoIdAndPlInstanceId(plRepo.getId(), plInstance.getId())) {
existingByName.put(assessment.getName(), assessment);
}
int updated = 0;
int unmatched = 0;
for (PrairieLearnService.AssessmentInfo info : plAssessments) {
PlAssessment assessment = existingByName.get(info.assessmentName());
if (assessment == null) {
unmatched++;
ctx.log(
"PrairieLearn assessment %s has no matching assessment directory in the repo; skipping"
.formatted(info.assessmentName()));
continue;
}
assessment.setPlAssessmentId(info.assessmentId());
assessment.setPlAssessmentNumber(info.assessmentNumber());
assessment.setPlAssessmentOrder(info.assessmentOrderBy());
assessment.setPlAssessmentTitle(info.title());
assessment.setPlAssessmentSetAbbreviation(info.assessmentSetAbbreviation());
assessment.setPlAssessmentSetNumber(info.assessmentSetNumber());
assessment.setPlAssessmentSetHeading(info.assessmentSetHeading());
assessment.setPlAssessmentSetColor(info.assessmentSetColor());
plAssessmentRepository.save(assessment);
updated++;
ctx.log("Updated PrairieLearn fields for assessment %s".formatted(info.assessmentName()));
}
ctx.log(
"PrairieLearn assessment fields: %d updated, %d without a matching repo assessment"
.formatted(updated, unmatched));
}
/**
* Rewrites the assessment's question list (join rows, in zone order) to match the ids referenced
* by the zones node of its infoAssessment.json. Ids that don't match any PlQuestion of this repo
* are logged and skipped. If the list already matches, the rows are left untouched.
*/
private void syncAssessmentQuestions(
JobContext ctx,
PlRepo plRepo,
PlInstance instance,
PlAssessment assessment,
JsonNode zones,
Map<String, PlQuestion> questionsByQuestionId) {
Set<String> referencedIds = new LinkedHashSet<>();
collectQuestionIds(zones, referencedIds);
List<Long> desiredQuestionRowIds = new ArrayList<>();
for (String questionId : referencedIds) {
PlQuestion question = questionsByQuestionId.get(questionId);
if (question == null) {
ctx.log(
"Assessment %s (instance %s) references unknown question id %s; skipping that link"
.formatted(assessment.getName(), instance.getShortName(), questionId));
continue;
}
desiredQuestionRowIds.add(question.getId());
}
List<Long> currentQuestionRowIds =
plAssessmentQuestionRepository
.findByPlAssessmentIdOrderByOrdinalAsc(assessment.getId())
.stream()
.map(PlAssessmentQuestion::getPlQuestionId)
.toList();
if (currentQuestionRowIds.equals(desiredQuestionRowIds)) {
return;
}
plAssessmentQuestionRepository.deleteByPlAssessmentId(assessment.getId());
// flush so the deletes hit the database before the re-inserts; Hibernate otherwise orders
// inserts first within the transaction, violating the (assessment, question) unique constraint
plAssessmentQuestionRepository.flush();
for (int i = 0; i < desiredQuestionRowIds.size(); i++) {
plAssessmentQuestionRepository.save(
PlAssessmentQuestion.builder()
.plRepoId(plRepo.getId())
.plAssessmentId(assessment.getId())
.plQuestionId(desiredQuestionRowIds.get(i))
.ordinal(i)
.build());
}
ctx.log(
"Linked %d question(s) to assessment %s (instance %s)"
.formatted(
desiredQuestionRowIds.size(), assessment.getName(), instance.getShortName()));
}
/**
* Recursively collects the value of every {@code "id"} key in the JSON tree under the zones node
* of an infoAssessment.json — each one references a question — preserving document order and
* dropping duplicates.
*/
static void collectQuestionIds(JsonNode node, Set<String> ids) {
if (node == null) {
return;
}
if (node.isObject() && node.hasNonNull("id")) {
ids.add(node.get("id").asText());
}
for (JsonNode child : node) {
collectQuestionIds(child, ids);
}
}
/**
* Recursively walks a directory under {@code questions}. {@code questionId} is the path relative
* to the questions directory ("" for the questions directory itself, which is never a question).
*/
private void walkQuestionsDirectory(
JobContext ctx,
PlRepo plRepo,
String token,
String path,
String questionId,
Map<String, QuestionInfo> foundQuestions) {
List<DirectoryEntry> entries = githubService.listDirectory(plRepo.getRepoName(), path, token);
boolean hasInfoJson =
entries.stream()
.anyMatch(entry -> INFO_JSON.equals(entry.name()) && "file".equals(entry.type()));
if (hasInfoJson && !questionId.isEmpty()) {
String content =
githubService.getFileContent(plRepo.getRepoName(), path + "/" + INFO_JSON, token);
QuestionInfo info = parseInfoJson(ctx, questionId, content);
if (info != null) {
foundQuestions.put(questionId, info);
}
return; // a question directory's subdirectories belong to the question; don't recurse
}
for (DirectoryEntry entry : entries) {
if (!"dir".equals(entry.type())) {
continue;
}
if (DRAFTS_DIRECTORY.equals(entry.name())) {
ctx.log("Skipping directory %s/%s".formatted(path, entry.name()));
continue;
}
String childQuestionId =
questionId.isEmpty() ? entry.name() : questionId + "/" + entry.name();
walkQuestionsDirectory(
ctx, plRepo, token, path + "/" + entry.name(), childQuestionId, foundQuestions);
}
}
/** Returns the parsed uuid/title, or null (with a log message) if info.json is unusable. */
private QuestionInfo parseInfoJson(JobContext ctx, String questionId, String content) {
try {
JsonNode node = MAPPER.readTree(content);
if (!node.hasNonNull("uuid") || !node.hasNonNull("title")) {
ctx.log("Skipping question %s: info.json is missing uuid or title".formatted(questionId));
return null;
}
return new QuestionInfo(
UUID.fromString(node.get("uuid").asText()), node.get("title").asText());
} catch (Exception e) {
ctx.log("Skipping question %s: could not parse info.json".formatted(questionId));
return null;
}
}
}