Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 31 additions & 33 deletions core/src/main/java/io/getskipper/core/SheetsClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,20 @@
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;

/**
* Reads test entries from a Google Sheets spreadsheet using a service account.
*/
public final class SheetsClient {

private static final String APPLICATION_NAME = "skipper-java";
private static final DateTimeFormatter DATE_ONLY = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter DATE_TIME_UTC =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
private static final DateTimeFormatter DATE_TIME_NO_TZ =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private static final Pattern DATE_RE = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$");

private final SkipperConfig config;

Expand Down Expand Up @@ -168,11 +162,7 @@ private SheetFetchResult fetchSheet(Sheets service, String sheetName, int sheetI
&& row.get(disabledUntilIdx) != null) {
String raw = row.get(disabledUntilIdx).toString().strip();
if (!raw.isBlank()) {
disabledUntil = parseDate(raw);
if (disabledUntil == null) {
SkipperLogger.warn("Row " + (i + 1) + " in \"" + sheetName
+ "\": invalid date \"" + raw + "\" — treating test as enabled.");
}
disabledUntil = parseDate(raw, i + 1, sheetName);
}
}

Expand Down Expand Up @@ -233,25 +223,33 @@ private static int indexOf(List<String> header, String column) {
return -1;
}

private static Instant parseDate(String s) {
// Try "yyyy-MM-dd" → treat as end-of-day UTC so the full day is disabled
try {
LocalDate date = LocalDate.parse(s, DATE_ONLY);
return date.atTime(23, 59, 59).toInstant(ZoneOffset.UTC);
} catch (DateTimeParseException ignored) {}

// Try "yyyy-MM-dd'T'HH:mm:ss'Z'"
try {
LocalDateTime dt = LocalDateTime.parse(s, DATE_TIME_UTC);
return dt.toInstant(ZoneOffset.UTC);
} catch (DateTimeParseException ignored) {}

// Try "yyyy-MM-dd'T'HH:mm:ss"
try {
LocalDateTime dt = LocalDateTime.parse(s, DATE_TIME_NO_TZ);
return dt.toInstant(ZoneOffset.UTC);
} catch (DateTimeParseException ignored) {}

return null;
/**
* Parses a {@code disabledUntil} date string.
*
* <p>Only {@code YYYY-MM-DD} is accepted. Malformed values throw immediately so bad
* spreadsheet data is caught at startup, not silently mid-run.
*
* <p>The returned instant is the start of the <em>following</em> UTC day, so a test
* marked disabled until {@code 2026-04-01} remains disabled through the end of that
* calendar day (UTC) and re-enables at {@code 2026-04-02T00:00:00Z}. This comparison
* is timezone-independent: {@code Instant.now().isBefore(result)} yields the same
* answer on every CI runner regardless of JVM default timezone.
*
* @param raw raw cell value
* @param rowNum 1-based row number, used in the error message
* @param sheetName sheet name, used in the error message
* @return the expiry instant, or {@code null} if {@code raw} is null or blank
* @throws IllegalArgumentException if {@code raw} does not match {@code YYYY-MM-DD}
*/
static Instant parseDate(String raw, int rowNum, String sheetName) {
if (raw == null || raw.isBlank()) return null;
String trimmed = raw.strip();
if (!DATE_RE.matcher(trimmed).matches()) {
throw new IllegalArgumentException(
"[skipper] Row " + rowNum + " in \"" + sheetName
+ "\": invalid disabledUntil \"" + raw + "\". Use YYYY-MM-DD.");
}
LocalDate date = LocalDate.parse(trimmed);
return date.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant();
}
}
76 changes: 72 additions & 4 deletions core/src/main/java/io/getskipper/core/SheetsWriter.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package io.getskipper.core;

import com.google.api.services.sheets.v4.Sheets;
import com.google.api.services.sheets.v4.model.BatchUpdateSpreadsheetRequest;
import com.google.api.services.sheets.v4.model.DeleteDimensionRequest;
import com.google.api.services.sheets.v4.model.DimensionRange;
import com.google.api.services.sheets.v4.model.Request;
import com.google.api.services.sheets.v4.model.ValueRange;
import java.io.IOException;
import java.util.ArrayList;
Expand Down Expand Up @@ -37,9 +41,15 @@ public SheetsWriter(SkipperConfig config, Sheets service) {
}

/**
* Appends any discovered test IDs that are not yet in the sheet.
* Existing rows are never deleted, making this operation safe to call from multiple
* Gradle test tasks in a multi-module build.
* Reconciles the spreadsheet with the test IDs discovered during a test run.
*
* <ul>
* <li>Appends rows for test IDs not yet in the sheet.</li>
* <li>Detects orphaned rows (IDs in the sheet that were not discovered). By default
* these are logged but <em>not</em> deleted, making sync safe for multi-module
* projects where different Gradle tasks each contribute a subset of the full suite.
* Set {@code SKIPPER_SYNC_ALLOW_DELETE=true} to actually prune them.</li>
* </ul>
*
* @param discoveredTestIds all test IDs discovered during the test run
*/
Expand All @@ -55,11 +65,46 @@ public void sync(Collection<String> discoveredTestIds) throws IOException {
List<String> header = primary.header();
int testIdColIdx = indexOf(header, config.testIdColumn());

Set<String> discoveredNormalized = new HashSet<>();
for (String id : discoveredTestIds) {
discoveredNormalized.add(TestIdHelper.normalize(id));
}

Set<String> existingIds = new HashSet<>();
for (TestEntry entry : primary.entries()) {
existingIds.add(TestIdHelper.normalize(entry.testId()));
}

// Detect orphaned rows (in sheet but not in discoveredTestIds).
// rawRows[0] is the header; data rows start at index 1.
List<Integer> orphanedRowIndices = new ArrayList<>();
List<List<Object>> rawRows = primary.rawRows();
for (int i = 1; i < rawRows.size(); i++) {
List<Object> row = rawRows.get(i);
if (testIdColIdx >= row.size()) continue;
Object cell = row.get(testIdColIdx);
if (cell == null) continue;
String id = cell.toString().strip();
if (id.isBlank()) continue;
if (!discoveredNormalized.contains(TestIdHelper.normalize(id))) {
orphanedRowIndices.add(i);
}
}

// Handle orphaned rows.
boolean allowDeletes = "true".equalsIgnoreCase(System.getenv("SKIPPER_SYNC_ALLOW_DELETE"));
if (!orphanedRowIndices.isEmpty()) {
if (!allowDeletes) {
SkipperLogger.logf("[skipper] %d orphaned row(s) found in \"%s\".",
orphanedRowIndices.size(), sheetName);
SkipperLogger.log("[skipper] Set SKIPPER_SYNC_ALLOW_DELETE=true to prune them.");
} else {
deleteRows(spreadsheetId, primary.sheetId(), orphanedRowIndices);
SkipperLogger.logf("Sync: deleted %d orphaned row(s).", orphanedRowIndices.size());
}
}

// Append new test IDs.
Set<String> toAdd = new LinkedHashSet<>();
for (String id : discoveredTestIds) {
if (!existingIds.contains(TestIdHelper.normalize(id))) {
Expand All @@ -68,7 +113,7 @@ public void sync(Collection<String> discoveredTestIds) throws IOException {
}

if (toAdd.isEmpty()) {
SkipperLogger.log("Sync: spreadsheet is already up to date.");
SkipperLogger.log("Sync: no new test IDs to append.");
return;
}

Expand All @@ -87,6 +132,29 @@ public void sync(Collection<String> discoveredTestIds) throws IOException {
SkipperLogger.logf("Sync: appended %d new test ID(s).", toAdd.size());
}

/**
* Deletes the specified sheet rows (0-based indices) in a single batch update.
* Rows are deleted in reverse order so earlier indices remain valid after each deletion.
*/
private void deleteRows(String spreadsheetId, int sheetId, List<Integer> rowIndices)
throws IOException {
List<Request> requests = new ArrayList<>();
// Iterate in reverse to avoid index shifting
for (int i = rowIndices.size() - 1; i >= 0; i--) {
int idx = rowIndices.get(i);
requests.add(new Request().setDeleteDimension(
new DeleteDimensionRequest().setRange(
new DimensionRange()
.setSheetId(sheetId)
.setDimension("ROWS")
.setStartIndex(idx)
.setEndIndex(idx + 1))));
}
service.spreadsheets()
.batchUpdate(spreadsheetId, new BatchUpdateSpreadsheetRequest().setRequests(requests))
.execute();
}

private static int indexOf(List<String> header, String column) {
for (int i = 0; i < header.size(); i++) {
if (column.equals(header.get(i))) return i;
Expand Down
100 changes: 93 additions & 7 deletions core/src/main/java/io/getskipper/core/SkipperResolver.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.api.services.sheets.v4.Sheets;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -37,17 +40,100 @@ public SkipperResolver(SkipperConfig config) {
/**
* Fetches the spreadsheet and populates the in-memory cache.
* Must be called before {@link #isTestEnabled(String)}.
*
* <p>Behaviour is controlled by three environment variables:
* <ul>
* <li>{@code SKIPPER_CACHE_FILE} (default: {@code .skipper-cache.json}) — path to the
* on-disk cache file that is written after every successful fetch.</li>
* <li>{@code SKIPPER_CACHE_TTL} (default: {@code 300}) — maximum age in seconds for the
* on-disk cache to be used as a fallback when the API is unreachable.</li>
* <li>{@code SKIPPER_FAIL_OPEN} (default: {@code true}) — if the API fails <em>and</em>
* no valid cache exists, run all tests instead of throwing. Set to {@code false} to
* restore the original crash behaviour.</li>
* </ul>
*/
public void initialize() throws IOException {
SheetsClient client = new SheetsClient(config);
FetchAllResult result = client.fetchAll();
this.sheetsService = result.service();
String cacheFile = System.getenv().getOrDefault("SKIPPER_CACHE_FILE", ".skipper-cache.json");
int cacheTtl = Integer.parseInt(
System.getenv().getOrDefault("SKIPPER_CACHE_TTL", "300"));
boolean failOpen = !"false".equalsIgnoreCase(
System.getenv().getOrDefault("SKIPPER_FAIL_OPEN", "true"));

try {
SheetsClient client = new SheetsClient(config);
FetchAllResult result = client.fetchAll();
this.sheetsService = result.service();

cache = new HashMap<>();
for (TestEntry entry : result.entries()) {
cache.put(TestIdHelper.normalize(entry.testId()), entry.disabledUntil());
}
SkipperLogger.logf("Resolver initialized with %d cached entries.", cache.size());
writeCacheFile(cacheFile, cache);
} catch (Exception err) {
Map<String, Instant> fromCache = readCacheFile(cacheFile, cacheTtl);
if (fromCache != null) {
cache = fromCache;
SkipperLogger.logf("[skipper] API failed, using on-disk cache (%s).", cacheFile);
return;
}
if (failOpen) {
cache = Collections.emptyMap();
SkipperLogger.log("[skipper] API failed, no valid cache — running all tests (fail-open).");
return;
}
if (err instanceof IOException ioEx) throw ioEx;
throw new IOException("[skipper] Initialization failed", err);
}
}

cache = new HashMap<>();
for (TestEntry entry : result.entries()) {
cache.put(TestIdHelper.normalize(entry.testId()), entry.disabledUntil());
/**
* Writes the current in-memory cache to disk as JSON so it can be used as a fallback
* on subsequent runs if the Sheets API is unavailable.
*/
void writeCacheFile(String path, Map<String, Instant> entries) {
try {
Map<String, Object> payload = new HashMap<>();
payload.put("savedAt", Instant.now().toString());
Map<String, String> serialized = new HashMap<>();
for (Map.Entry<String, Instant> e : entries.entrySet()) {
serialized.put(e.getKey(), e.getValue() != null ? e.getValue().toString() : null);
}
payload.put("entries", serialized);
Files.writeString(Path.of(path), MAPPER.writeValueAsString(payload));
} catch (Exception e) {
SkipperLogger.warn("[skipper] Could not write cache file \"" + path + "\": " + e.getMessage());
}
}

/**
* Reads the on-disk cache file and returns the entries if the file exists and is within
* the TTL window. Returns {@code null} if the file is missing, unreadable, or expired.
*/
static Map<String, Instant> readCacheFile(String path, int ttlSeconds) {
try {
String json = Files.readString(Path.of(path));
Map<String, Object> payload = MAPPER.readValue(json, new TypeReference<>() {});
String savedAtStr = (String) payload.get("savedAt");
if (savedAtStr == null) return null;
Instant savedAt = Instant.parse(savedAtStr);
long ageSeconds = Instant.now().getEpochSecond() - savedAt.getEpochSecond();
if (ageSeconds > ttlSeconds) {
SkipperLogger.logf("[skipper] Cache file \"%s\" is %ds old (TTL=%ds) — ignoring.",
path, ageSeconds, ttlSeconds);
return null;
}
@SuppressWarnings("unchecked")
Map<String, String> raw = (Map<String, String>) payload.get("entries");
if (raw == null) return null;
Map<String, Instant> result = new HashMap<>();
for (Map.Entry<String, String> e : raw.entrySet()) {
result.put(e.getKey(), e.getValue() != null ? Instant.parse(e.getValue()) : null);
}
return result;
} catch (Exception e) {
return null;
}
SkipperLogger.logf("Resolver initialized with %d cached entries.", cache.size());
}

/**
Expand Down
Loading
Loading