~jan0sch/smederee

Showing details for patch c58485fe63ca711cd48b681ba40c149ca104b04b.
2023-01-09 (Mon), 4:41 PM - Jens Grassel - c58485fe63ca711cd48b681ba40c149ca104b04b

More functionality regarding forking of repositories.

While already possible ("clone to your account") there was no metadata
stored regarding the actual fork and thus the connection between the
repositories was lost.

Now a separate table (`forks`) holds the relations and allows for additional
functionality. Currently only the showing of the parent repository (the one
from which was forked) on the repository overview page is implemented.
Summary of changes
1 files added
  • modules/hub/src/main/resources/db/migration/V3__fork_tables.sql
7 files modified with 189 lines added and 8 lines removed
  • CHANGELOG.md with 5 added and 0 removed lines
  • modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala with 107 added and 0 removed lines
  • modules/hub/src/main/resources/messages_en.properties with 1 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala with 12 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala with 23 added and 0 removed lines
  • modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala with 23 added and 7 removed lines
  • modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html with 18 added and 1 removed lines
diff -rN -u old-smederee/CHANGELOG.md new-smederee/CHANGELOG.md
--- old-smederee/CHANGELOG.md	2025-02-01 06:41:25.083311739 +0000
+++ new-smederee/CHANGELOG.md	2025-02-01 06:41:25.087311745 +0000
@@ -20,6 +20,11 @@
 
 ## Unreleased
 
+### Added
+
+- show parent repository from which the current one was forked on the
+  repository overview page
+
 ### Changed
 
 - store the CSRF key on disk and load it if present
diff -rN -u old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala
--- old-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-02-01 06:41:25.083311739 +0000
+++ new-smederee/modules/hub/src/it/scala/de/smederee/hub/DoobieVcsMetadataRepositoryTest.scala	2025-02-01 06:41:25.087311745 +0000
@@ -28,6 +28,8 @@
 
 import munit._
 
+import scala.collection.immutable.Queue
+
 final class DoobieVcsMetadataRepositoryTest extends BaseSpec {
 
   /** Find the repository ID for the given owner and repository name.
@@ -62,6 +64,32 @@
       } yield account
     }
 
+  /** Find all forks of the original repository with the given ID.
+    *
+    * @param originalRepoId
+    *   The unique ID of the original repo from which was forked.
+    * @return
+    *   A list of ID pairs (original repository id, forked repository id) which may be empty.
+    */
+  @throws[java.sql.SQLException]("Errors from the underlying SQL procedures will throw exceptions!")
+  protected def findForks(originalRepoId: Long): IO[Seq[(Long, Long)]] =
+    connectToDb(configuration).use { con =>
+      for {
+        statement <- IO.delay(
+          con.prepareStatement("""SELECT original_repo, forked_repo FROM "forks" WHERE original_repo = ?""")
+        )
+        _      <- IO.delay(statement.setLong(1, originalRepoId))
+        result <- IO.delay(statement.executeQuery)
+        forks <- IO.delay {
+          var queue = Queue.empty[(Long, Long)]
+          while (result.next())
+            queue = queue :+ (result.getLong("original_repo"), result.getLong("forked_repo"))
+          queue
+        }
+        _ <- IO.delay(statement.close())
+      } yield forks.toList
+    }
+
   override def beforeEach(context: BeforeEach): Unit = {
     val dbConfig = configuration.database
     val flyway: Flyway =
@@ -79,6 +107,46 @@
     val _ = flyway.clean()
   }
 
+  test("createFork must work correctly") {
+    (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match {
+      case (Some(accounts), Some(repositories)) =>
+        val vcsRepositories = accounts.zip(repositories).map { tuple =>
+          val (account, repo) = tuple
+          repo.copy(owner = account.toVcsRepositoryOwner)
+        }
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo     = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _ <- accounts.traverse(account =>
+            createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          )
+          written <- vcsRepositories.traverse(repo.createVcsRepository)
+          original <- vcsRepositories.headOption.traverse(vcsRepository =>
+            findVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name)
+          )
+          toFork <- vcsRepositories.drop(1).take(5).traverse { vcsRepository =>
+            findVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name)
+          }
+          forked <- (original, toFork) match {
+            case (Some(Some(originalId)), forkIds) => forkIds.flatten.traverse(id => repo.createFork(originalId, id))
+            case _                                 => IO.pure(List.empty)
+          }
+          foundForks <- original match {
+            case Some(Some(originalId)) => findForks(originalId)
+            case _                      => IO.pure(List.empty)
+          }
+        } yield (written, forked, foundForks)
+        test.map { result =>
+          val (written, forked, foundForks) = result
+          assert(written.sum === vcsRepositories.size, "Test repository data was not written to database!")
+          assert(forked.sum === vcsRepositories.drop(1).take(5).size, "Number of created forks does not match!")
+          assert(foundForks.size === vcsRepositories.drop(1).take(5).size, "Number of found forks does not match!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
+  }
+
   test("createVcsRepository must create a repository entry") {
     (genValidAccount.sample, genValidVcsRepository.sample) match {
       case (Some(account), Some(repository)) =>
@@ -202,6 +270,45 @@
         }
       case _ => fail("Could not generate data samples!")
     }
+  }
+
+  test("findVcsRepositoryParentFork must return the parent repository if it exists") {
+    (genValidAccounts.suchThat(_.size > 4).sample, genValidVcsRepositories.suchThat(_.size > 4).sample) match {
+      case (Some(accounts), Some(repositories)) =>
+        val vcsRepositories = accounts.zip(repositories).map { tuple =>
+          val (account, repo) = tuple
+          repo.copy(owner = account.toVcsRepositoryOwner)
+        }
+        val dbConfig = configuration.database
+        val tx       = Transactor.fromDriverManager[IO](dbConfig.driver, dbConfig.url, dbConfig.user, dbConfig.pass)
+        val repo     = new DoobieVcsMetadataRepository[IO](tx)
+        val test = for {
+          _ <- accounts.traverse(account =>
+            createAccount(account, PasswordHash("I am not a password hash!"), None, None)
+          )
+          written <- vcsRepositories.traverse(repo.createVcsRepository)
+          original <- vcsRepositories.headOption.traverse(vcsRepository =>
+            findVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name)
+          )
+          toFork <- vcsRepositories.drop(1).take(5).traverse { vcsRepository =>
+            findVcsRepositoryId(vcsRepository.owner.uid, vcsRepository.name)
+          }
+          forked <- (original, toFork) match {
+            case (Some(Some(originalId)), forkIds) => forkIds.flatten.traverse(id => repo.createFork(originalId, id))
+            case _                                 => IO.pure(List.empty)
+          }
+          foundParents <- vcsRepositories.drop(1).take(5).traverse { vcsRepository =>
+            repo.findVcsRepositoryParentFork(vcsRepository.owner, vcsRepository.name)
+          }
+        } yield (written, forked, vcsRepositories.headOption, foundParents)
+        test.map { result =>
+          val (written, forked, original, foundParents) = result
+          assert(written.sum === vcsRepositories.size, "Test repository data was not written to database!")
+          assert(forked.sum === vcsRepositories.drop(1).take(5).size, "Number of created forks does not match!")
+          assert(foundParents.forall(_ === original), "Parent vcs repository not matching!")
+        }
+      case _ => fail("Could not generate data samples!")
+    }
   }
 
   test("listAllRepositories must return only public repositories for guest users") {
diff -rN -u old-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql new-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql
--- old-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql	1970-01-01 00:00:00.000000000 +0000
+++ new-smederee/modules/hub/src/main/resources/db/migration/V3__fork_tables.sql	2025-02-01 06:41:25.087311745 +0000
@@ -0,0 +1,18 @@
+CREATE TABLE "forks"
+(
+  "original_repo" BIGINT NOT NULL,
+  "forked_repo"  BIGINT NOT NULL,
+  CONSTRAINT "forks_pk" PRIMARY KEY ("original_repo", "forked_repo"),
+  CONSTRAINT "forks_fk_original_repo" FOREIGN KEY ("original_repo")
+    REFERENCES "repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
+  CONSTRAINT "forks_fk_forked_repo" FOREIGN KEY ("forked_repo")
+    REFERENCES "repositories" ("id") ON UPDATE CASCADE ON DELETE CASCADE
+)
+WITH (
+  OIDS=FALSE
+);
+
+COMMENT ON TABLE "forks" IS 'Stores fork relationships between repositories.';
+COMMENT ON COLUMN "forks"."original_repo" IS 'The ID of the original repository from which was forked.';
+COMMENT ON COLUMN "forks"."forked_repo" IS 'The ID of the repository which is the fork.';
+
diff -rN -u old-smederee/modules/hub/src/main/resources/messages_en.properties new-smederee/modules/hub/src/main/resources/messages_en.properties
--- old-smederee/modules/hub/src/main/resources/messages_en.properties	2025-02-01 06:41:25.083311739 +0000
+++ new-smederee/modules/hub/src/main/resources/messages_en.properties	2025-02-01 06:41:25.087311745 +0000
@@ -183,6 +183,7 @@
 repository.menu.website=Website
 repository.menu.website.tooltip=Click here to open the project website ({0}) in a new tab or window.
 repository.description.title=Summary:
+repository.description.forked-from=Forked from:
 
 repository.overview.clone.fork=Create your personal fork.
 repository.overview.clone.title=Clone this repository
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-02-01 06:41:25.083311739 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/DoobieVcsMetadataRepository.scala	2025-02-01 06:41:25.087311745 +0000
@@ -41,6 +41,9 @@
   private val selectRepositoryColumns =
     fr"""SELECT "repos".name AS name, "accounts".uid AS owner_id, "accounts".name AS owner_name, "repos".is_private AS is_private, "repos".description AS description, "repos".vcs_type AS vcs_type, "repos".website AS website FROM "repositories" AS "repos" JOIN "accounts" ON "repos".owner = "accounts".uid"""
 
+  override def createFork(source: Long, target: Long): F[Int] =
+    sql"""INSERT INTO "forks" (original_repo, forked_repo) VALUES ($source, $target)""".update.run.transact(tx)
+
   override def createVcsRepository(repository: VcsRepository): F[Int] =
     sql"""INSERT INTO "repositories" (name, owner, is_private, description, vcs_type, website, created_at, updated_at) VALUES (${repository.name}, ${repository.owner.uid}, ${repository.isPrivate}, ${repository.description}, ${repository.vcsType}, ${repository.website}, NOW(), NOW())""".update.run
       .transact(tx)
@@ -72,6 +75,15 @@
       .option
       .transact(tx)
 
+  override def findVcsRepositoryParentFork(
+      owner: VcsRepositoryOwner,
+      name: VcsRepositoryName
+  ): F[Option[VcsRepository]] = {
+    val query =
+      selectRepositoryColumns ++ fr"""WHERE "repos".id = (SELECT original_repo FROM "forks" WHERE forked_repo = (SELECT id FROM "repositories" WHERE name = $name AND owner = ${owner.uid}))"""
+    query.query[VcsRepository].option.transact(tx)
+  }
+
   override def listAllRepositories(
       requester: Option[Account]
   )(ordering: VcsMetadataRepositoriesOrdering): Stream[F, VcsRepository] = {
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-02-01 06:41:25.083311739 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsMetadataRepository.scala	2025-02-01 06:41:25.087311745 +0000
@@ -27,6 +27,17 @@
   */
 abstract class VcsMetadataRepository[F[_]] {
 
+  /** Create an entry in the forks table to save the relationship between to repositories.
+    *
+    * @param source
+    *   The ID of the original repository from which was forked.
+    * @param target
+    *   The ID of the forked repository.
+    * @return
+    *   The number of affected database rows.
+    */
+  def createFork(source: Long, target: Long): F[Int]
+
   /** Create a database entry for the given vcs repository.
     *
     * @param repository
@@ -77,6 +88,18 @@
     */
   def findVcsRepositoryOwner(name: Username): F[Option[VcsRepositoryOwner]]
 
+  /** Search for a possible parent repository (i.e. a repository forked from) of the one described by the given name and
+    * owner.
+    *
+    * @param owner
+    *   Data about the owner of the repository containing information needed to query the database.
+    * @param name
+    *   The repository name which must be unique in regard to the owner.
+    * @return
+    *   An option to the successfully found parent vcs repository entry.
+    */
+  def findVcsRepositoryParentFork(owner: VcsRepositoryOwner, name: VcsRepositoryName): F[Option[VcsRepository]]
+
   /** Return a list of all repositories from all users.
     *
     * @param requester
diff -rN -u old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala
--- old-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-02-01 06:41:25.083311739 +0000
+++ new-smederee/modules/hub/src/main/scala/de/smederee/hub/VcsRepositoryRoutes.scala	2025-02-01 06:41:25.087311745 +0000
@@ -311,8 +311,8 @@
           )
         )
       )
-      log        <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2", "--xml-output"))
-      xmlLog     <- Sync[F].delay(scala.xml.XML.loadString(log.stdout.toList.mkString))
+      vcsLog     <- darcs.log(directory.toNIO)(repositoryName.toString)(Chain(s"--max-count=2", "--xml-output"))
+      xmlLog     <- Sync[F].delay(scala.xml.XML.loadString(vcsLog.stdout.toList.mkString))
       patches    <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList)
       readmeData <- repo.traverse(repo => doLoadReadme(repo))
       readme <- readmeData match {
@@ -327,6 +327,10 @@
         case _ => Sync[F].delay(None)
       }
       readmeName = readmeData.flatMap(_._2)
+      parentFork <- repo match {
+        case None       => Sync[F].pure(None)
+        case Some(repo) => vcsMetadataRepo.findVcsRepositoryParentFork(repo.owner, repo.name)
+      }
       resp <- repo match {
         case None => NotFound("Repository not found!")
         case Some(repo) =>
@@ -339,6 +343,7 @@
             )(
               repo,
               vcsRepositoryHistory = patches,
+              vcsRepositoryParentFork = parentFork,
               vcsRepositoryReadme = readme,
               vcsRepositoryReadmeFilename = readmeName,
               vcsRepositorySshUri = sshUri
@@ -515,10 +520,10 @@
         else
           from + maxCount
       next = if (to < numberOfChanges) Option(to + 1) else None
-      log <- darcs.log(directory.toNIO)(repositoryName.toString)(
+      vcsLog <- darcs.log(directory.toNIO)(repositoryName.toString)(
         Chain(s"--index=$from-$to", "--summary", "--xml-output")
       )
-      xmlLog  <- Sync[F].delay(scala.xml.XML.loadString(log.stdout.toList.mkString))
+      xmlLog  <- Sync[F].delay(scala.xml.XML.loadString(vcsLog.stdout.toList.mkString))
       patches <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList)
       actionBaseUri <- Sync[F].delay(
         linkConfig.createFullUri(
@@ -603,10 +608,10 @@
           )
         )
       )
-      log <- darcs.log(directory.toNIO)(repositoryName.toString)(
+      vcsLog <- darcs.log(directory.toNIO)(repositoryName.toString)(
         Chain(s"--hash=${hash.toString}", "--summary", "--xml-output")
       )
-      xmlLog <- Sync[F].delay(scala.xml.XML.loadString(log.stdout.toList.mkString))
+      xmlLog <- Sync[F].delay(scala.xml.XML.loadString(vcsLog.stdout.toList.mkString))
       patch  <- Sync[F].delay((xmlLog \ "patch").flatMap(VcsRepositoryPatchMetadata.fromDarcsXmlLog).toList.headOption)
       darcsDiff        <- darcs.diff(directory.toNIO)(repositoryName.toString)(hash)(Chain("--no-pause-for-gui"))
       patchCutMarker   <- Sync[F].delay(s"patch ${hash.toString}")
@@ -818,7 +823,18 @@
                 darcsClone <- darcs.clone(sourceDirectory, targetDirectory)(Chain("--complete"))
                 createRepo <-
                   if (darcsClone.exitValue === 0)
-                    vcsMetadataRepo.createVcsRepository(repoMetadata)
+                    for {
+                      create <- vcsMetadataRepo.createVcsRepository(repoMetadata)
+                      // Get IDs to create the fork entry in the database.
+                      source <- sourceRepository.traverse(sourceRepo =>
+                        vcsMetadataRepo.findVcsRepositoryId(sourceRepo.owner, sourceRepo.name)
+                      )
+                      target <- vcsMetadataRepo.findVcsRepositoryId(repoMetadata.owner, repoMetadata.name)
+                      fork <- (source, target) match {
+                        case (Some(Some(source)), Some(target)) => vcsMetadataRepo.createFork(source, target)
+                        case _                                  => Sync[F].pure(0)
+                      }
+                    } yield create + fork
                   else
                     Sync[F].pure(0) // Do not create DB entry if darcs init failed!
               } yield createRepo
diff -rN -u old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html
--- old-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-02-01 06:41:25.087311745 +0000
+++ new-smederee/modules/hub/src/main/twirl/de/smederee/hub/views/showRepositoryOverview.scala.html	2025-02-01 06:41:25.087311745 +0000
@@ -1,4 +1,16 @@
-@(baseUri: Uri, lang: LanguageCode = LanguageCode("en"))(actionBaseUri: Uri, csrf: Option[CsrfToken] = None, title: Option[String] = None, user: Option[Account])(vcsRepository: VcsRepository, vcsRepositoryHistory: List[VcsRepositoryPatchMetadata], vcsRepositoryReadme: Option[String] = None, vcsRepositoryReadmeFilename: Option[String] = None, vcsRepositorySshUri: Option[String] = None)
+@(baseUri: Uri,
+  lang: LanguageCode = LanguageCode("en")
+)(actionBaseUri: Uri,
+  csrf: Option[CsrfToken] = None,
+  title: Option[String] = None,
+  user: Option[Account]
+)(vcsRepository: VcsRepository,
+  vcsRepositoryHistory: List[VcsRepositoryPatchMetadata],
+  vcsRepositoryParentFork: Option[VcsRepository] = None,
+  vcsRepositoryReadme: Option[String] = None,
+  vcsRepositoryReadmeFilename: Option[String] = None,
+  vcsRepositorySshUri: Option[String] = None
+)
 @main(baseUri, lang)()(csrf, title, user) {
 @defining(lang.toLocale) { implicit locale =>
   <div class="content">
@@ -27,6 +39,11 @@
               <strong>@Messages("repository.description.title")</strong> @description
             </div>
           }
+          @for(parentRepo <- vcsRepositoryParentFork) {
+            <div class="repo-summary-description">
+              <i>@Messages("repository.description.forked-from") <a href="@{baseUri.addSegment(s"~${parentRepo.owner.name.toString}").addSegment(parentRepo.name.toString)}">~@{parentRepo.owner.name}/@{parentRepo.name}</a></i>
+            </div>
+          }
         </div>
       </div>
     </div>