From 08d313ffc210786d193d96e68e3769f97484b800 Mon Sep 17 00:00:00 2001 From: Trevor Burton-McCreadie Date: Tue, 18 Jul 2023 10:20:46 +0100 Subject: [PATCH] cmta new command (#237) * added outline 'new' command * The story so far... * Refactor cmta new * Remove utilisation of Github access tokens - Refactor `ctma new` and `cmtc install` implementations to not utilise the Github API & access token to retrieve artefacts and [release] tags - test code needs to be refactored as it doesn't compile after the refactoring of the main code - Left a couple TODO that require follow-up * Update cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala Co-authored-by: Trevor Burton-McCreadie * Update cmtc install/cmta new commands - Implement `cmtc install` from a folder - Fix errors in `cmta new` - Added checks for pre-existing targets * Fix tests * Print current course info when installing from a Github repo --------- Co-authored-by: Eric Loots --- .../scala/com/lunatech/cmt/admin/Domain.scala | 21 ++ .../scala/com/lunatech/cmt/admin/Main.scala | 4 +- .../lunatech/cmt/admin/cli/ArgParsers.scala | 3 + .../com/lunatech/cmt/admin/command/New.scala | 144 ++++++++++ .../com/lunatech/cmt/client/Domain.scala | 2 + .../lunatech/cmt/client/cli/ArgParsers.scala | 5 +- .../lunatech/cmt/client/command/Install.scala | 197 +++++++++++++ .../cmt/client/command/SetCurrentCourse.scala | 6 +- .../cmt/client/cli/InstallArgumentsSpec.scala | 6 +- .../client/cli}/SetCurrentCourseSpec.scala | 0 .../main/scala/com/lunatech/cmt/Domain.scala | 6 +- .../main/scala/com/lunatech/cmt/Helpers.scala | 2 + .../scala/com/lunatech/cmt/ProcessDSL.scala | 3 +- .../lunatech/cmt/client/command/Install.scala | 263 ------------------ .../lunatech/cmt/core/cli/ArgParsers.scala | 14 +- 15 files changed, 399 insertions(+), 277 deletions(-) create mode 100644 cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala create mode 100644 cmtc/src/main/scala/com/lunatech/cmt/client/command/Install.scala rename {core => cmtc}/src/main/scala/com/lunatech/cmt/client/command/SetCurrentCourse.scala (93%) rename {core/src/test/scala/com/lunatech/cmt/client/command => cmtc/src/test/scala/com/lunatech/cmt/client/cli}/SetCurrentCourseSpec.scala (100%) delete mode 100644 core/src/main/scala/com/lunatech/cmt/client/command/Install.scala diff --git a/cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala b/cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala index ef74ad9e..72b93002 100644 --- a/cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala +++ b/cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala @@ -13,7 +13,12 @@ package com.lunatech.cmt.admin * See the License for the specific language governing permissions and limitations under the License. */ +import com.lunatech.cmt.CmtError import sbt.io.syntax.File +import com.lunatech.cmt.* +import cats.syntax.either.* + +import com.lunatech.cmt.Domain.InstallationSource object Domain: @@ -37,3 +42,19 @@ object Domain: final case class LinearizeBaseDirectory(value: File) final case class MainRepository(value: File) final case class ConfigurationFile(value: File) + + final case class CourseTemplate(value: Either[CmtError, InstallationSource.GithubProject]) + object CourseTemplate: + val GithubTemplateRegex = "([A-Za-z0-9-_]*)".r + val GithubProjectRegex = "([A-Za-z0-9-_]*)\\/([A-Za-z0-9-_]*)".r + val GithubProjectWithTagRegex = "([A-Za-z0-9-_]*)\\/([A-Za-z0-9-_]*)\\/(.*)".r + def fromString(str: String): CourseTemplate = + str match { + case GithubTemplateRegex(template) => + CourseTemplate(Right(InstallationSource.GithubProject("lunatech-labs", s"cmt-template-$template", None))) + case GithubProjectRegex(organisation, project) => + CourseTemplate(Right(InstallationSource.GithubProject(organisation, project, None))) + case GithubProjectWithTagRegex(organisation, project, tag) => + CourseTemplate(Right(InstallationSource.GithubProject(organisation, project, Some(tag)))) + case _ => CourseTemplate(s"Invalid template name: $str".toExecuteCommandErrorMessage.asLeft) + } diff --git a/cmta/src/main/scala/com/lunatech/cmt/admin/Main.scala b/cmta/src/main/scala/com/lunatech/cmt/admin/Main.scala index 16d4a26b..20405a16 100644 --- a/cmta/src/main/scala/com/lunatech/cmt/admin/Main.scala +++ b/cmta/src/main/scala/com/lunatech/cmt/admin/Main.scala @@ -13,11 +13,12 @@ package com.lunatech.cmt.admin * See the License for the specific language governing permissions and limitations under the License. */ -import caseapp.core.app.{CommandsEntryPoint} +import caseapp.core.app.CommandsEntryPoint import com.lunatech.cmt.admin.command.{ Delinearize, DuplicateInsertBefore, Linearize, + New, RenumberExercises, Studentify, Version @@ -26,6 +27,7 @@ import com.lunatech.cmt.admin.command.{ object Main extends CommandsEntryPoint: override def progName = "cmta" override def commands = Seq( + New.command, Delinearize.command, DuplicateInsertBefore.command, Linearize.command, diff --git a/cmta/src/main/scala/com/lunatech/cmt/admin/cli/ArgParsers.scala b/cmta/src/main/scala/com/lunatech/cmt/admin/cli/ArgParsers.scala index 48535d04..62bdbf81 100644 --- a/cmta/src/main/scala/com/lunatech/cmt/admin/cli/ArgParsers.scala +++ b/cmta/src/main/scala/com/lunatech/cmt/admin/cli/ArgParsers.scala @@ -69,4 +69,7 @@ object ArgParsers: given renumberOffsetArgParser: ArgParser[RenumberOffset] = intGreaterThanZero.xmap[RenumberOffset](_.value, RenumberOffset(_)) + given courseTemplateArgParser: ArgParser[CourseTemplate] = + SimpleArgParser.from[CourseTemplate]("Course Template")(str => CourseTemplate.fromString(str).asRight) + end ArgParsers diff --git a/cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala b/cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala new file mode 100644 index 00000000..cd50f9c9 --- /dev/null +++ b/cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala @@ -0,0 +1,144 @@ +package com.lunatech.cmt.admin.command + +import caseapp.{AppName, CommandName, ExtraName, HelpMessage, RemainingArgs, ValueDescription} +import com.lunatech.cmt.{CmtError, printResult} +import com.lunatech.cmt.admin.Domain.{ConfigurationFile, CourseTemplate} +import com.lunatech.cmt.client.command.Executable +import com.lunatech.cmt.core.validation.Validatable +import com.lunatech.cmt.admin.cli.ArgParsers.{configurationFileArgParser, courseTemplateArgParser} +import com.lunatech.cmt.client.Configuration +import com.lunatech.cmt.client.cli.CmtcCommand +import com.lunatech.cmt.Domain.InstallationSource.* +import com.lunatech.cmt.Helpers.ignoreProcessStdOutStdErr +import sbt.io.IO as sbtio +import sbt.io.syntax.* +import com.lunatech.cmt.* + +import cats.syntax.either.* + +import java.io.FileFilter +import sys.process.* +import util.{Try, Success, Failure} + +object New: + + private final case class TagSet(tags: Vector[String]) + + @AppName("new") + @CommandName("new") + @HelpMessage( + "Create a new course from an existing course template in a Github repository - by default the `lunatech-labs` organisation is used.") + final case class Options( + @ExtraName("t") + @ValueDescription( + "the template course to use - provide in the format 'organisation/project' or just 'project' if the project is in the lunatech-labs organisation on Github") + template: CourseTemplate, + @ExtraName("c") + @ValueDescription("The (optional) configuration file to use during processing of the command") + @HelpMessage( + "if not specified will default to the config file present in the directory provided by the --main-repository argument") + maybeConfigFile: Option[ConfigurationFile] = None) + + given Validatable[New.Options] with + extension (options: New.Options) + def validated(): Either[CmtError, New.Options] = + Right(options) + end given + + given Executable[New.Options] with + extension (options: New.Options) + def execute(configuration: Configuration): Either[CmtError, String] = + for { + // list the contents of the ~/Courses directory, if there's anything already matching the + // name then get the count so we can append to the name and prevent conflicts + template <- options.template.value + targetDirectoryName = createTargetDirectoryName(template, configuration) + newRepo <- newCmtRepoFromGithubProject(template, targetDirectoryName, configuration) + } yield newRepo + + private def createTargetDirectoryName(template: GithubProject, configuration: Configuration): String = { + val existingFilesWithSameName = sbtio.listFiles( + configuration.coursesDirectory.value, + new FileFilter { + override def accept(file: File): Boolean = + file.name.startsWith(template.project) + }) + val discriminator = if (existingFilesWithSameName.size > 0) s"-${existingFilesWithSameName.size}" else "" + s"${template.project}$discriminator" + } + + private def cloneMainRepo(githubProject: GithubProject, tmpDir: File): Either[CmtError, TagSet] = + val project = githubProject.project + val organisation = githubProject.organisation + val tag = githubProject.tag + val cloneGit = Process(Seq("git", "clone", s"git@github.com:$organisation/$project.git"), tmpDir) + val cloneGh = Process(Seq("gh", "repo", "clone", s"$organisation/$project"), tmpDir) + val cloneHttp = Process(Seq("git", "clone", s"https://github.com/$organisation/$project"), tmpDir) + val cloneRepoStatus = + Try(cloneGit.!).recoverWith(_ => Try(cloneGh.!)).recoverWith(_ => Try(cloneHttp.!)) match { + case Success(x) => + if x == 0 then Right(x) + else s"Cannot install from ${githubProject.displayName}: No such repo".toExecuteCommandErrorMessage.asLeft + case Failure(_) => + s"Cannot install from ${githubProject.displayName}: No such repo".toExecuteCommandErrorMessage.asLeft + } + for { + _ <- cloneRepoStatus + tags = Process(Seq("git", "tag", "--sort", "v:refname"), tmpDir / project).!!.split("\n").to(Vector) + } yield TagSet(tags) + + private def newCmtRepoFromGithubProject( + githubProject: GithubProject, + targetDirectoryName: String, + configuration: Configuration): Either[CmtError, String] = + val tmpDir = sbtio.createTemporaryDirectory + val installResult = for { + tagSet <- cloneMainRepo(githubProject, tmpDir) + result <- downloadAndInstallRepo(githubProject, targetDirectoryName, configuration, tagSet, tmpDir) + } yield result + sbtio.delete(tmpDir) + installResult + + private def copyRepo( + githubProject: GithubProject, + targetDirectoryName: String, + configuration: Configuration, + tag: String, + tmpDir: File): Unit = + Process(Seq("git", "checkout", tag), tmpDir / githubProject.project).!(ignoreProcessStdOutStdErr) + sbtio.copyDirectory(tmpDir / githubProject.project, configuration.coursesDirectory.value / targetDirectoryName) + sbtio.delete(configuration.coursesDirectory.value / targetDirectoryName / ".git") + Helpers.initializeGitRepo(configuration.coursesDirectory.value / targetDirectoryName) + val _ = Helpers.commitToGit("Initial commit", configuration.coursesDirectory.value / targetDirectoryName) + + private def downloadAndInstallRepo( + githubProject: GithubProject, + targetDirectoryName: String, + configuration: Configuration, + tagSet: TagSet, + tmpDir: File): Either[CmtError, String] = + (githubProject.tag, tagSet.tags.isEmpty, tagSet.tags.lastOption) match { + case (None, _, Some(lastReleaseTag)) => + copyRepo(githubProject, targetDirectoryName, configuration, lastReleaseTag, tmpDir) + Right(s"""Project: + | ${githubProject.copy(tag = Some(lastReleaseTag)).displayName} + |successfully installed to: + | ${configuration.coursesDirectory.value}/${targetDirectoryName}""".stripMargin) + case (Some(tag), false, _) if tagSet.tags.contains(tag) => + copyRepo(githubProject, targetDirectoryName, configuration, tag, tmpDir) + Right(s"""Project: + | ${githubProject.displayName} + |successfully installed to: + | ${configuration.coursesDirectory.value}/${targetDirectoryName}""".stripMargin) + case (Some(tag), false, _) => + s"Cannot install from ${githubProject.displayName}. No such tag: $tag".toExecuteCommandErrorMessage.asLeft + case (Some(_), true, _) | (None, _, None) => + s"Cannot install from ${githubProject.displayName}: No releases found".toExecuteCommandErrorMessage.asLeft + } + + val command = new CmtcCommand[New.Options] { + def run(options: New.Options, args: RemainingArgs): Unit = + options.validated().flatMap(_.execute(configuration)).printResult() + } + +end New diff --git a/cmtc/src/main/scala/com/lunatech/cmt/client/Domain.scala b/cmtc/src/main/scala/com/lunatech/cmt/client/Domain.scala index e13f2c25..14e94d7b 100644 --- a/cmtc/src/main/scala/com/lunatech/cmt/client/Domain.scala +++ b/cmtc/src/main/scala/com/lunatech/cmt/client/Domain.scala @@ -17,6 +17,8 @@ object Domain: object ExerciseId: val default: ExerciseId = ExerciseId("") + final case class ForceDeleteDestinationDirectory(value: Boolean) + final case class ForceMoveToExercise(forceMove: Boolean) final case class TemplatePath(value: String) diff --git a/cmtc/src/main/scala/com/lunatech/cmt/client/cli/ArgParsers.scala b/cmtc/src/main/scala/com/lunatech/cmt/client/cli/ArgParsers.scala index 373b57e3..1e53f39e 100644 --- a/cmtc/src/main/scala/com/lunatech/cmt/client/cli/ArgParsers.scala +++ b/cmtc/src/main/scala/com/lunatech/cmt/client/cli/ArgParsers.scala @@ -1,7 +1,7 @@ package com.lunatech.cmt.client.cli import caseapp.core.argparser.{ArgParser, FlagArgParser, SimpleArgParser} -import com.lunatech.cmt.client.Domain.{ExerciseId, ForceMoveToExercise, TemplatePath} +import com.lunatech.cmt.client.Domain.{ExerciseId, ForceMoveToExercise, TemplatePath, ForceDeleteDestinationDirectory} import cats.syntax.either.* object ArgParsers { @@ -14,4 +14,7 @@ object ArgParsers { given templatePathArgParser: ArgParser[TemplatePath] = SimpleArgParser.from[TemplatePath]("template path")(TemplatePath(_).asRight) + + given forceDeleteDestinationDirectoryArgParser: ArgParser[ForceDeleteDestinationDirectory] = + FlagArgParser.boolean.xmap[ForceDeleteDestinationDirectory](_.value, ForceDeleteDestinationDirectory(_)) } diff --git a/cmtc/src/main/scala/com/lunatech/cmt/client/command/Install.scala b/cmtc/src/main/scala/com/lunatech/cmt/client/command/Install.scala new file mode 100644 index 00000000..1350d903 --- /dev/null +++ b/cmtc/src/main/scala/com/lunatech/cmt/client/command/Install.scala @@ -0,0 +1,197 @@ +package com.lunatech.cmt.client.command + +import caseapp.* +import cats.syntax.either.* +import com.lunatech.cmt.* +import com.lunatech.cmt.Domain.InstallationSource.{GithubProject, LocalDirectory, ZipFile} +import com.lunatech.cmt.Domain.{InstallationSource, StudentifiedRepo} +import com.lunatech.cmt.Helpers.{findStudentRepoRoot, ignoreProcessStdOutStdErr} +import com.lunatech.cmt.client.Configuration +import com.lunatech.cmt.client.cli.CmtcCommand +import com.lunatech.cmt.core.cli.ArgParsers.installationSourceArgParser +import com.lunatech.cmt.core.cli.enforceNoTrailingArguments +import com.lunatech.cmt.core.validation.Validatable +import com.lunatech.cmt.client.Domain.ForceDeleteDestinationDirectory +import sbt.io.IO as sbtio +import sbt.io.syntax.* +import com.lunatech.cmt.client.cli.ArgParsers.forceDeleteDestinationDirectoryArgParser + +import sys.process.* +import scala.util.{Failure, Success, Try} +import java.io.File +import java.net.URL + +object Install: + + @AppName("install") + @CommandName("install") + @HelpMessage( + "Install a course - from either a local directory, a zip file on the local file system or a Github project") + final case class Options( + @ExtraName("s") + @ValueDescription("Source of the course, either a local folder or a zip file, or a Github project") + source: InstallationSource, + @ExtraName("f") + @ValueDescription( + "if set to 'true', a pre-existing installed course with the same name will be wiped before the new one is installed") + forceDelete: ForceDeleteDestinationDirectory = ForceDeleteDestinationDirectory(false)) + + given Validatable[Install.Options] with + extension (options: Install.Options) + def validated(): Either[CmtError, Install.Options] = + options.asRight + end validated + end given + + given Executable[Install.Options] with + extension (cmd: Install.Options) + def execute(configuration: Configuration): Either[CmtError, String] = + cmd.source match { + case localDirectory: LocalDirectory => + installFromLocalDirectory(localDirectory, configuration, cmd.forceDelete.value) + case zipFile: ZipFile => installFromZipFile(zipFile, configuration) + case githubProject @ GithubProject(_, _, _) => + installFromGithubProject(githubProject, configuration, cmd.forceDelete.value) + } + + private def installFromLocalDirectory( + localDirectory: LocalDirectory, + configuration: Configuration, + forceDelete: Boolean): Either[CmtError, String] = + for { + studentRepoRoot <- findStudentRepoRoot(localDirectory.value) + project = studentRepoRoot.getName + _ <- checkPreExistingTargetFolder(project, configuration, forceDelete) + _ = sbtio.move(studentRepoRoot, configuration.coursesDirectory.value / studentRepoRoot.getName) + installCompletionMessage <- setCurrentCourse(project, configuration) + } yield installCompletionMessage + + private def installFromZipFile( + zipFile: ZipFile, + configuration: Configuration, + deleteZipAfterInstall: Boolean = false): Either[CmtError, String] = + sbtio.unzip(zipFile.value, configuration.coursesDirectory.value) + if (deleteZipAfterInstall) { + sbtio.delete(zipFile.value) + } + s"Unzipped '${zipFile.value.name}' to '${configuration.coursesDirectory.value.getAbsolutePath}'".asRight + + private def extractTag(lsFilesTagLine: String): String = + lsFilesTagLine.replaceAll(""".*refs/tags/""", "") + + private def checkPreExistingTargetFolder( + project: String, + configuration: Configuration, + forceDelete: Boolean): Either[CmtError, Unit] = + val targetFolder = configuration.coursesDirectory.value / project + val preExistingTargetFolder = targetFolder.exists() + (preExistingTargetFolder, forceDelete) match { + case (true, false) => + s"There is a pre-existing installed course for ${project}".toExecuteCommandErrorMessage.asLeft + case (true, true) => + Right(sbtio.delete(targetFolder)) + case (false, _) => + Right(()) + } + + private def installFromGithubProject( + githubProject: GithubProject, + configuration: Configuration, + forceDelete: Boolean): Either[CmtError, String] = { + for { + _ <- checkPreExistingTargetFolder(githubProject.project, configuration, forceDelete) + installCompletionMessage <- { + val cwd = file(".").getCanonicalFile + val maybeTags = Try( + Process( + Seq( + "git", + "-c", + "versionsort.suffix=-", + "ls-remote", + "--tags", + "--refs", + "--sort", + "v:refname", + s"git@github.com:${githubProject.organisation}/${githubProject.project}.git"), + cwd).!!(ignoreProcessStdOutStdErr).split("\n").to(Seq).map(extractTag)) + val tags: Seq[String] = maybeTags match { + case Success(s) => s + case Failure(_) => Seq.empty[String] + } + + val aTagWasPassedToInstall = githubProject.tag.isDefined + val aTagWasPassedToInstallWhichMatchesARelease = + githubProject.tag.isDefined && tags.contains(githubProject.tag.get) + val maybeMostRecentTag = tags.lastOption + + (aTagWasPassedToInstall, aTagWasPassedToInstallWhichMatchesARelease, maybeMostRecentTag) match { + case (false, _, Some(mostRecentTag)) => + downloadAndInstallStudentifiedRepo(githubProject, mostRecentTag, configuration) + case (false, _, None) => + s"${githubProject.displayName}: Missing tag".toExecuteCommandErrorMessage.asLeft + case (true, false, _) => + s"${githubProject.displayName}. ${githubProject.tag.get}: No such tag".toExecuteCommandErrorMessage.asLeft + case (true, true, _) => + downloadAndInstallStudentifiedRepo(githubProject, githubProject.tag.get, configuration) + } + } + } yield installCompletionMessage + } + + private def downloadAndInstallStudentifiedRepo( + githubProject: GithubProject, + tag: String, + configuration: Configuration): Either[CmtError, String] = + for { + studentAssetUrl <- getStudentAssetUrl(githubProject, tag) + _ = printMessage(s"Downloading studentified course from '$studentAssetUrl' to courses directory\n") + downloadedZipFile <- downloadStudentAsset(studentAssetUrl, githubProject, configuration) + _ <- installFromZipFile(downloadedZipFile, configuration, deleteZipAfterInstall = true) + setCurrentCourseMessage <- setCurrentCourse(githubProject.project, configuration) + } yield s"""Project ${githubProject.project} (${tag}) successfully installed to: + | ${configuration.coursesDirectory.value}/${githubProject.project} + | + |$setCurrentCourseMessage""".stripMargin + + private def setCurrentCourse(project: String, configuration: Configuration): Either[CmtError, String] = { + val courseDirectory = configuration.coursesDirectory.value / project + val studentifiedRepo = StudentifiedRepo(courseDirectory) + SetCurrentCourse.Options(studentifiedRepo).execute(configuration) + } + + private def getStudentAssetUrl(githubProject: GithubProject, tag: String): Either[CmtError, String] = { + val organisation = githubProject.organisation + val project = githubProject.project + Right(s"https://github.com/$organisation/$project/releases/download/$tag/$project-student.zip") + } + + private def downloadStudentAsset( + url: String, + githubProject: GithubProject, + configuration: Configuration): Either[CmtError, ZipFile] = { + val zipFile = ZipFile(configuration.coursesDirectory.value / s"${githubProject.project}.zip") + for { + _ <- downloadFile(url, zipFile) + } yield zipFile + } + + private def downloadFile(fileUri: String, destination: ZipFile): Either[CmtError, Unit] = + Try((new URL(fileUri) #> new File(destination.value.getAbsolutePath)).!) match { + case Success(0) => Right(()) + case _ => s"Failed to download asset: ${fileUri}".toExecuteCommandErrorMessage.asLeft + } + + end extension + end given + + val command = new CmtcCommand[Install.Options] { + + def run(options: Install.Options, args: RemainingArgs): Unit = + args + .enforceNoTrailingArguments() + .flatMap(_ => options.validated().flatMap(_.execute(configuration))) + .printResult() + } + +end Install diff --git a/core/src/main/scala/com/lunatech/cmt/client/command/SetCurrentCourse.scala b/cmtc/src/main/scala/com/lunatech/cmt/client/command/SetCurrentCourse.scala similarity index 93% rename from core/src/main/scala/com/lunatech/cmt/client/command/SetCurrentCourse.scala rename to cmtc/src/main/scala/com/lunatech/cmt/client/command/SetCurrentCourse.scala index c8fe5f9b..06eccff3 100644 --- a/core/src/main/scala/com/lunatech/cmt/client/command/SetCurrentCourse.scala +++ b/cmtc/src/main/scala/com/lunatech/cmt/client/command/SetCurrentCourse.scala @@ -38,9 +38,9 @@ object SetCurrentCourse: .copy(currentCourse = CurrentCourse(StudentifiedRepo(studentRepoRoot))) .flush() .map(_ => s"""Current course set to '${studentRepoRoot.getAbsolutePath}' - | - |Exercises in repository: - |$formattedExerciseList""".stripMargin) + | + |Exercises in repository: + |$formattedExerciseList""".stripMargin) } yield currentCourse val command = new CmtcCommand[SetCurrentCourse.Options] { diff --git a/cmtc/src/test/scala/com/lunatech/cmt/client/cli/InstallArgumentsSpec.scala b/cmtc/src/test/scala/com/lunatech/cmt/client/cli/InstallArgumentsSpec.scala index fa34850d..42105359 100644 --- a/cmtc/src/test/scala/com/lunatech/cmt/client/cli/InstallArgumentsSpec.scala +++ b/cmtc/src/test/scala/com/lunatech/cmt/client/cli/InstallArgumentsSpec.scala @@ -6,6 +6,7 @@ import com.lunatech.cmt.client.command.Install import com.lunatech.cmt.support.TestDirectories import sbt.io.syntax.{File, file} import com.lunatech.cmt.core.cli.ArgParsers.given +import com.lunatech.cmt.client.cli.ArgParsers.forceDeleteDestinationDirectoryArgParser final class InstallArgumentsSpec extends CommandLineArgumentsSpec[Install.Options] with TestDirectories { @@ -18,5 +19,8 @@ final class InstallArgumentsSpec extends CommandLineArgumentsSpec[Install.Option def validArguments(tempDirectory: File) = validArgumentsTable( (Seq("-s", baseDirectory.getAbsolutePath), Install.Options(LocalDirectory(baseDirectory))), (Seq("-s", zippedCourse), Install.Options(ZipFile(file(zippedCourse)))), - (Seq("-s", "bleep/bloop"), Install.Options(GithubProject(organisation = "bleep", project = "bloop")))) + ( + Seq("-s", "bleep/bloop/blop"), + Install.Options(GithubProject(organisation = "bleep", project = "bloop", tag = Some("blop")))), + (Seq("-s", "bleep/bloop"), Install.Options(GithubProject(organisation = "bleep", project = "bloop", tag = None)))) } diff --git a/core/src/test/scala/com/lunatech/cmt/client/command/SetCurrentCourseSpec.scala b/cmtc/src/test/scala/com/lunatech/cmt/client/cli/SetCurrentCourseSpec.scala similarity index 100% rename from core/src/test/scala/com/lunatech/cmt/client/command/SetCurrentCourseSpec.scala rename to cmtc/src/test/scala/com/lunatech/cmt/client/cli/SetCurrentCourseSpec.scala diff --git a/core/src/main/scala/com/lunatech/cmt/Domain.scala b/core/src/main/scala/com/lunatech/cmt/Domain.scala index 9516ac77..263c21f8 100644 --- a/core/src/main/scala/com/lunatech/cmt/Domain.scala +++ b/core/src/main/scala/com/lunatech/cmt/Domain.scala @@ -12,8 +12,10 @@ object Domain { object InstallationSource: final case class LocalDirectory(value: File) extends InstallationSource final case class ZipFile(value: File) extends InstallationSource - final case class GithubProject(organisation: String, project: String) extends InstallationSource { - val displayName = s"$organisation/$project" + final case class GithubProject(organisation: String, project: String, tag: Option[String]) + extends InstallationSource { + val displayName: String = + if tag.isEmpty then s"$organisation/$project" else s"$organisation/$project/${tag.getOrElse("")}" } end InstallationSource } diff --git a/core/src/main/scala/com/lunatech/cmt/Helpers.scala b/core/src/main/scala/com/lunatech/cmt/Helpers.scala index c4a0b955..4bd603db 100644 --- a/core/src/main/scala/com/lunatech/cmt/Helpers.scala +++ b/core/src/main/scala/com/lunatech/cmt/Helpers.scala @@ -244,6 +244,8 @@ object Helpers: d.toInt } + val ignoreProcessStdOutStdErr: sys.process.ProcessLogger = + sys.process.ProcessLogger(_ => (), _ => ()) def copyCleanViaGit(mainRepo: File, tmpDir: File, repoName: String): Either[CmtError, Unit] = import ProcessDSL.* diff --git a/core/src/main/scala/com/lunatech/cmt/ProcessDSL.scala b/core/src/main/scala/com/lunatech/cmt/ProcessDSL.scala index ab430ea9..4b79c0d2 100644 --- a/core/src/main/scala/com/lunatech/cmt/ProcessDSL.scala +++ b/core/src/main/scala/com/lunatech/cmt/ProcessDSL.scala @@ -15,6 +15,7 @@ package com.lunatech.cmt import com.lunatech.cmt.toExecuteCommandErrorMessage import sbt.io.syntax.* +import com.lunatech.cmt.Helpers.ignoreProcessStdOutStdErr import scala.sys.process.Process import scala.util.{Failure, Success, Try} @@ -26,7 +27,7 @@ object ProcessDSL: extension (cmd: ProcessCmd) def runWithStatus(msg: String): Either[CmtError, Unit] = { - val status = Try(Process(cmd.cmd, cmd.workingDir).!) + val status = Try(Process(cmd.cmd, cmd.workingDir).!(ignoreProcessStdOutStdErr)) status match case Success(_) => Right(()) case Failure(ex) => Left(msg.toExecuteCommandErrorMessage) diff --git a/core/src/main/scala/com/lunatech/cmt/client/command/Install.scala b/core/src/main/scala/com/lunatech/cmt/client/command/Install.scala deleted file mode 100644 index a7fc10a6..00000000 --- a/core/src/main/scala/com/lunatech/cmt/client/command/Install.scala +++ /dev/null @@ -1,263 +0,0 @@ -package com.lunatech.cmt.client.command - -import caseapp.* -import cats.effect.IO -import cats.effect.unsafe.implicits.global -import cats.syntax.either.* -import com.lunatech.cmt.* -import com.lunatech.cmt.Domain.InstallationSource.{GithubProject, LocalDirectory, ZipFile} -import com.lunatech.cmt.Domain.{InstallationSource, StudentifiedRepo} -import com.lunatech.cmt.client.Configuration -import com.lunatech.cmt.client.cli.CmtcCommand -import com.lunatech.cmt.client.command.GithubSupport.* -import com.lunatech.cmt.core.cli.ArgParsers.installationSourceArgParser -import com.lunatech.cmt.core.cli.enforceNoTrailingArguments -import com.lunatech.cmt.core.validation.Validatable -import github4s.Github -import github4s.domain.Release -import io.circe.* -import org.http4s.* -import org.http4s.circe.* -import org.http4s.client.{Client, JavaNetClientBuilder} -import sbt.io.IO as sbtio -import sbt.io.syntax.* - -import java.io.File -import java.net.URL -import java.time.ZonedDateTime -import scala.concurrent.Await -import scala.concurrent.duration.* -import scala.sys.process.* - -object Install: - - @AppName("install") - @CommandName("install") - @HelpMessage( - "Install a course - from either a local directory, a zip file on the local file system or a Github project") - final case class Options( - @ExtraName("s") - source: InstallationSource) - - given Validatable[Install.Options] with - extension (options: Install.Options) - def validated(): Either[CmtError, Install.Options] = - options.asRight - end validated - end given - - given Executable[Install.Options] with - extension (cmd: Install.Options) - def execute(configuration: Configuration): Either[CmtError, String] = - cmd.source match { - case localDirectory: LocalDirectory => installFromLocalDirectory(localDirectory) - case zipFile: ZipFile => installFromZipFile(zipFile, configuration) - case githubProject: GithubProject => installFromGithubProject(githubProject, configuration) - } - - private def installFromLocalDirectory(localDirectory: LocalDirectory): Either[CmtError, String] = - GenericError( - s"unable to install course from local directory at '${localDirectory.value.getCanonicalPath}' - installing from a local directory is not supported... yet").asLeft - - private def installFromZipFile( - zipFile: ZipFile, - configuration: Configuration, - deleteZipAfterInstall: Boolean = false): Either[CmtError, String] = - sbtio.unzip(zipFile.value, configuration.coursesDirectory.value) - if (deleteZipAfterInstall) { - sbtio.delete(zipFile.value) - } - s"Unzipped '${zipFile.value.name}' to '${configuration.coursesDirectory.value.getAbsolutePath}'".asRight - - private def installFromGithubProject( - githubProject: GithubProject, - configuration: Configuration): Either[CmtError, String] = { - implicit val httpClient: Client[IO] = JavaNetClientBuilder[IO].create - val github = Github[IO](httpClient, Some(configuration.githubApiToken.value)) - val latestRelease = - github.repos.latestRelease(githubProject.organisation, githubProject.project).unsafeToFuture() - val response = Await.result(latestRelease, 10.seconds) - - response.result match { - case Left(error) => - s"failed to retrieve latest release of ${githubProject.displayName}".toExecuteCommandErrorMessage.asLeft - case Right(None) => - s"failed to retrieve latest release of ${githubProject.displayName}".toExecuteCommandErrorMessage.asLeft - case Right(Some(release)) => - downloadAndInstallStudentifiedRepo(githubProject, release, configuration) - } - } - - private def downloadAndInstallStudentifiedRepo( - githubProject: GithubProject, - release: Release, - configuration: Configuration)(implicit client: Client[IO]): Either[CmtError, String] = - for { - studentAssetUrl <- getStudentAssetUrl(githubProject, release) - _ = printMessage(s"downloading studentified course from '$studentAssetUrl' to courses directory") - downloadedZipFile <- downloadStudentAsset(studentAssetUrl, githubProject, release.tag_name, configuration) - _ <- installFromZipFile(downloadedZipFile, configuration, deleteZipAfterInstall = true) - _ <- setCurrentCourse(githubProject, configuration) - } yield s"${githubProject.project} (${release.tag_name}) successfully installed to ${configuration.coursesDirectory.value}/${githubProject.project}" - - private def setCurrentCourse( - githubProject: GithubProject, - configuration: Configuration): Either[CmtError, String] = { - val courseDirectory = configuration.coursesDirectory.value / githubProject.project - val studentifiedRepo = StudentifiedRepo(courseDirectory) - SetCurrentCourse.Options(studentifiedRepo).execute(configuration) - } - - private def getStudentAssetUrl(githubProject: GithubProject, release: Release)(implicit - httpClient: Client[IO]): Either[CmtError, String] = { - val maybeAssetsFuture = httpClient - .expect[List[Asset]](release.assets_url) - .map { assets => - val requiredName = s"${githubProject.project}-student.zip" - assets.find(_.name == requiredName).map(_.browserDownloadUrl) - } - .unsafeToFuture() - val maybeStudentAsset = Await.result(maybeAssetsFuture, 10.seconds) - maybeStudentAsset.toRight( - s"latest release of ${githubProject.displayName} does not have a studentified zip - unable to install without one".toExecuteCommandErrorMessage) - } - - private def downloadStudentAsset( - url: String, - githubProject: GithubProject, - tagName: String, - configuration: Configuration)(implicit client: Client[IO]): Either[CmtError, ZipFile] = { - val zipFile = ZipFile(configuration.coursesDirectory.value / s"${githubProject.project}.zip") - downloadFile(url, zipFile) - zipFile.asRight - } - - private def downloadFile(fileUri: String, destination: ZipFile)(implicit client: Client[IO]): Unit = - val _ = (new URL(fileUri) #> new File(destination.value.getAbsolutePath)).!! - - end extension - end given - - val command = new CmtcCommand[Install.Options] { - - def run(options: Install.Options, args: RemainingArgs): Unit = - args - .enforceNoTrailingArguments() - .flatMap(_ => options.validated().flatMap(_.execute(configuration))) - .printResult() - } - -end Install - -object GithubSupport: - - case class User( - name: Option[String], - email: Option[String], - login: Option[String], - id: Option[Long], - avatarUrl: Option[String], - gravatarId: Option[String], - url: Option[String], - htmlUrl: Option[String], - followersUrl: Option[String], - followingUrl: Option[String], - gistsUrl: Option[String], - starredUrl: Option[String], - subscriptionsUrl: Option[String], - organizationsUrl: Option[String], - reposUrl: Option[String], - eventsUrl: Option[String], - receivedEventsUrl: Option[String], - userType: Option[String], - siteAdmin: Option[Boolean]) - - implicit lazy val userDecoder: Decoder[User] = Decoder.instance { cursor => - for { - name <- cursor.downField("name").as[Option[String]] - email <- cursor.downField("email").as[Option[String]] - login <- cursor.downField("login").as[Option[String]] - id <- cursor.downField("id").as[Option[Long]] - avatarUrl <- cursor.downField("avatar_url").as[Option[String]] - gravatarId <- cursor.downField("gravatar_id").as[Option[String]] - url <- cursor.downField("url").as[Option[String]] - htmlUrl <- cursor.downField("html_url").as[Option[String]] - followersUrl <- cursor.downField("followers_url").as[Option[String]] - followingUrl <- cursor.downField("following_url").as[Option[String]] - gistsUrl <- cursor.downField("gists_url").as[Option[String]] - starredUrl <- cursor.downField("starred_url").as[Option[String]] - subscriptionsUrl <- cursor.downField("subscriptions_url").as[Option[String]] - organizationsUrl <- cursor.downField("organizations_url").as[Option[String]] - reposUrl <- cursor.downField("repos_url").as[Option[String]] - eventsUrl <- cursor.downField("events_url").as[Option[String]] - receivedEventsUrl <- cursor.downField("received_events_url").as[Option[String]] - userType <- cursor.downField("type").as[Option[String]] - siteAdmin <- cursor.downField("site_admin").as[Option[Boolean]] - } yield User( - name, - email, - login, - id, - avatarUrl, - gravatarId, - url, - htmlUrl, - followersUrl, - followingUrl, - gistsUrl, - starredUrl, - subscriptionsUrl, - organizationsUrl, - reposUrl, - eventsUrl, - receivedEventsUrl, - userType, - siteAdmin) - } - - case class Asset( - url: String, - browserDownloadUrl: String, - id: Long, - name: String, - label: Option[String], - state: String, - contentType: String, - size: Long, - downloadCount: Long, - createdAt: ZonedDateTime, - updatedAt: ZonedDateTime, - uploader: User) - - implicit lazy val assetDecoder: Decoder[Asset] = Decoder.instance { cursor => - for { - url <- cursor.downField("url").as[String] - browserDownloadUrl <- cursor.downField("browser_download_url").as[String] - id <- cursor.downField("id").as[Long] - name <- cursor.downField("name").as[String] - label <- cursor.downField("label").as[Option[String]] - state <- cursor.downField("state").as[String] - contentType <- cursor.downField("content_type").as[String] - size <- cursor.downField("size").as[Long] - downloadCount <- cursor.downField("download_count").as[Long] - createdAt <- cursor.downField("created_at").as[ZonedDateTime] - updatedAt <- cursor.downField("updated_at").as[ZonedDateTime] - uploader <- cursor.downField("uploader").as[User] - } yield Asset( - url, - browserDownloadUrl, - id, - name, - label, - state, - contentType, - size, - downloadCount, - createdAt, - updatedAt, - uploader) - } - - implicit val assetEntityDecoder: EntityDecoder[IO, List[Asset]] = jsonOf[IO, List[Asset]] - -end GithubSupport diff --git a/core/src/main/scala/com/lunatech/cmt/core/cli/ArgParsers.scala b/core/src/main/scala/com/lunatech/cmt/core/cli/ArgParsers.scala index 51d8ef27..d80cd035 100644 --- a/core/src/main/scala/com/lunatech/cmt/core/cli/ArgParsers.scala +++ b/core/src/main/scala/com/lunatech/cmt/core/cli/ArgParsers.scala @@ -27,19 +27,23 @@ object ArgParsers: given installationSourceArgParser: ArgParser[InstallationSource] = { val githubProjectRegex = "([A-Za-z0-9-_]*)\\/([A-Za-z0-9-_]*)".r + val githubProjectWithTagRegex = "([A-Za-z0-9-_]*)\\/([A-Za-z0-9-_]*)\\/(.*)".r def toString(installationSource: InstallationSource): String = installationSource match { - case LocalDirectory(value) => value.getAbsolutePath() - case ZipFile(value) => value.getAbsolutePath() - case GithubProject(organisation, project) => s"$organisation/$project" + case LocalDirectory(value) => value.getAbsolutePath() + case ZipFile(value) => value.getAbsolutePath() + case GithubProject(organisation, project, None) => s"$organisation/$project" + case GithubProject(organisation, project, Some(tag)) => s"$organisation/$project/$tag" } def fromString(str: String): Either[Error, InstallationSource] = { val maybeFile = file(str) val maybeGithub = str match { - case githubProjectRegex(organisation, project) => Some(GithubProject(organisation, project)) - case _ => None + case githubProjectRegex(organisation, project) => Some(GithubProject(organisation, project, None)) + case githubProjectWithTagRegex(organisation, project, tag) => + Some(GithubProject(organisation, project, Some(tag))) + case _ => None } // is it a file? does it exist?