packages feed

oglyad-0.1.1.0: src/Oglyad/Source/Upstream.hs

-- | asks upstreams what their latest version is, using the same endpoints and
-- json fields as anda's andax helpers (andax/fns/tsunagu.rs in FyraLabs/anda)
-- so oglyad sees exactly what `anda update` would see
module Oglyad.Source.Upstream
  ( latestVersion,
  )
where

import Control.Monad.Reader (liftIO)
import Data.Aeson (Value (..), eitherDecode)
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Bifunctor (first)
import Data.List (maximumBy)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector qualified as V
import Oglyad.App (App, HttpError (..), httpGetE)
import Oglyad.Process (run)
import Oglyad.Types (CrateField (..), UpstreamSource (..))
import Oglyad.Version (normalizeVersion, rpmvercmp)

-- | a step into a json document: an object key or an array index
data Step = K Text | I Int

-- | latest upstream version, or a reason we couldn't get it
latestVersion :: UpstreamSource -> App (Either HttpError Text)
latestVersion = \case
  GitHub repo -> json ("https://api.github.com/repos/" <> repo <> "/releases/latest") [K "tag_name"]
  GitHubTag repo -> json ("https://api.github.com/repos/" <> repo <> "/tags?per_page=1") [I 0, K "name"]
  GitLab host i -> json ("https://" <> host <> "/api/v4/projects/" <> i <> "/releases/") [I 0, K "tag_name"]
  GitLabTag host i -> json ("https://" <> host <> "/api/v4/projects/" <> i <> "/repository/tags") [I 0, K "name"]
  Gitea host repo -> json ("https://" <> host <> "/api/v1/repos/" <> repo <> "/releases/latest") [K "tag_name"]
  GiteaTag host repo -> json ("https://" <> host <> "/api/v1/repos/" <> repo <> "/tags") [I 0, K "name"]
  PyPI name -> json ("https://pypi.org/pypi/" <> name <> "/json") [K "info", K "version"]
  Crates field name -> json ("https://crates.io/api/v1/crates/" <> name) [K "crate", K (crateKey field)]
  Npm name -> json ("https://registry.npmjs.org/" <> name <> "/latest") [K "version"]
  Hackage name -> json ("https://hackage.haskell.org/package/" <> name <> "/preferred.json") [K "normal-version", I 0]
  Gems name -> json ("https://rubygems.org/api/v1/versions/" <> name <> "/latest.json") [K "version"]
  Hex name -> json ("https://hex.pm/api/packages/" <> name) [K "latest_stable_version"]
  Cran name -> json ("https://crandb.r-pkg.org/" <> name) [K "Version"]
  Opam name -> opam name
  AnsibleGalaxy ns coll ->
    json
      ("https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/index/" <> ns <> "/" <> coll <> "/versions/?limit=1&ordering=-version")
      [K "data", I 0, K "version"]
  GitTags url -> first HttpFailed <$> liftIO (gitTags url)
  Derived how -> pure (Left (HttpFailed ("follows " <> how <> ", not an upstream release")))
  CommitTracked -> pure (Left (HttpFailed "tracks a commit, not a version"))
  Unknown _ -> pure (Left (HttpFailed "unparsed update.rhai"))
  where
    crateKey = \case
      MaxStable -> "max_stable_version"
      Max -> "max_version"
      Newest -> "newest_version"

-- | GET a url and walk a path into the json it returns
json :: Text -> [Step] -> App (Either HttpError Text)
json url path = do
  body <- httpGetE url
  pure $ do
    v <- body >>= either (\e -> Left (HttpFailed ("bad json from " <> url <> ": " <> T.pack e))) Right . eitherDecode
    case walk path v of
      Just (String s) | not (T.null s) -> Right s
      _ -> Left (HttpFailed ("no version at expected spot in " <> url))

walk :: [Step] -> Value -> Maybe Value
walk [] v = Just v
walk (K k : rest) (Object o) = KM.lookup (Key.fromText k) o >>= walk rest
walk (I i : rest) (Array a) = a V.!? i >>= walk rest
walk _ _ = Nothing

-- | opam has no "latest" endpoint. anda lists the package's directory in
-- ocaml/opam-repository and picks the highest `name.version` entry
opam :: Text -> App (Either HttpError Text)
opam name = do
  let url = "https://api.github.com/repos/ocaml/opam-repository/contents/packages/" <> name
  body <- httpGetE url
  pure $ do
    v <- body >>= either (Left . HttpFailed . T.pack) Right . eitherDecode
    let versions = case v of
          Array entries ->
            [ ver
            | Object o <- V.toList entries,
              Just (String n) <- [KM.lookup "name" o],
              Just ver <- [T.stripPrefix (name <> ".") n]
            ]
          _ -> []
    case versions of
      [] -> Left (HttpFailed ("no versions listed at " <> url))
      _ -> Right (maximumBy rpmvercmp versions)

-- | newest tag on a git remote, for forges without a releases api.
-- `git ls-remote` speaks the smart http protocol, so nothing gets cloned
gitTags :: Text -> IO (Either Text Text)
gitTags url = do
  out <- run "git" ["ls-remote", "--tags", "--refs", T.unpack url]
  pure $ case out of
    Left err -> Left ("git ls-remote failed for " <> url <> ": " <> err)
    Right o -> case [t | l <- T.lines o, Just t <- [T.stripPrefix "refs/tags/" (T.takeWhileEnd (/= '\t') l)]] of
      [] -> Left ("no tags at " <> url)
      tags -> Right (maximumBy (\a b -> rpmvercmp (normalizeVersion a) (normalizeVersion b)) tags)