ReadPLColorsJob.java

package edu.ucsb.cs.scaffold.jobs;

import edu.ucsb.cs.scaffold.entity.PatCredential;
import edu.ucsb.cs.scaffold.entity.PlColor;
import edu.ucsb.cs.scaffold.enums.PatPlatform;
import edu.ucsb.cs.scaffold.repository.PatCredentialRepository;
import edu.ucsb.cs.scaffold.repository.PlColorRepository;
import edu.ucsb.cs.scaffold.services.GithubService;
import edu.ucsb.cs.scaffold.services.PatEncryptionService;
import edu.ucsb.cs156.jobs.services.JobContext;
import edu.ucsb.cs156.jobs.services.JobContextConsumer;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.Builder;
import org.springframework.web.client.HttpClientErrorException;

/**
 * Reads PrairieLearn's badge color map from GitHub (issue #96) and upserts the {@code pl_color}
 * table with any colors that are new or whose hex code has changed. Launched by an admin (POST
 * /api/jobs/launch/readPLColors), using that admin's own stored GitHub PAT.
 *
 * <p>PrairieLearn does not expose these colors through an API, so this job reads the raw {@code
 * colors.scss} stylesheet from the public PrairieLearn repo and locates the {@code $custom-colors:
 * ( 'name': #hex, ... )} Sass map with a regular expression, rather than a full SCSS parser.
 */
@Builder
public class ReadPLColorsJob implements JobContextConsumer {

  static final String PL_REPO_NAME = "PrairieLearn/PrairieLearn";
  static final String COLORS_SCSS_PATH = "apps/prairielearn/public/stylesheets/colors.scss";

  // Matches the $custom-colors: ( ... ); Sass map as a whole, non-greedily, so that only the
  // color map's own entries (and not any later parentheses in the file) are captured.
  static final Pattern COLOR_MAP_PATTERN =
      Pattern.compile("\\$custom-colors\\s*:\\s*\\((.*?)\\)\\s*;", Pattern.DOTALL);

  // Matches a single 'name': #hex entry within the color map.
  static final Pattern COLOR_ENTRY_PATTERN =
      Pattern.compile("['\"]([a-zA-Z0-9_-]+)['\"]\\s*:\\s*#([0-9a-fA-F]{3,8})");

  private long userId;
  private PatCredentialRepository patCredentialRepository;
  private PatEncryptionService patEncryptionService;
  private GithubService githubService;
  private PlColorRepository plColorRepository;

  @Override
  public void accept(JobContext ctx) throws Exception {
    ctx.log("Reading PrairieLearn colors from %s".formatted(COLORS_SCSS_PATH));

    Optional<PatCredential> githubCredential =
        patCredentialRepository.findByUserIdAndPlatform(userId, PatPlatform.GITHUB);
    if (githubCredential.isEmpty()) {
      String message =
          "No GitHub PAT is set up for this user; set one up on the /profile page before running"
              + " this job";
      ctx.log(message);
      throw new Exception(message);
    }
    String githubToken =
        patEncryptionService.decrypt(
            githubCredential.get().getCiphertext(), githubCredential.get().getKeyVersion());

    String scss;
    try {
      scss = githubService.getFileContent(PL_REPO_NAME, COLORS_SCSS_PATH, githubToken);
    } catch (HttpClientErrorException e) {
      String message =
          "Could not read %s from %s (HTTP %d); the file may not exist or may not be accessible"
              .formatted(COLORS_SCSS_PATH, PL_REPO_NAME, e.getStatusCode().value());
      ctx.log(message);
      throw new Exception(message);
    }

    if (scss.isBlank()) {
      String message =
          "%s in %s is empty or could not be read".formatted(COLORS_SCSS_PATH, PL_REPO_NAME);
      ctx.log(message);
      throw new Exception(message);
    }

    Matcher mapMatcher = COLOR_MAP_PATTERN.matcher(scss);
    if (!mapMatcher.find()) {
      String message =
          "%s does not contain the expected $custom-colors color mapping"
              .formatted(COLORS_SCSS_PATH);
      ctx.log(message);
      throw new Exception(message);
    }

    String colorMapBody = mapMatcher.group(1);
    Matcher entryMatcher = COLOR_ENTRY_PATTERN.matcher(colorMapBody);

    int added = 0;
    int updated = 0;
    int unchanged = 0;
    boolean anyEntries = false;
    while (entryMatcher.find()) {
      anyEntries = true;
      String colorName = entryMatcher.group(1);
      String hexCode = "#" + entryMatcher.group(2).toLowerCase();

      Optional<PlColor> existing = plColorRepository.findById(colorName);
      if (existing.isEmpty()) {
        plColorRepository.save(PlColor.builder().colorName(colorName).hexCode(hexCode).build());
        added++;
        ctx.log("Added color %s: %s".formatted(colorName, hexCode));
      } else if (!existing.get().getHexCode().equalsIgnoreCase(hexCode)) {
        PlColor color = existing.get();
        String oldHexCode = color.getHexCode();
        color.setHexCode(hexCode);
        plColorRepository.save(color);
        updated++;
        ctx.log("Updated color %s: %s -> %s".formatted(colorName, oldHexCode, hexCode));
      } else {
        unchanged++;
      }
    }

    if (!anyEntries) {
      String message =
          "%s did not contain any 'name': #hex color entries".formatted(COLORS_SCSS_PATH);
      ctx.log(message);
      throw new Exception(message);
    }

    ctx.log("Done: %d added, %d updated, %d unchanged".formatted(added, updated, unchanged));
  }
}