Skip to content

Commit

Permalink
Remove utilisation of Github access tokens
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
eloots committed Jul 13, 2023
1 parent 4a798a1 commit f3f7749
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 294 deletions.
35 changes: 11 additions & 24 deletions cmta/src/main/scala/com/lunatech/cmt/admin/Domain.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import sbt.io.syntax.File
import com.lunatech.cmt.*
import cats.syntax.either.*

import com.lunatech.cmt.Domain.InstallationSource

object Domain:

final case class RenumberStart(value: Int)
Expand All @@ -43,31 +45,16 @@ object Domain:

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 =
if (str.indexOf("/") > 0) {
str match {
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)
}
} else {
CourseTemplate(Right(InstallationSource.GithubProject("lunatech-labs", str, None)))
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)
}

sealed trait InstallationSource

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, tag: Option[String])
extends InstallationSource {
val displayName: String =
if tag.isEmpty then s"$organisation/$project" else s"$organisation/$project/${tag.getOrElse("")}"
}
end InstallationSource
10 changes: 6 additions & 4 deletions cmta/src/main/scala/com/lunatech/cmt/admin/command/New.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ 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.admin.Domain.InstallationSource.*
import com.lunatech.cmt.Domain.InstallationSource.*
import com.lunatech.cmt.Helpers.ignoreProcessStdOutStdErr
import sbt.io.IO as sbtio
import sbt.io.syntax.*
Expand Down Expand Up @@ -68,14 +68,16 @@ object New:
val organisation = githubProject.organisation
val tag = githubProject.tag
val cloneGit = Process(Seq("git", "clone", s"[email protected]:$organisation/$project.git"), tmpDir)
val cloneHttp = Process(Seq("git", "clone", s"[email protected]:$organisation/$project.git"), tmpDir)
val cloneGh = Process(Seq("gh", "repo", "clone", s"$organisation/$project"), tmpDir)
// TODO CHECK: I wonder if we should support cloning via HTTP...
val cloneHttp = Process(Seq("git", "clone", s"https://github.com/$organisation/$project"), tmpDir)
val cloneRepoStatus =
Try(cloneGit.!).recoverWith(_ => Try(cloneHttp.!)).recoverWith(_ => Try(cloneGh.!)) match {
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
case Failure(_) =>
s"Cannot install from ${githubProject.displayName}: No such repo".toExecuteCommandErrorMessage.asLeft
}
for {
_ <- cloneRepoStatus
Expand Down
154 changes: 154 additions & 0 deletions cmtc/src/main/scala/com/lunatech/cmt/client/command/Install.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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.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 sbt.io.IO as sbtio
import sbt.io.syntax.*

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")
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 extractTag(lsFilesTagLine: String): String =
lsFilesTagLine.replaceAll(""".*refs/tags/""", "")
private def installFromGithubProject(
githubProject: GithubProject,
configuration: Configuration): Either[CmtError, String] = {
val cwd = file(".").getCanonicalFile
// TODO This may be brittle; if the user doesn't have its git credentials set, we
// may want to retry using HTTP
// if it fails, we may still attempt to download the artefact
val maybeTags = Try(
Process(
Seq(
"git",
"-c",
"versionsort.suffix=-",
"ls-remote",
"--tags",
"--refs",
"--sort",
"v:refname",
s"[email protected]:${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)

(aTagWasPassedToInstall, aTagWasPassedToInstallWhichMatchesARelease) match {
case (false, _) =>
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)
}
}

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")
downloadedZipFile <- downloadStudentAsset(studentAssetUrl, githubProject, configuration)
_ <- installFromZipFile(downloadedZipFile, configuration, deleteZipAfterInstall = true)
_ <- setCurrentCourse(githubProject, configuration)
} yield s"${githubProject.project} (${tag}) 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, tag: String): Either[CmtError, String] = {
val organisation = githubProject.organisation
val project = githubProject.project
val tag = githubProject.tag.get
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")
downloadFile(url, zipFile)
zipFile.asRight
}

private def downloadFile(fileUri: String, destination: ZipFile): 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
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
Loading

0 comments on commit f3f7749

Please sign in to comment.