packages feed

hix-0.9.0: lib/Hix/Managed/Cabal/Upload.hs

{-# language CPP #-}

module Hix.Managed.Cabal.Upload where

import Control.Lens (IndexedTraversal', ix, (%~), (^.))
import Control.Lens.Regex.Text (Match, groups, regex)
import Data.List.Extra (unescapeHTML)
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Text as Text
import Distribution.Client.Setup (IsCandidate (IsPublished), RepoContext)
import qualified Distribution.Client.Types.Credentials as Cabal
import qualified Distribution.Client.Upload as Upload
import Distribution.PackageDescription (GenericPackageDescription)
import Distribution.PackageDescription.PrettyPrint (showGenericPackageDescription)
import Distribution.Types.Lens (customFieldsPD, packageDescription)
import Distribution.Verbosity (Verbosity)
import Exon (exon)
import Path (Abs, File, Path, filename, toFilePath)

import Hix.Cabal (catchExitCodeM)
import qualified Hix.Color as Color
import Hix.Data.Monad (M)
import Hix.Data.PackageId (PackageId (..))
import Hix.Error (pathText)
import Hix.Hackage (fatalHackageRequest, hackageGet, hackagePostForm)
import qualified Hix.Log as Log
import Hix.Managed.Cabal.Data.Revision (Revision (..))
import Hix.Managed.Cabal.Init (SolveFlags (..))
import Hix.Managed.Cabal.PackageDescription (parseCabalFile)
import Hix.Managed.Cabal.Repo (withRepoContextM)
import Hix.Managed.Handlers.HackageClient (HackageClient (..), HackageError (..), HackageResponse (..))
import Hix.Monad (appContext, fatalError, noteFatal)
import Hix.Pretty (showP)

type Regex = IndexedTraversal' Int Text Match

newtype User =
  User { value :: Text }
  deriving stock (Eq, Show, Generic)
  deriving newtype (IsString, Ord)

newtype Password =
  Password { value :: Text }
  deriving stock (Eq, Show, Generic)
  deriving newtype (IsString, Ord)

data UploadConfig =
  UploadConfig {
    verbosity :: Verbosity,
    user :: User,
    password :: Password
  }
  deriving stock (Eq, Show, Generic)

upload ::
  UploadConfig ->
  RepoContext ->
  Maybe Cabal.Username ->
  Maybe Cabal.Password ->
  IsCandidate ->
  [FilePath] ->
  IO ()
upload conf ctx =
#if MIN_VERSION_cabal_install(3,12,0)
    Upload.upload conf.verbosity ctx Nothing
#else
    Upload.upload conf.verbosity ctx
#endif

-- TODO When candidates are uploaded, versions don't have to be written to the repo.
-- Since we're gonna add automatic semver incrementing, we can do that in memory and manipulate the cabal files in a
-- temp dir somewhere, even without asking the user for a version, since there's no danger in publishing candidates.
--
-- TODO Allow token authentication
publishPackage ::
  UploadConfig ->
  SolveFlags ->
  Path Abs File ->
  M ()
publishPackage conf flags sourceTar =
  withRepoContextM conf.verbosity flags.global \ ctx ->
    catchExitCodeM "Upload failed" do
      upload conf ctx (Just user) (Just password) candidate [toFilePath sourceTar]
  where
    user = Cabal.Username (toString conf.user.value)

    password = Cabal.Password (toString conf.password.value)

    candidate = IsPublished

revisions ::
  HackageClient ->
  PackageId ->
  M [Revision]
revisions client package =
  appContext [exon|fetching revisions for #{showP package}|] do
    result <- hackageGet Right client [exon|package/##{showP package}/revisions/|] HackageResponseJson
    fatalHackageRequest result

latestRevision ::
  HackageClient ->
  PackageId ->
  M Revision
latestRevision client package = do
  revs <- revisions client package
  noteFatal "Package has no revisions" (maximumBy (comparing (.number)) revs)

updateRevision :: Word -> GenericPackageDescription -> GenericPackageDescription
updateRevision newRevision =
  packageDescription . customFieldsPD %~ \ old -> new : filter (not . (revKey ==) . fst) old
  where
    new = (revKey, show newRevision)
    revKey = "x-revision"

revisionCabalFile ::
  HackageClient ->
  PackageId ->
  Word ->
  M Text
revisionCabalFile client package rev =
  appContext [exon|fetching revision #{Color.number rev} for #{Color.package package}|] do
    result <- hackageGet Right client [exon|package/##{showP package}/revision/#{show rev}.cabal|] HackageResponseHtml
    fatalHackageRequest result

tagRegex :: Regex
tagRegex = [regex|<[^>]+>([^<]*)</[^>]+>|]

checkPublishErrorMessage :: Text -> M ()
checkPublishErrorMessage html =
  for_ (nonEmpty (takeMessages afterErrorHeadline)) \ err ->
    fatalError [exon|Publishing revision failed: #{Text.intercalate " | " (toList err)}|]
  where
    takeMessages = filter (not . Text.null) . fmap sanitize . takeWhile isParagraph
    sanitize = toText . unescapeHTML . toString . stripTags
    afterErrorHeadline = drop 1 (dropWhile (not . errorHeadline) (Text.lines html))
    isParagraph = Text.isPrefixOf "<p>"
    errorHeadline line = line == "<h2>Errors</h2>"
    stripTags line = line ^. tagRegex . groups . ix 0

publishRevisionTo ::
  HackageClient ->
  PackageId ->
  Path Abs File ->
  M Revision
publishRevisionTo client package cabalFile = do
  pkgDesc <- parseCabalFile cabalFile
  prev <- latestRevision client package
  let newRevision = prev.number + 1
      withRevision =
        toText $
        showGenericPackageDescription (updateRevision newRevision pkgDesc)
      payload =
        [
          ("pkgid", slug),
          ("cabalfile", withRevision),
          ("publish", "true")
        ]
  Log.info [exon|Publishing revision #{show newRevision} for #{showP package} to #{showP client.description}|]
  hackagePostForm Right client [exon|package/#{slug}/#{cabalName}/edit|] payload HackageResponseHtml >>= \case
    Right response -> checkPublishErrorMessage response
    Left reason -> case reason of
      HackageFatal msg -> err msg
      HackageNotFound -> err [exon|Hackage couldn't find the package '#{slug}'|]
      HackageParseError msg -> err msg
  new <- latestRevision client package
  when (new.number <= prev.number) do
    fatalError "Revision wasn't published"
  pure new
  where
    cabalName = pathText (filename cabalFile)
    slug = showP package
    err msg = fatalError [exon|Publishing revision failed: #{msg}|]

-- TODO better return value
publishRevision ::
  NonEmpty HackageClient ->
  PackageId ->
  Path Abs File ->
  M Revision
publishRevision clients package cabalFile =
  appContext [exon|publishing revision for #{Color.package package} from #{Color.path cabalFile}|] do
    NonEmpty.head <$> for clients \ client -> publishRevisionTo client package cabalFile