From 5262e2e1405fb0016b683dbd05281076b6057b27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:12:42 +0000 Subject: [PATCH 01/10] Initial plan From 3bca8bc4873fd76de7dcd87e43c82c486eca4195 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:17:52 +0000 Subject: [PATCH 02/10] Add daemon mode with scheduled backups and Docker support Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .env.example | 7 ++ Dockerfile | 34 ++++++++ README.md | 66 +++++++++++++++ docker-compose.yml | 23 +++++ docker-entrypoint.sh | 13 +++ .../github/backup/GhBackupApplication.java | 2 + .../github/backup/ScheduledBackupService.java | 84 +++++++++++++++++++ .../resources/application-daemon.properties | 7 ++ 8 files changed, 236 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 src/main/java/com/github/backup/ScheduledBackupService.java create mode 100644 src/main/resources/application-daemon.properties diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e0e84c --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# GitHub Personal Access Token (optional but recommended for higher API rate limits) +# Get one from: https://github.com/settings/tokens +GITHUB_TOKEN=your_github_token_here + +# Comma-separated list of GitHub users/organizations to backup +# Example: SCHEDULED_USERS=octocat,github,spring-projects +SCHEDULED_USERS=octocat diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a0e0f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn clean package -DskipTests + +# Runtime stage +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app + +# Install git (required for cloning repositories) +RUN apk add --no-cache git + +# Copy the built jar from build stage +COPY --from=build /app/target/gh-backup-1.0.0.jar gh-backup.jar + +# Create backup directory +RUN mkdir -p /backups + +# Set default environment variables +ENV BACKUP_DIRECTORY=/backups +ENV GITHUB_TOKEN="" +ENV SCHEDULED_USERS="" + +# Expose port for web mode (optional) +EXPOSE 8080 + +# Create entrypoint script +COPY docker-entrypoint.sh /app/ +RUN chmod +x /app/docker-entrypoint.sh + +# Default to daemon mode with scheduled backups +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 858d40c..eacfcc4 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A tool built with Spring Boot to backup public GitHub repositories for specified ## Features - **Web UI** for easy backup management through your browser +- **Daemon Mode** with Docker support for automatic daily backups - Backup all public repositories from one or more GitHub users/organizations - Clone new repositories or update existing ones - **Interactive mode** for easier management and status viewing @@ -32,6 +33,71 @@ The executable JAR will be created at `target/gh-backup-1.0.0.jar` ## Usage +### Daemon Mode with Docker (Automatic Daily Backups) + +The easiest way to run automatic daily backups is using Docker. This will spin up a background service that backs up your configured repositories every 24 hours. + +#### Quick Start with Docker Compose + +1. Create a `.env` file (or copy from `.env.example`): +```bash +cp .env.example .env +``` + +2. Edit `.env` to configure your backups: +```bash +GITHUB_TOKEN=your_github_token_here +SCHEDULED_USERS=octocat,github,spring-projects +``` + +3. Start the daemon service: +```bash +docker-compose up -d +``` + +The service will: +- Run the first backup immediately +- Continue running in the background +- Automatically backup every 24 hours +- Persist backups to the `./backups` directory on your host machine + +4. View logs: +```bash +docker-compose logs -f +``` + +5. Stop the service: +```bash +docker-compose down +``` + +#### Using Docker directly + +Build the image: +```bash +docker build -t gh-backup . +``` + +Run the daemon: +```bash +docker run -d \ + -e GITHUB_TOKEN=your_token \ + -e SCHEDULED_USERS=octocat,github \ + -v $(pwd)/backups:/backups \ + --name gh-backup-daemon \ + gh-backup +``` + +#### Running without Docker + +You can also run daemon mode directly with Java: +```bash +java -Dspring.profiles.active=daemon \ + -Dbackup.scheduled.users=octocat,github \ + -Dbackup.directory=/path/to/backups \ + -jar target/gh-backup-1.0.0.jar +``` + ### Web UI Mode (Recommended) Start the web server: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..386b4de --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + gh-backup-daemon: + build: . + container_name: gh-backup-daemon + restart: unless-stopped + environment: + # Set your GitHub personal access token here for higher API rate limits + - GITHUB_TOKEN=${GITHUB_TOKEN:-} + + # Comma-separated list of GitHub users/organizations to backup + # Example: SCHEDULED_USERS=octocat,github,spring-projects + - SCHEDULED_USERS=${SCHEDULED_USERS:-} + + # Optional: Custom backup directory inside container + - BACKUP_DIRECTORY=/backups + volumes: + # Mount a local directory to persist backups + - ./backups:/backups + # Keep container running + stdin_open: true + tty: true diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..0704c61 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -e + +# Build Java command with environment variables +JAVA_OPTS="-Dspring.profiles.active=daemon" +JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}" + +if [ -n "$SCHEDULED_USERS" ]; then + JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}" +fi + +# Execute the application +exec java $JAVA_OPTS -jar gh-backup.jar "$@" diff --git a/src/main/java/com/github/backup/GhBackupApplication.java b/src/main/java/com/github/backup/GhBackupApplication.java index a5de447..24e7abb 100644 --- a/src/main/java/com/github/backup/GhBackupApplication.java +++ b/src/main/java/com/github/backup/GhBackupApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class GhBackupApplication { public static void main(String[] args) { diff --git a/src/main/java/com/github/backup/ScheduledBackupService.java b/src/main/java/com/github/backup/ScheduledBackupService.java new file mode 100644 index 0000000..0964f19 --- /dev/null +++ b/src/main/java/com/github/backup/ScheduledBackupService.java @@ -0,0 +1,84 @@ +package com.github.backup; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; + +/** + * Service that runs scheduled backups at configured intervals. + * Enabled when backup.mode=daemon. + */ +@Service +@ConditionalOnProperty(name = "backup.mode", havingValue = "daemon") +public class ScheduledBackupService { + + private final BackupService backupService; + private final List scheduledUsers; + private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public ScheduledBackupService(BackupService backupService, + @Value("${backup.scheduled.users:}") String scheduledUsersConfig) { + this.backupService = backupService; + // Parse comma-separated list of users/orgs + this.scheduledUsers = scheduledUsersConfig.isBlank() + ? List.of() + : Arrays.stream(scheduledUsersConfig.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + @PostConstruct + public void init() { + if (scheduledUsers.isEmpty()) { + System.out.println("⚠ Warning: No users/organizations configured for scheduled backups."); + System.out.println("Set the 'backup.scheduled.users' property with a comma-separated list."); + System.out.println("Example: backup.scheduled.users=octocat,github"); + } else { + System.out.println("GitHub Backup Daemon Mode"); + System.out.println("========================"); + System.out.println("Scheduled backups enabled for: " + String.join(", ", scheduledUsers)); + System.out.println("Backup interval: Every 24 hours"); + System.out.println("First backup will run immediately, then every 24 hours."); + System.out.println(); + } + } + + /** + * Runs backup for all configured users/organizations. + * Executes on startup and then every 24 hours (86400000 milliseconds). + */ + @Scheduled(fixedRate = 86400000, initialDelay = 0) + public void runScheduledBackup() { + if (scheduledUsers.isEmpty()) { + return; + } + + String timestamp = LocalDateTime.now().format(dateTimeFormatter); + System.out.println("\n" + "=".repeat(80)); + System.out.println("Starting scheduled backup at " + timestamp); + System.out.println("=".repeat(80)); + + for (String userOrOrg : scheduledUsers) { + try { + backupService.backupUserRepositories(userOrOrg); + } catch (IOException e) { + System.err.println("Error backing up " + userOrOrg + ": " + e.getMessage()); + } + } + + timestamp = LocalDateTime.now().format(dateTimeFormatter); + System.out.println("\n" + "=".repeat(80)); + System.out.println("Scheduled backup completed at " + timestamp); + System.out.println("Next backup will run in 24 hours."); + System.out.println("=".repeat(80) + "\n"); + } +} diff --git a/src/main/resources/application-daemon.properties b/src/main/resources/application-daemon.properties new file mode 100644 index 0000000..013b64c --- /dev/null +++ b/src/main/resources/application-daemon.properties @@ -0,0 +1,7 @@ +# Daemon mode configuration +spring.main.web-application-type=none +backup.mode=daemon + +# Comma-separated list of GitHub users/organizations to backup automatically +# Example: backup.scheduled.users=octocat,github,spring-projects +backup.scheduled.users= From 630156221d9c4db3b8f96f36c6f184d4e9fd03d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:19:24 +0000 Subject: [PATCH 03/10] Add tests for scheduled backup service Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .../backup/ScheduledBackupServiceTest.java | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/test/java/com/github/backup/ScheduledBackupServiceTest.java diff --git a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java new file mode 100644 index 0000000..f439135 --- /dev/null +++ b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java @@ -0,0 +1,109 @@ +package com.github.backup; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ScheduledBackupServiceTest { + + @Mock + private BackupService backupService; + + private ScheduledBackupService scheduledBackupService; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testScheduledBackupService_WithNoUsers() { + scheduledBackupService = new ScheduledBackupService(backupService, ""); + + // Should not throw exception with empty users list + assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); + + // Should not call backup service when no users configured + verifyNoInteractions(backupService); + } + + @Test + void testScheduledBackupService_WithSingleUser() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat"); + + scheduledBackupService.runScheduledBackup(); + + // Should call backup service once for the configured user + verify(backupService, times(1)).backupUserRepositories("octocat"); + } + + @Test + void testScheduledBackupService_WithMultipleUsers() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github,spring-projects"); + + scheduledBackupService.runScheduledBackup(); + + // Should call backup service for each configured user + verify(backupService, times(1)).backupUserRepositories("octocat"); + verify(backupService, times(1)).backupUserRepositories("github"); + verify(backupService, times(1)).backupUserRepositories("spring-projects"); + } + + @Test + void testScheduledBackupService_WithWhitespace() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, " octocat , github , spring-projects "); + + scheduledBackupService.runScheduledBackup(); + + // Should trim whitespace from user names + verify(backupService, times(1)).backupUserRepositories("octocat"); + verify(backupService, times(1)).backupUserRepositories("github"); + verify(backupService, times(1)).backupUserRepositories("spring-projects"); + } + + @Test + void testScheduledBackupService_HandlesException() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github"); + + doThrow(new IOException("Test exception")).when(backupService).backupUserRepositories("octocat"); + + // Should continue to next user even if one fails + assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); + + verify(backupService, times(1)).backupUserRepositories("octocat"); + verify(backupService, times(1)).backupUserRepositories("github"); + } + + @Test + void testScheduledBackupService_WithEmptyStrings() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,,github"); + + scheduledBackupService.runScheduledBackup(); + + // Should filter out empty strings + verify(backupService, times(1)).backupUserRepositories("octocat"); + verify(backupService, times(1)).backupUserRepositories("github"); + verify(backupService, times(2)).backupUserRepositories(anyString()); + } + + @Test + void testScheduledBackupService_WithBlankString() { + scheduledBackupService = new ScheduledBackupService(backupService, " "); + + assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); + + // Should not call backup service with blank configuration + verifyNoInteractions(backupService); + } +} From ae861ddf2cc2f7b9cb889871b00d6568532183fa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:20:51 +0000 Subject: [PATCH 04/10] Fix test issues based on code review feedback Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .../com/github/backup/ScheduledBackupServiceTest.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java index f439135..3d3e585 100644 --- a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java +++ b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java @@ -2,12 +2,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import java.io.IOException; -import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -19,9 +17,6 @@ class ScheduledBackupServiceTest { private ScheduledBackupService scheduledBackupService; - @TempDir - Path tempDir; - @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); @@ -91,10 +86,10 @@ void testScheduledBackupService_WithEmptyStrings() throws IOException { scheduledBackupService.runScheduledBackup(); - // Should filter out empty strings + // Should filter out empty strings and only call for valid users verify(backupService, times(1)).backupUserRepositories("octocat"); verify(backupService, times(1)).backupUserRepositories("github"); - verify(backupService, times(2)).backupUserRepositories(anyString()); + verifyNoMoreInteractions(backupService); } @Test From a5d3d6848f9b0f975e4532dbd358df21dac64cae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:22:28 +0000 Subject: [PATCH 05/10] Address security and code quality issues from review Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- docker-entrypoint.sh | 4 ++-- .../com/github/backup/ScheduledBackupService.java | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0704c61..8bbc17c 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,10 +3,10 @@ set -e # Build Java command with environment variables JAVA_OPTS="-Dspring.profiles.active=daemon" -JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}" +JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=\"${BACKUP_DIRECTORY}\"" if [ -n "$SCHEDULED_USERS" ]; then - JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}" + JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=\"${SCHEDULED_USERS}\"" fi # Execute the application diff --git a/src/main/java/com/github/backup/ScheduledBackupService.java b/src/main/java/com/github/backup/ScheduledBackupService.java index 0964f19..e936089 100644 --- a/src/main/java/com/github/backup/ScheduledBackupService.java +++ b/src/main/java/com/github/backup/ScheduledBackupService.java @@ -20,6 +20,9 @@ @ConditionalOnProperty(name = "backup.mode", havingValue = "daemon") public class ScheduledBackupService { + private static final long BACKUP_INTERVAL_MS = 86400000; // 24 hours in milliseconds + private static final int SEPARATOR_LENGTH = 80; + private final BackupService backupService; private final List scheduledUsers; private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @@ -54,18 +57,18 @@ public void init() { /** * Runs backup for all configured users/organizations. - * Executes on startup and then every 24 hours (86400000 milliseconds). + * Executes on startup and then every 24 hours. */ - @Scheduled(fixedRate = 86400000, initialDelay = 0) + @Scheduled(fixedRate = BACKUP_INTERVAL_MS, initialDelay = 0) public void runScheduledBackup() { if (scheduledUsers.isEmpty()) { return; } String timestamp = LocalDateTime.now().format(dateTimeFormatter); - System.out.println("\n" + "=".repeat(80)); + System.out.println("\n" + "=".repeat(SEPARATOR_LENGTH)); System.out.println("Starting scheduled backup at " + timestamp); - System.out.println("=".repeat(80)); + System.out.println("=".repeat(SEPARATOR_LENGTH)); for (String userOrOrg : scheduledUsers) { try { @@ -76,9 +79,9 @@ public void runScheduledBackup() { } timestamp = LocalDateTime.now().format(dateTimeFormatter); - System.out.println("\n" + "=".repeat(80)); + System.out.println("\n" + "=".repeat(SEPARATOR_LENGTH)); System.out.println("Scheduled backup completed at " + timestamp); System.out.println("Next backup will run in 24 hours."); - System.out.println("=".repeat(80) + "\n"); + System.out.println("=".repeat(SEPARATOR_LENGTH) + "\n"); } } From 721d02abff80af835f6909454e6e4dd0b721d4bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:59:26 +0000 Subject: [PATCH 06/10] Address PR review feedback - make scheduling conditional, use fixedDelay, add configurability Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- Dockerfile | 5 +- docker-compose.yml | 2 - docker-entrypoint.sh | 4 +- .../github/backup/GhBackupApplication.java | 2 - .../github/backup/ScheduledBackupService.java | 47 ++++++++++--------- .../backup/SchedulingConfiguration.java | 15 ++++++ .../resources/application-daemon.properties | 4 ++ .../backup/ScheduledBackupServiceTest.java | 25 +++++++--- 8 files changed, 69 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/github/backup/SchedulingConfiguration.java diff --git a/Dockerfile b/Dockerfile index 6a0e0f2..c9cb0de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,8 @@ FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . COPY src ./src +# Skip tests during Docker build to speed up image creation +# Tests should be run in CI/CD pipeline before building the image RUN mvn clean package -DskipTests # Runtime stage @@ -23,7 +25,8 @@ ENV BACKUP_DIRECTORY=/backups ENV GITHUB_TOKEN="" ENV SCHEDULED_USERS="" -# Expose port for web mode (optional) +# Expose port 8080 - only used if running in web mode with -Dspring.profiles.active=web +# The default daemon mode does not use this port EXPOSE 8080 # Create entrypoint script diff --git a/docker-compose.yml b/docker-compose.yml index 386b4de..3c178bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: gh-backup-daemon: build: . diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8bbc17c..0704c61 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,10 +3,10 @@ set -e # Build Java command with environment variables JAVA_OPTS="-Dspring.profiles.active=daemon" -JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=\"${BACKUP_DIRECTORY}\"" +JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}" if [ -n "$SCHEDULED_USERS" ]; then - JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=\"${SCHEDULED_USERS}\"" + JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}" fi # Execute the application diff --git a/src/main/java/com/github/backup/GhBackupApplication.java b/src/main/java/com/github/backup/GhBackupApplication.java index 24e7abb..a5de447 100644 --- a/src/main/java/com/github/backup/GhBackupApplication.java +++ b/src/main/java/com/github/backup/GhBackupApplication.java @@ -2,10 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication -@EnableScheduling public class GhBackupApplication { public static void main(String[] args) { diff --git a/src/main/java/com/github/backup/ScheduledBackupService.java b/src/main/java/com/github/backup/ScheduledBackupService.java index e936089..957f3f6 100644 --- a/src/main/java/com/github/backup/ScheduledBackupService.java +++ b/src/main/java/com/github/backup/ScheduledBackupService.java @@ -1,5 +1,7 @@ package com.github.backup; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.scheduling.annotation.Scheduled; @@ -20,16 +22,19 @@ @ConditionalOnProperty(name = "backup.mode", havingValue = "daemon") public class ScheduledBackupService { - private static final long BACKUP_INTERVAL_MS = 86400000; // 24 hours in milliseconds + private static final Logger log = LoggerFactory.getLogger(ScheduledBackupService.class); private static final int SEPARATOR_LENGTH = 80; private final BackupService backupService; private final List scheduledUsers; + private final long backupIntervalMs; private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public ScheduledBackupService(BackupService backupService, - @Value("${backup.scheduled.users:}") String scheduledUsersConfig) { + @Value("${backup.scheduled.users:}") String scheduledUsersConfig, + @Value("${backup.scheduled.interval.ms:86400000}") long backupIntervalMs) { this.backupService = backupService; + this.backupIntervalMs = backupIntervalMs; // Parse comma-separated list of users/orgs this.scheduledUsers = scheduledUsersConfig.isBlank() ? List.of() @@ -42,46 +47,46 @@ public ScheduledBackupService(BackupService backupService, @PostConstruct public void init() { if (scheduledUsers.isEmpty()) { - System.out.println("⚠ Warning: No users/organizations configured for scheduled backups."); - System.out.println("Set the 'backup.scheduled.users' property with a comma-separated list."); - System.out.println("Example: backup.scheduled.users=octocat,github"); + log.warn("No users/organizations configured for scheduled backups."); + log.warn("Set the 'backup.scheduled.users' property with a comma-separated list."); + log.warn("Example: backup.scheduled.users=octocat,github"); } else { - System.out.println("GitHub Backup Daemon Mode"); - System.out.println("========================"); - System.out.println("Scheduled backups enabled for: " + String.join(", ", scheduledUsers)); - System.out.println("Backup interval: Every 24 hours"); - System.out.println("First backup will run immediately, then every 24 hours."); - System.out.println(); + log.info("GitHub Backup Daemon Mode"); + log.info("========================"); + log.info("Scheduled backups enabled for: {}", String.join(", ", scheduledUsers)); + log.info("Backup interval: {} hours", backupIntervalMs / 3600000.0); + log.info("First backup will run immediately, then every {} hours.", backupIntervalMs / 3600000.0); } } /** * Runs backup for all configured users/organizations. - * Executes on startup and then every 24 hours. + * Executes on startup and then at the configured interval after each completion. + * Uses fixedDelay to ensure the next backup starts only after the previous one completes. */ - @Scheduled(fixedRate = BACKUP_INTERVAL_MS, initialDelay = 0) + @Scheduled(fixedDelayString = "${backup.scheduled.interval.ms:86400000}", initialDelay = 0) public void runScheduledBackup() { if (scheduledUsers.isEmpty()) { return; } String timestamp = LocalDateTime.now().format(dateTimeFormatter); - System.out.println("\n" + "=".repeat(SEPARATOR_LENGTH)); - System.out.println("Starting scheduled backup at " + timestamp); - System.out.println("=".repeat(SEPARATOR_LENGTH)); + log.info("\n{}", "=".repeat(SEPARATOR_LENGTH)); + log.info("Starting scheduled backup at {}", timestamp); + log.info("{}", "=".repeat(SEPARATOR_LENGTH)); for (String userOrOrg : scheduledUsers) { try { backupService.backupUserRepositories(userOrOrg); } catch (IOException e) { - System.err.println("Error backing up " + userOrOrg + ": " + e.getMessage()); + log.error("Error backing up {}: {}", userOrOrg, e.getMessage()); } } timestamp = LocalDateTime.now().format(dateTimeFormatter); - System.out.println("\n" + "=".repeat(SEPARATOR_LENGTH)); - System.out.println("Scheduled backup completed at " + timestamp); - System.out.println("Next backup will run in 24 hours."); - System.out.println("=".repeat(SEPARATOR_LENGTH) + "\n"); + log.info("\n{}", "=".repeat(SEPARATOR_LENGTH)); + log.info("Scheduled backup completed at {}", timestamp); + log.info("Next backup will run {} hours after this backup completes.", backupIntervalMs / 3600000.0); + log.info("{}\n", "=".repeat(SEPARATOR_LENGTH)); } } diff --git a/src/main/java/com/github/backup/SchedulingConfiguration.java b/src/main/java/com/github/backup/SchedulingConfiguration.java new file mode 100644 index 0000000..6bb2842 --- /dev/null +++ b/src/main/java/com/github/backup/SchedulingConfiguration.java @@ -0,0 +1,15 @@ +package com.github.backup; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * Configuration to enable Spring scheduling only in daemon mode. + * This prevents scheduling from being activated in CLI and web modes. + */ +@Configuration +@EnableScheduling +@Profile("daemon") +public class SchedulingConfiguration { +} diff --git a/src/main/resources/application-daemon.properties b/src/main/resources/application-daemon.properties index 013b64c..68e6664 100644 --- a/src/main/resources/application-daemon.properties +++ b/src/main/resources/application-daemon.properties @@ -5,3 +5,7 @@ backup.mode=daemon # Comma-separated list of GitHub users/organizations to backup automatically # Example: backup.scheduled.users=octocat,github,spring-projects backup.scheduled.users= + +# Backup interval in milliseconds (default: 86400000 = 24 hours) +# Example: 3600000 = 1 hour, 43200000 = 12 hours +backup.scheduled.interval.ms=86400000 diff --git a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java index 3d3e585..6ad69d9 100644 --- a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java +++ b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java @@ -12,6 +12,8 @@ class ScheduledBackupServiceTest { + private static final long DEFAULT_INTERVAL = 86400000L; // 24 hours + @Mock private BackupService backupService; @@ -24,7 +26,7 @@ void setUp() { @Test void testScheduledBackupService_WithNoUsers() { - scheduledBackupService = new ScheduledBackupService(backupService, ""); + scheduledBackupService = new ScheduledBackupService(backupService, "", DEFAULT_INTERVAL); // Should not throw exception with empty users list assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); @@ -35,7 +37,7 @@ void testScheduledBackupService_WithNoUsers() { @Test void testScheduledBackupService_WithSingleUser() throws IOException { - scheduledBackupService = new ScheduledBackupService(backupService, "octocat"); + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", DEFAULT_INTERVAL); scheduledBackupService.runScheduledBackup(); @@ -45,7 +47,7 @@ void testScheduledBackupService_WithSingleUser() throws IOException { @Test void testScheduledBackupService_WithMultipleUsers() throws IOException { - scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github,spring-projects"); + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github,spring-projects", DEFAULT_INTERVAL); scheduledBackupService.runScheduledBackup(); @@ -57,7 +59,7 @@ void testScheduledBackupService_WithMultipleUsers() throws IOException { @Test void testScheduledBackupService_WithWhitespace() throws IOException { - scheduledBackupService = new ScheduledBackupService(backupService, " octocat , github , spring-projects "); + scheduledBackupService = new ScheduledBackupService(backupService, " octocat , github , spring-projects ", DEFAULT_INTERVAL); scheduledBackupService.runScheduledBackup(); @@ -69,7 +71,7 @@ void testScheduledBackupService_WithWhitespace() throws IOException { @Test void testScheduledBackupService_HandlesException() throws IOException { - scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github"); + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github", DEFAULT_INTERVAL); doThrow(new IOException("Test exception")).when(backupService).backupUserRepositories("octocat"); @@ -82,7 +84,7 @@ void testScheduledBackupService_HandlesException() throws IOException { @Test void testScheduledBackupService_WithEmptyStrings() throws IOException { - scheduledBackupService = new ScheduledBackupService(backupService, "octocat,,github"); + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,,github", DEFAULT_INTERVAL); scheduledBackupService.runScheduledBackup(); @@ -94,11 +96,20 @@ void testScheduledBackupService_WithEmptyStrings() throws IOException { @Test void testScheduledBackupService_WithBlankString() { - scheduledBackupService = new ScheduledBackupService(backupService, " "); + scheduledBackupService = new ScheduledBackupService(backupService, " ", DEFAULT_INTERVAL); assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); // Should not call backup service with blank configuration verifyNoInteractions(backupService); } + + @Test + void testScheduledBackupService_WithCustomInterval() { + long customInterval = 3600000L; // 1 hour + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", customInterval); + + // Should not throw exception with custom interval + assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); + } } From c52ad877bbafcd09931704c192731578ab35abc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:01:10 +0000 Subject: [PATCH 07/10] Fix logging format and add environment variable validation Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- docker-entrypoint.sh | 7 ++++++- .../java/com/github/backup/ScheduledBackupService.java | 9 ++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0704c61..526f695 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -3,8 +3,13 @@ set -e # Build Java command with environment variables JAVA_OPTS="-Dspring.profiles.active=daemon" -JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}" +# Set backup directory if provided (defaults to /backups in Dockerfile ENV) +if [ -n "$BACKUP_DIRECTORY" ]; then + JAVA_OPTS="$JAVA_OPTS -Dbackup.directory=${BACKUP_DIRECTORY}" +fi + +# Set scheduled users if provided if [ -n "$SCHEDULED_USERS" ]; then JAVA_OPTS="$JAVA_OPTS -Dbackup.scheduled.users=${SCHEDULED_USERS}" fi diff --git a/src/main/java/com/github/backup/ScheduledBackupService.java b/src/main/java/com/github/backup/ScheduledBackupService.java index 957f3f6..2d2d403 100644 --- a/src/main/java/com/github/backup/ScheduledBackupService.java +++ b/src/main/java/com/github/backup/ScheduledBackupService.java @@ -71,7 +71,8 @@ public void runScheduledBackup() { } String timestamp = LocalDateTime.now().format(dateTimeFormatter); - log.info("\n{}", "=".repeat(SEPARATOR_LENGTH)); + log.info(""); + log.info("{}", "=".repeat(SEPARATOR_LENGTH)); log.info("Starting scheduled backup at {}", timestamp); log.info("{}", "=".repeat(SEPARATOR_LENGTH)); @@ -84,9 +85,11 @@ public void runScheduledBackup() { } timestamp = LocalDateTime.now().format(dateTimeFormatter); - log.info("\n{}", "=".repeat(SEPARATOR_LENGTH)); + log.info(""); + log.info("{}", "=".repeat(SEPARATOR_LENGTH)); log.info("Scheduled backup completed at {}", timestamp); log.info("Next backup will run {} hours after this backup completes.", backupIntervalMs / 3600000.0); - log.info("{}\n", "=".repeat(SEPARATOR_LENGTH)); + log.info("{}", "=".repeat(SEPARATOR_LENGTH)); + log.info(""); } } From b2504118e4ad2bc9a96a0a82b1557ad0618464ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 02:31:52 +0000 Subject: [PATCH 08/10] Expand unit tests to prove daemon mode functionality Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .../backup/DaemonModeIntegrationTest.java | 95 +++++++++ .../backup/ScheduledBackupServiceTest.java | 194 +++++++++++++++++- .../backup/SchedulingConfigurationTest.java | 77 +++++++ 3 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/github/backup/DaemonModeIntegrationTest.java create mode 100644 src/test/java/com/github/backup/SchedulingConfigurationTest.java diff --git a/src/test/java/com/github/backup/DaemonModeIntegrationTest.java b/src/test/java/com/github/backup/DaemonModeIntegrationTest.java new file mode 100644 index 0000000..a8102ef --- /dev/null +++ b/src/test/java/com/github/backup/DaemonModeIntegrationTest.java @@ -0,0 +1,95 @@ +package com.github.backup; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration test for daemon mode to verify the entire setup works correctly. + */ +@SpringBootTest +@ActiveProfiles("daemon") +@TestPropertySource(properties = { + "backup.mode=daemon", + "backup.scheduled.users=testuser1,testuser2", + "backup.scheduled.interval.ms=3600000" +}) +class DaemonModeIntegrationTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void daemonModeBeansAreLoaded() { + // Verify SchedulingConfiguration is loaded + assertTrue(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration should be loaded in daemon mode"); + + // Verify ScheduledBackupService is loaded + assertTrue(applicationContext.containsBean("scheduledBackupService"), + "ScheduledBackupService should be loaded in daemon mode"); + } + + @Test + void scheduledBackupServiceIsConfiguredCorrectly() { + ScheduledBackupService service = applicationContext.getBean(ScheduledBackupService.class); + assertNotNull(service, "ScheduledBackupService should be available"); + + // The service should be instantiated without errors + assertDoesNotThrow(() -> service.runScheduledBackup(), + "runScheduledBackup should execute without errors"); + } + + @Test + void backupServiceIsAvailable() { + // BackupService should be available for ScheduledBackupService to use + assertTrue(applicationContext.containsBean("backupService"), + "BackupService should be available in daemon mode"); + } + + @Test + void gitHubServiceIsAvailable() { + // GitHubService should be available for BackupService to use + assertTrue(applicationContext.containsBean("gitHubService"), + "GitHubService should be available in daemon mode"); + } + + @Test + void applicationContextLoadsSuccessfully() { + // The application context should load without errors + assertNotNull(applicationContext, "Application context should be loaded"); + + // Verify we're in daemon profile + String[] activeProfiles = applicationContext.getEnvironment().getActiveProfiles(); + assertEquals(1, activeProfiles.length, "Should have exactly one active profile"); + assertEquals("daemon", activeProfiles[0], "Active profile should be daemon"); + } + + @Test + void scheduledUsersAreConfigured() { + // Verify the test properties are being picked up + String scheduledUsers = applicationContext.getEnvironment().getProperty("backup.scheduled.users"); + assertNotNull(scheduledUsers, "scheduled users property should be set"); + assertTrue(scheduledUsers.contains("testuser1"), "Should contain testuser1"); + assertTrue(scheduledUsers.contains("testuser2"), "Should contain testuser2"); + } + + @Test + void customIntervalIsConfigured() { + // Verify custom interval is configured + String interval = applicationContext.getEnvironment().getProperty("backup.scheduled.interval.ms"); + assertEquals("3600000", interval, "Interval should be set to 1 hour (3600000ms)"); + } + + @Test + void backupModeIsSetToDaemon() { + // Verify backup.mode is set to daemon + String backupMode = applicationContext.getEnvironment().getProperty("backup.mode"); + assertEquals("daemon", backupMode, "backup.mode should be set to daemon"); + } +} diff --git a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java index 6ad69d9..fe0154b 100644 --- a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java +++ b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java @@ -1,11 +1,18 @@ package com.github.backup; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.slf4j.LoggerFactory; import java.io.IOException; +import java.util.List; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -13,15 +20,31 @@ class ScheduledBackupServiceTest { private static final long DEFAULT_INTERVAL = 86400000L; // 24 hours + private static final long ONE_HOUR_INTERVAL = 3600000L; // 1 hour @Mock private BackupService backupService; private ScheduledBackupService scheduledBackupService; + private ListAppender logAppender; + private Logger logger; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); + + // Set up log appender to capture log messages + logger = (Logger) LoggerFactory.getLogger(ScheduledBackupService.class); + logAppender = new ListAppender<>(); + logAppender.start(); + logger.addAppender(logAppender); + } + + @AfterEach + void tearDown() { + if (logAppender != null) { + logger.detachAppender(logAppender); + } } @Test @@ -106,10 +129,177 @@ void testScheduledBackupService_WithBlankString() { @Test void testScheduledBackupService_WithCustomInterval() { - long customInterval = 3600000L; // 1 hour - scheduledBackupService = new ScheduledBackupService(backupService, "octocat", customInterval); + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", ONE_HOUR_INTERVAL); // Should not throw exception with custom interval assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup()); } + + @Test + void testInit_WithNoUsers_LogsWarning() { + scheduledBackupService = new ScheduledBackupService(backupService, "", DEFAULT_INTERVAL); + scheduledBackupService.init(); + + // Verify warning logs are present + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getLevel() == Level.WARN && + event.getFormattedMessage().contains("No users/organizations configured")), + "Should log warning when no users configured"); + } + + @Test + void testInit_WithUsers_LogsInfo() { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github", DEFAULT_INTERVAL); + scheduledBackupService.init(); + + // Verify info logs are present + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getLevel() == Level.INFO && + event.getFormattedMessage().contains("GitHub Backup Daemon Mode")), + "Should log info header when users are configured"); + + assertTrue(logsList.stream().anyMatch(event -> + event.getLevel() == Level.INFO && + event.getFormattedMessage().contains("Scheduled backups enabled for: octocat, github")), + "Should log configured users"); + } + + @Test + void testInit_DisplaysCorrectIntervalInHours() { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", ONE_HOUR_INTERVAL); + scheduledBackupService.init(); + + // Verify interval is displayed correctly + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("1.0 hours")), + "Should display interval as 1.0 hours for 3600000ms"); + } + + @Test + void testInit_DisplaysDefault24HourInterval() { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", DEFAULT_INTERVAL); + scheduledBackupService.init(); + + // Verify 24 hour interval is displayed + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("24.0 hours")), + "Should display interval as 24.0 hours for default interval"); + } + + @Test + void testRunScheduledBackup_LogsStartAndComplete() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", DEFAULT_INTERVAL); + scheduledBackupService.runScheduledBackup(); + + // Verify backup start and completion are logged + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("Starting scheduled backup")), + "Should log when backup starts"); + + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("Scheduled backup completed")), + "Should log when backup completes"); + } + + @Test + void testRunScheduledBackup_LogsErrorForFailedUser() throws IOException { + scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github", DEFAULT_INTERVAL); + + doThrow(new IOException("Network error")).when(backupService).backupUserRepositories("octocat"); + + scheduledBackupService.runScheduledBackup(); + + // Verify error is logged for failed user + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getLevel() == Level.ERROR && + event.getFormattedMessage().contains("Error backing up octocat") && + event.getFormattedMessage().contains("Network error")), + "Should log error with user name and error message"); + + // Verify github backup was still attempted + verify(backupService, times(1)).backupUserRepositories("github"); + } + + @Test + void testRunScheduledBackup_WithNoUsers_NoLogsGenerated() { + scheduledBackupService = new ScheduledBackupService(backupService, "", DEFAULT_INTERVAL); + + logAppender.list.clear(); // Clear any init logs + scheduledBackupService.runScheduledBackup(); + + // Should not generate any logs when no users configured + assertTrue(logAppender.list.isEmpty() || logAppender.list.stream().noneMatch(event -> + event.getFormattedMessage().contains("Starting scheduled backup")), + "Should not log backup start/complete when no users configured"); + + verifyNoInteractions(backupService); + } + + @Test + void testMultipleUserNames_ParsedCorrectly() throws IOException { + scheduledBackupService = new ScheduledBackupService( + backupService, + "user1,user2,user3,user4,user5", + DEFAULT_INTERVAL + ); + + scheduledBackupService.runScheduledBackup(); + + // Verify all 5 users are backed up + verify(backupService, times(1)).backupUserRepositories("user1"); + verify(backupService, times(1)).backupUserRepositories("user2"); + verify(backupService, times(1)).backupUserRepositories("user3"); + verify(backupService, times(1)).backupUserRepositories("user4"); + verify(backupService, times(1)).backupUserRepositories("user5"); + verify(backupService, times(5)).backupUserRepositories(anyString()); + } + + @Test + void testSpecialCharactersInUserNames() throws IOException { + // Test that usernames with hyphens and underscores work correctly + scheduledBackupService = new ScheduledBackupService( + backupService, + "user-with-dash,user_with_underscore,user.with.dots", + DEFAULT_INTERVAL + ); + + scheduledBackupService.runScheduledBackup(); + + verify(backupService, times(1)).backupUserRepositories("user-with-dash"); + verify(backupService, times(1)).backupUserRepositories("user_with_underscore"); + verify(backupService, times(1)).backupUserRepositories("user.with.dots"); + } + + @Test + void testIntervalAccuracy_SmallInterval() { + long twoHours = 7200000L; // 2 hours + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", twoHours); + scheduledBackupService.init(); + + // Verify correct hour calculation for 2 hours + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("2.0 hours")), + "Should display interval as 2.0 hours for 7200000ms"); + } + + @Test + void testIntervalAccuracy_LargeInterval() { + long oneWeek = 604800000L; // 7 days = 168 hours + scheduledBackupService = new ScheduledBackupService(backupService, "octocat", oneWeek); + scheduledBackupService.init(); + + // Verify correct hour calculation for 168 hours (1 week) + List logsList = logAppender.list; + assertTrue(logsList.stream().anyMatch(event -> + event.getFormattedMessage().contains("168.0 hours")), + "Should display interval as 168.0 hours for 604800000ms"); + } } + diff --git a/src/test/java/com/github/backup/SchedulingConfigurationTest.java b/src/test/java/com/github/backup/SchedulingConfigurationTest.java new file mode 100644 index 0000000..859234c --- /dev/null +++ b/src/test/java/com/github/backup/SchedulingConfigurationTest.java @@ -0,0 +1,77 @@ +package com.github.backup; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Integration tests for SchedulingConfiguration to verify that + * scheduling is only enabled in daemon profile. + */ +class SchedulingConfigurationTest { + + @Nested + @SpringBootTest + @ActiveProfiles("daemon") + class DaemonProfileTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void schedulingConfigurationShouldBeLoadedInDaemonProfile() { + // SchedulingConfiguration bean should exist in daemon profile + assertTrue(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration should be present in daemon profile"); + + // ScheduledAnnotationBeanPostProcessor should be present when scheduling is enabled + assertNotNull(applicationContext.getBean(ScheduledAnnotationBeanPostProcessor.class), + "ScheduledAnnotationBeanPostProcessor should be present when scheduling is enabled"); + } + + @Test + void scheduledBackupServiceShouldBeLoadedInDaemonProfile() { + // In daemon profile with backup.mode=daemon, ScheduledBackupService should be loaded + // Note: This requires backup.mode=daemon to be set + assertTrue(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration bean should be present in daemon profile"); + } + } + + @Nested + @SpringBootTest + @ActiveProfiles("web") + class WebProfileTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void schedulingConfigurationShouldNotBeLoadedInWebProfile() { + // SchedulingConfiguration bean should NOT exist in web profile + assertFalse(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration should NOT be present in web profile"); + } + } + + @Nested + @SpringBootTest + class DefaultProfileTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void schedulingConfigurationShouldNotBeLoadedInDefaultProfile() { + // SchedulingConfiguration bean should NOT exist without daemon profile + assertFalse(applicationContext.containsBean("schedulingConfiguration"), + "SchedulingConfiguration should NOT be present in default profile"); + } + } +} From 32f772d7d98d440704f55482a43fe418df3fe68b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 02:55:05 +0000 Subject: [PATCH 09/10] Add CI workflow to run unit tests Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .github/workflows/ci.yml | 90 ++++++++++++++++++++++++++++++++++++++++ README.md | 2 + 2 files changed, 92 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a056e13 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Run Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Run tests + run: mvn clean test + + - name: Generate test report + if: always() + uses: dorny/test-reporter@v1 + with: + name: Maven Tests + path: target/surefire-reports/*.xml + reporter: java-junit + fail-on-error: true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: target/surefire-reports/ + retention-days: 30 + + build: + name: Build Application + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Build with Maven + run: mvn clean package -DskipTests + + - name: Upload JAR artifact + uses: actions/upload-artifact@v4 + with: + name: gh-backup-jar + path: target/*.jar + retention-days: 30 + + docker-build: + name: Docker Build Test + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: gh-backup:test + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/README.md b/README.md index eacfcc4..1241599 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # gh-backup +![CI](https://github.com/dmccoystephenson/gh-backup/workflows/CI/badge.svg) + A tool built with Spring Boot to backup public GitHub repositories for specified users or organizations. Available as both a command-line tool and a web application. ## Features From 0f91b7be28b8f10a10d439d2ac079bd50aefee69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:31:16 +0000 Subject: [PATCH 10/10] Fix CI workflow by removing test-reporter action that requires write permissions Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com> --- .github/workflows/ci.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a056e13..b3d356f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,15 +24,6 @@ jobs: - name: Run tests run: mvn clean test - - - name: Generate test report - if: always() - uses: dorny/test-reporter@v1 - with: - name: Maven Tests - path: target/surefire-reports/*.xml - reporter: java-junit - fail-on-error: true - name: Upload test results if: always()