Skip to content

Commit

Permalink
cmta new command (#237)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>
  • Loading branch information
thinkmorestupidless and eloots authored Jul 18, 2023
1 parent fd88865 commit 08d313f
Show file tree
Hide file tree
Showing 15 changed files with 399 additions and 277 deletions.
21 changes: 21 additions & 0 deletions cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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)
}
4 changes: 3 additions & 1 deletion cmta/src/main/scala/com/lunatech/cmt/admin/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
144 changes: 144 additions & 0 deletions cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala
Original file line number Diff line number Diff line change
@@ -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"[email protected]:$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
2 changes: 2 additions & 0 deletions cmtc/src/main/scala/com/lunatech/cmt/client/Domain.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(_))
}
Loading

0 comments on commit 08d313f

Please sign in to comment.