diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index fb22214..dbd6c5d 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -30,5 +30,8 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Spotless, Checkstyle, compile, and test run: ./gradlew check --no-daemon diff --git a/backend/apps/api/src/main/java/dev/cleat/api/.gitkeep b/backend/apps/api/src/main/java/dev/cleat/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/api/src/main/resources/application.yml b/backend/apps/api/src/main/resources/application.yml index 31322b9..6bde21e 100644 --- a/backend/apps/api/src/main/resources/application.yml +++ b/backend/apps/api/src/main/resources/application.yml @@ -3,11 +3,11 @@ spring: name: cleat-api autoconfigure: exclude: - # Temporary: database is not wired yet. - # Remove these excludes once datasource is configured in issue #5. - - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration - - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration - - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration + # Temporary: database is not wired yet. + # Remove these excludes once datasource is configured in issue #5. + - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration + - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration + - org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration server: port: 8080 @@ -22,5 +22,3 @@ management: enabled: true redis: enabled: false - db: - enabled: false diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 734c508..9cacf33 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -1,5 +1,4 @@ import com.diffplug.gradle.spotless.SpotlessExtension -import org.gradle.api.plugins.quality.CheckstyleExtension import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { @@ -36,7 +35,8 @@ subprojects { } } - configure { + + extensions.configure { java { palantirJavaFormat() removeUnusedImports() @@ -46,7 +46,7 @@ subprojects { } } - configure { + extensions.configure { toolVersion = "10.26.1" configFile = rootProject.file("config/checkstyle/checkstyle.xml") isIgnoreFailures = false @@ -55,4 +55,4 @@ subprojects { tasks.withType { useJUnitPlatform() } -} +} \ No newline at end of file diff --git a/backend/gradle.properties b/backend/gradle.properties index 8c5fc5c..97f8878 100644 --- a/backend/gradle.properties +++ b/backend/gradle.properties @@ -1,3 +1,4 @@ org.gradle.caching=true org.gradle.parallel=true org.gradle.configuration-cache=true + diff --git a/backend/gradlew b/backend/gradlew old mode 100755 new mode 100644 diff --git a/backend/libs/persistence/build.gradle.kts b/backend/libs/persistence/build.gradle.kts index 4da8a80..c79da08 100644 --- a/backend/libs/persistence/build.gradle.kts +++ b/backend/libs/persistence/build.gradle.kts @@ -1,13 +1,17 @@ plugins { `java-library` } - dependencies { api(project(":libs:domain")) implementation(project(":libs:common")) api("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.flywaydb:flyway-core") - implementation("org.flywaydb:flyway-database-postgresql") + runtimeOnly("org.flywaydb:flyway-database-postgresql") runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.testcontainers:junit-jupiter") + testImplementation("org.testcontainers:postgresql") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } + + diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountEntity.java new file mode 100644 index 0000000..33cb645 --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountEntity.java @@ -0,0 +1,200 @@ +package dev.cleat.persistence; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.hibernate.annotations.CreationTimestamp; + +@Entity +@Table(name = "account") +public class AccountEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "login", nullable = false) + private String login; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "type", nullable = false) + @Enumerated(EnumType.STRING) + private AccountType type; + + @Column(name = "plan") + @Enumerated(EnumType.STRING) + private Plan plan; + + @Column(name = "repo_count") + private Integer repoCount; + + @Column(name = "member_count") + private Integer memberCount; + + @Column(name = "posture_score") + private Integer postureScore; + + @Column(name = "monthly_spend") + private BigDecimal monthlySpend; + + @Column(name = "reclaimable") + private BigDecimal reclaimable; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @OneToMany(mappedBy = "account", cascade = CascadeType.ALL, orphanRemoval = true) + private List repos = new ArrayList<>(); + + public AccountEntity( + UUID id, + String login, + String name, + AccountType type, + Plan plan, + Integer repoCount, + Integer memberCount, + Integer postureScore, + BigDecimal monthlySpend, + BigDecimal reclaimable, + OffsetDateTime createdAt, + List repos) { + this.id = id; + this.login = login; + this.name = name; + this.type = type; + this.plan = plan; + this.repoCount = repoCount; + this.memberCount = memberCount; + this.postureScore = postureScore; + this.monthlySpend = monthlySpend; + this.reclaimable = reclaimable; + this.createdAt = createdAt; + this.repos = repos; + } + + public AccountEntity() {} + + public UUID getId() { + return id; + } + + public AccountEntity setId(UUID id) { + this.id = id; + return this; + } + + public String getLogin() { + return login; + } + + public AccountEntity setLogin(String login) { + this.login = login; + return this; + } + + public String getName() { + return name; + } + + public AccountEntity setName(String name) { + this.name = name; + return this; + } + + public AccountType getType() { + return type; + } + + public AccountEntity setType(AccountType type) { + this.type = type; + return this; + } + + public Plan getPlan() { + return plan; + } + + public AccountEntity setPlan(Plan plan) { + this.plan = plan; + return this; + } + + public Integer getRepoCount() { + return repoCount; + } + + public AccountEntity setRepoCount(Integer repoCount) { + this.repoCount = repoCount; + return this; + } + + public Integer getMemberCount() { + return memberCount; + } + + public AccountEntity setMemberCount(Integer memberCount) { + this.memberCount = memberCount; + return this; + } + + public Integer getPostureScore() { + return postureScore; + } + + public AccountEntity setPostureScore(Integer postureScore) { + this.postureScore = postureScore; + return this; + } + + public BigDecimal getMonthlySpend() { + return monthlySpend; + } + + public AccountEntity setMonthlySpend(BigDecimal monthlySpend) { + this.monthlySpend = monthlySpend; + return this; + } + + public BigDecimal getReclaimable() { + return reclaimable; + } + + public AccountEntity setReclaimable(BigDecimal reclaimable) { + this.reclaimable = reclaimable; + return this; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public AccountEntity setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + return this; + } + + public List getRepos() { + return repos; + } + + public AccountEntity setRepos(List repos) { + this.repos = repos; + return this; + } +} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountRepository.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountRepository.java new file mode 100644 index 0000000..822d78a --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountRepository.java @@ -0,0 +1,6 @@ +package dev.cleat.persistence; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AccountRepository extends JpaRepository {} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountType.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountType.java new file mode 100644 index 0000000..d792f6a --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/AccountType.java @@ -0,0 +1,6 @@ +package dev.cleat.persistence; + +public enum AccountType { + USER, + ORG +} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/Plan.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/Plan.java new file mode 100644 index 0000000..752828f --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/Plan.java @@ -0,0 +1,6 @@ +package dev.cleat.persistence; + +public enum Plan { + FREE, + TEAM +} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/RepoEntity.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/RepoEntity.java new file mode 100644 index 0000000..1cebae4 --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/RepoEntity.java @@ -0,0 +1,352 @@ +package dev.cleat.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import org.hibernate.annotations.CreationTimestamp; + +@Entity +@Table(name = "repo") +public class RepoEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(name = "name", nullable = false) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "account_id", nullable = false) + private AccountEntity account; + + @Column(name = "visibility") + @Enumerated(EnumType.STRING) + private Visibility visibility; + + @Column(name = "language") + private String language; + + @Column(name = "stars") + private Integer stars; + + @Column(name = "default_branch") + private String defaultBranch; + + @Column(name = "branch_protected") + private Boolean branchProtected; + + @Column(name = "has_readme") + private Boolean hasReadme; + + @Column(name = "has_license") + private Boolean hasLicense; + + @Column(name = "has_contributing") + private Boolean hasContributing; + + @Column(name = "has_codeowners") + private Boolean hasCodeowners; + + @Column(name = "has_ci") + private Boolean hasCi; + + @Column(name = "size_mb") + private Double sizeMb; + + @Column(name = "last_pushed_at") + private OffsetDateTime lastPushedAt; + + @Column(name = "archived") + private Boolean archived; + + @Column(name = "open_vulns") + private Integer openVulns; + + @Column(name = "open_secrets") + private Integer openSecrets; + + @Column(name = "open_code_alerts") + private Integer openCodeAlerts; + + @Column(name = "stale_branches") + private Integer staleBranches; + + @Column(name = "open_prs") + private Integer openPRs; + + @Column(name = "hygiene_score") + private Integer hygieneScore; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + public RepoEntity( + UUID id, + String name, + AccountEntity account, + Visibility visibility, + String language, + Integer stars, + String defaultBranch, + Boolean branchProtected, + Boolean hasReadme, + Boolean hasLicense, + Boolean hasContributing, + Boolean hasCodeowners, + Boolean hasCi, + Double sizeMb, + OffsetDateTime lastPushedAt, + Boolean archived, + Integer openVulns, + Integer openSecrets, + Integer openCodeAlerts, + Integer staleBranches, + Integer openPRs, + Integer hygieneScore, + OffsetDateTime createdAt) { + this.id = id; + this.name = name; + this.account = account; + this.visibility = visibility; + this.language = language; + this.stars = stars; + this.defaultBranch = defaultBranch; + this.branchProtected = branchProtected; + this.hasReadme = hasReadme; + this.hasLicense = hasLicense; + this.hasContributing = hasContributing; + this.hasCodeowners = hasCodeowners; + this.hasCi = hasCi; + this.sizeMb = sizeMb; + this.lastPushedAt = lastPushedAt; + this.archived = archived; + this.openVulns = openVulns; + this.openSecrets = openSecrets; + this.openCodeAlerts = openCodeAlerts; + this.staleBranches = staleBranches; + this.openPRs = openPRs; + this.hygieneScore = hygieneScore; + this.createdAt = createdAt; + } + + public RepoEntity() {} + + public UUID getId() { + return id; + } + + public RepoEntity setId(UUID id) { + this.id = id; + return this; + } + + public String getName() { + return name; + } + + public RepoEntity setName(String name) { + this.name = name; + return this; + } + + public AccountEntity getAccount() { + return account; + } + + public RepoEntity setAccount(AccountEntity account) { + this.account = account; + return this; + } + + public Visibility getVisibility() { + return visibility; + } + + public RepoEntity setVisibility(Visibility visibility) { + this.visibility = visibility; + return this; + } + + public String getLanguage() { + return language; + } + + public RepoEntity setLanguage(String language) { + this.language = language; + return this; + } + + public Integer getStars() { + return stars; + } + + public RepoEntity setStars(Integer stars) { + this.stars = stars; + return this; + } + + public String getDefaultBranch() { + return defaultBranch; + } + + public RepoEntity setDefaultBranch(String defaultBranch) { + this.defaultBranch = defaultBranch; + return this; + } + + public Boolean getBranchProtected() { + return branchProtected; + } + + public RepoEntity setBranchProtected(Boolean branchProtected) { + this.branchProtected = branchProtected; + return this; + } + + public Boolean getHasReadme() { + return hasReadme; + } + + public RepoEntity setHasReadme(Boolean hasReadme) { + this.hasReadme = hasReadme; + return this; + } + + public Boolean getHasLicense() { + return hasLicense; + } + + public RepoEntity setHasLicense(Boolean hasLicense) { + this.hasLicense = hasLicense; + return this; + } + + public Boolean getHasContributing() { + return hasContributing; + } + + public RepoEntity setHasContributing(Boolean hasContributing) { + this.hasContributing = hasContributing; + return this; + } + + public Boolean getHasCodeowners() { + return hasCodeowners; + } + + public RepoEntity setHasCodeowners(Boolean hasCodeowners) { + this.hasCodeowners = hasCodeowners; + return this; + } + + public Boolean getHasCi() { + return hasCi; + } + + public RepoEntity setHasCi(Boolean hasCi) { + this.hasCi = hasCi; + return this; + } + + public Double getSizeMb() { + return sizeMb; + } + + public RepoEntity setSizeMb(Double sizeMb) { + this.sizeMb = sizeMb; + return this; + } + + public OffsetDateTime getLastPushedAt() { + return lastPushedAt; + } + + public RepoEntity setLastPushedAt(OffsetDateTime lastPushedAt) { + this.lastPushedAt = lastPushedAt; + return this; + } + + public Boolean getArchived() { + return archived; + } + + public RepoEntity setArchived(Boolean archived) { + this.archived = archived; + return this; + } + + public Integer getOpenVulns() { + return openVulns; + } + + public RepoEntity setOpenVulns(Integer openVulns) { + this.openVulns = openVulns; + return this; + } + + public Integer getOpenSecrets() { + return openSecrets; + } + + public RepoEntity setOpenSecrets(Integer openSecrets) { + this.openSecrets = openSecrets; + return this; + } + + public Integer getOpenCodeAlerts() { + return openCodeAlerts; + } + + public RepoEntity setOpenCodeAlerts(Integer openCodeAlerts) { + this.openCodeAlerts = openCodeAlerts; + return this; + } + + public Integer getStaleBranches() { + return staleBranches; + } + + public RepoEntity setStaleBranches(Integer staleBranches) { + this.staleBranches = staleBranches; + return this; + } + + public Integer getOpenPRs() { + return openPRs; + } + + public RepoEntity setOpenPRs(Integer openPRs) { + this.openPRs = openPRs; + return this; + } + + public Integer getHygieneScore() { + return hygieneScore; + } + + public RepoEntity setHygieneScore(Integer hygieneScore) { + this.hygieneScore = hygieneScore; + return this; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public RepoEntity setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + return this; + } +} diff --git a/backend/libs/persistence/src/main/java/dev/cleat/persistence/Visibility.java b/backend/libs/persistence/src/main/java/dev/cleat/persistence/Visibility.java new file mode 100644 index 0000000..d5920b6 --- /dev/null +++ b/backend/libs/persistence/src/main/java/dev/cleat/persistence/Visibility.java @@ -0,0 +1,7 @@ +package dev.cleat.persistence; + +public enum Visibility { + PUBLIC, + PRIVATE, + INTERNAL +} diff --git a/backend/libs/persistence/src/main/resources/db/migration/V1__create_account_and_repo_tables.sql b/backend/libs/persistence/src/main/resources/db/migration/V1__create_account_and_repo_tables.sql new file mode 100644 index 0000000..9c7ccd8 --- /dev/null +++ b/backend/libs/persistence/src/main/resources/db/migration/V1__create_account_and_repo_tables.sql @@ -0,0 +1,46 @@ +CREATE TABLE account ( + id UUID PRIMARY KEY, + login VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + plan VARCHAR(50), + repo_count INTEGER DEFAULT 0, + member_count INTEGER DEFAULT 0, + posture_score INTEGER DEFAULT 0, + monthly_spend DECIMAL(19,4) DEFAULT 0.0, + reclaimable DECIMAL(19,4) DEFAULT 0.0, + created_at TIMESTAMP WITH TIME ZONE NOT NULL +); + +CREATE TABLE repo ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + account_id UUID NOT NULL, + visibility VARCHAR(50), + language VARCHAR(100), + stars INTEGER, + default_branch VARCHAR(255), + branch_protected BOOLEAN, + has_readme BOOLEAN, + has_license BOOLEAN, + has_contributing BOOLEAN, + has_codeowners BOOLEAN, + has_ci BOOLEAN, + size_mb DOUBLE PRECISION, + last_pushed_at TIMESTAMP WITH TIME ZONE, + archived BOOLEAN, + open_vulns INTEGER, + open_secrets INTEGER, + open_code_alerts INTEGER, + stale_branches INTEGER, + open_prs INTEGER, + hygiene_score INTEGER, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + + CONSTRAINT fk_account + FOREIGN KEY (account_id) + REFERENCES account(id) + ON DELETE CASCADE +); + +CREATE INDEX idx_repo_account_id ON repo(account_id); diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java new file mode 100644 index 0000000..2cf33ca --- /dev/null +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/AccountRepositoryTest.java @@ -0,0 +1,56 @@ +package dev.cleat.persistence; + +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ContextConfiguration(classes = TestPersistenceConfig.class) +public class AccountRepositoryTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16"); + + @Autowired + private TestEntityManager testEntityManager; + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired + AccountRepository accountRepository; + + @Test + void shouldSaveAccountWithRepos() { + AccountEntity account = + new AccountEntity().setLogin("test-user").setName("Test User").setType(AccountType.USER); + + RepoEntity repo = new RepoEntity().setName("test-repo").setAccount(account); + + account.setRepos(List.of(repo)); + repo.setAccount(account); + accountRepository.saveAndFlush(account); + testEntityManager.clear(); + AccountEntity found = accountRepository.findById(account.getId()).orElseThrow(); + + Assertions.assertNotNull(found.getId()); + Assertions.assertEquals(1, found.getRepos().size()); + Assertions.assertEquals("test-repo", found.getRepos().getFirst().getName()); + } +} diff --git a/backend/libs/persistence/src/test/java/dev/cleat/persistence/TestPersistenceConfig.java b/backend/libs/persistence/src/test/java/dev/cleat/persistence/TestPersistenceConfig.java new file mode 100644 index 0000000..c5e1ce7 --- /dev/null +++ b/backend/libs/persistence/src/test/java/dev/cleat/persistence/TestPersistenceConfig.java @@ -0,0 +1,10 @@ +package dev.cleat.persistence; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaRepositories(basePackages = "dev.cleat.persistence") +@EntityScan(basePackages = "dev.cleat.persistence") +public class TestPersistenceConfig {} diff --git a/backend/libs/persistence/src/test/resources/application.yml b/backend/libs/persistence/src/test/resources/application.yml new file mode 100644 index 0000000..f9f0212 --- /dev/null +++ b/backend/libs/persistence/src/test/resources/application.yml @@ -0,0 +1,6 @@ +spring: + flyway: + enabled: true + jpa: + hibernate: + ddl-auto: validate