swiss-army-knife-1.2.0.0: lib/Knives/Gitit/Repo.hs
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE MultiWayIf #-}
{-|
Module : Knives.Gitit.Repo
Description : Repo creation and manipulation
Maintainer : fred.mitchell@atomlogik.de
Deals with repo maitenance for the various
collections like Local, GitHub, Gitlab, etc.
-}
module Knives.Gitit.Repo where
import Data.Aeson.Types (Parser, parseMaybe)
import Control.Applicative ((<|>))
import qualified Data.ByteString.Lazy.Char8 as BL
import qualified Data.Text as T
import Network.HTTP.Simple
import qualified Data.ByteString.Char8 as B
import System.Process (CreateProcess(..)
, proc
, readCreateProcessWithExitCode
, readProcess)
import System.Environment (lookupEnv)
import System.Exit (ExitCode(..))
import System.Directory ( doesFileExist
, doesDirectoryExist
, createDirectory
, makeAbsolute)
import Data.List (find)
import Data.Char (isSpace, toLower)
import Data.Maybe (fromMaybe)
-- import Config ( getKnifeConfig, setKnifeConfig )
-- import CommandLine ( GititCommand(..) )
import Data.Aeson
( withObject
, object
, (.:)
, (.:?)
, Value(..)
, decode
, encode
, KeyValue((.=)) )
import Control.Exception (try, SomeException)
-- import Control.Monad (when)
import Utils ()
-- import Control.Monad.Extra (whenJust)
import System.IO (hPutStrLn, stderr)
import Knives.Gitit.Yaml
-- | "sak gitit list" -- list all targets
listTarget :: GititConfig -> IO ()
listTarget cfg = putStrLn $ unlines [t.name <> " - " <> show t.kind | t <- cfg.targets]
-- | Look up a target by its name.
findTarget :: GititConfig -> String -> Maybe Target
findTarget cfg n = find (\t -> t.name == n) cfg.targets
data PathKind = IsFile | IsDir | Missing
deriving (Show, Eq)
pathKind :: FilePath -> IO PathKind
pathKind p = do
isFile <- doesFileExist p
isDir <- doesDirectoryExist p
pure $ if | isFile -> IsFile
| isDir -> IsDir
| otherwise -> Missing
trim :: [Char] -> [Char]
trim = reverse . dropWhile isSpace . reverse . dropWhile isSpace
-- | An environment variable, treating "" as unset.
nonEmptyEnv :: String -> IO (Maybe String)
nonEmptyEnv v = do
e <- lookupEnv v
pure $ case e of
Just s | not (null s) -> Just s
_ -> Nothing
-- | Token from an env var, falling back to a CLI command's output.
tokenFrom :: String -> String -> [String] -> IO (Maybe B.ByteString)
tokenFrom var cmd args = do
env <- nonEmptyEnv var
case env of
Just t -> pure (Just (B.pack t))
Nothing -> do
r <- try (readProcess cmd args "") :: IO (Either SomeException String)
pure $ case r of
Right out | let t = trim out, not (null t) -> Just (B.pack t)
_ -> Nothing
-- | fetches the GITHUB_TOKEN environment variable, or derives it from gh
githubToken :: IO (Maybe B.ByteString)
githubToken = tokenFrom "GITHUB_TOKEN" "gh" ["auth", "token"]
-- | GitLab host; honours GITLAB_HOST (same variable glab uses) for self-hosted instances.
gitlabHost :: IO String
gitlabHost = fromMaybe "gitlab.com" <$> nonEmptyEnv "GITLAB_HOST"
-- | fetches GITLAB_TOKEN, or derives it from glab's config
gitlabToken :: String -> IO (Maybe B.ByteString)
gitlabToken host = tokenFrom "GITLAB_TOKEN" "glab" ["config", "get", "token", "--host", host]
-- | Pull a human-readable error out of a forge's JSON error body.
-- Bitbucket: {"error": {"message": ...}}
-- GitHub: {"message": ..., "errors": [...]}
-- GitLab: {"message": ...} (message may be a string, object or list)
apiMessage :: Value -> Maybe String
apiMessage = parseMaybe $ withObject "error body" $ \o -> bitbucket o <|> plain o
where
bitbucket o = (o .: "error") >>= withObject "error" (.: "message")
plain o = do
m <- o .: "message" :: Parser Value
errs <- o .:? "errors" :: Parser (Maybe Value)
pure $ render m <> maybe "" (\e -> " " <> render e) errs
render (String t) = T.unpack t
render v = BL.unpack (encode v)
-- | Explain an HTTP failure; falls back to a status-based hint when the
-- body is empty (Bitbucket sends a bare 401 for bad credentials).
describeFailure :: Int -> BL.ByteString -> String
describeFailure code body = case decode body >>= apiMessage of
Just m -> m
Nothing
| not (BL.null body) -> BL.unpack body
| code == 401 -> "authentication failed (check the token and, for Bitbucket, the email)"
| code == 403 -> "permission denied (the token lacks the needed scopes or rights)"
| otherwise -> "empty response"
-- | Send an API request without ever throwing: network errors, non-2xx
-- statuses and unparseable bodies are all reported on stderr.
apiCall :: String -> Request -> IO (Maybe Value)
apiCall site req = do
r <- try (httpLBS req) :: IO (Either HttpException (Response BL.ByteString))
case r of
Left e -> Nothing <$ hPutStrLn stderr ("gitit: " <> site <> " request failed: " <> show e)
Right resp -> do
let code = getResponseStatusCode resp
body = getResponseBody resp
if code >= 200 && code < 300
then case decode body of
Just v -> pure (Just v)
Nothing -> Nothing <$ hPutStrLn stderr
("gitit: " <> site <> " returned HTTP " <> show code <> " with a non-JSON body")
else Nothing <$ hPutStrLn stderr
("gitit: " <> site <> " API error (HTTP " <> show code <> "): " <> describeFailure code body)
-- | Return the extracted URL, or report the API's response on stderr.
extractOr :: String -> (Value -> Maybe String) -> Value -> IO (Maybe String)
extractOr site f v = case f v of
Just u -> pure (Just u)
Nothing -> Nothing <$ hPutStrLn stderr ("gitit: " <> site <> " API error: " <> BL.unpack (encode v))
-- | Bitbucket returns clone URLs as links.clone = [{name, href}, ...]
bitbucketSshUrl :: Value -> Maybe String
bitbucketSshUrl = parseMaybe $ withObject "repo" $ \o -> do
clones <- (o .: "links") >>= (.: "clone")
pairs <- mapM (withObject "clone" (\c -> (,) <$> c .: "name" <*> c .: "href")) clones
maybe (fail "no ssh clone link") pure (lookup ("ssh" :: String) pairs)
-- Local repo / remote handling ---------------------------------------------
-- | Which front-end manages the repo in the current directory.
-- jj is preferred when present, since it also covers colocated repos
-- (where it writes the remote through to .git/config).
data LocalVcs = Jj | Git
deriving (Show, Eq)
-- | Run a command, never throwing: a missing executable shows up as
-- exit code 127 with the exception text on stderr.
runQuiet :: String -> [String] -> IO (ExitCode, String, String)
runQuiet cmd args = do
r <- try (readCreateProcessWithExitCode (proc cmd args) "")
:: IO (Either SomeException (ExitCode, String, String))
pure $ either (\e -> (ExitFailure 127, "", show e)) id r
-- | Find the VCS for the current directory, if we are inside a repo at all.
detectVcs :: IO (Maybe LocalVcs)
detectVcs = do
(jc, _, _) <- runQuiet "jj" ["root"]
if jc == ExitSuccess
then pure (Just Jj)
else do
(gc, out, _) <- runQuiet "git" ["rev-parse", "--is-inside-work-tree"]
pure $ if gc == ExitSuccess && trim out == "true" then Just Git else Nothing
-- | Does the local repo already have a remote with this name?
remoteExists :: LocalVcs -> String -> IO Bool
remoteExists Git n = (\(c, _, _) -> c == ExitSuccess) <$> runQuiet "git" ["remote", "get-url", n]
remoteExists Jj n = do
(c, out, _) <- runQuiet "jj" ["git", "remote", "list"]
pure $ c == ExitSuccess && any ((== [n]) . take 1 . words) (lines out)
-- | The command that registers a remote, for running and for printing.
remoteAddCmd :: LocalVcs -> String -> String -> (String, [String])
remoteAddCmd Git n url = ("git", ["remote", "add", n, url])
remoteAddCmd Jj n url = ("jj", ["git", "remote", "add", n, url])
showCmd :: (String, [String]) -> String
showCmd (c, xs) = unwords (c : xs)
-- | Check, before anything is created remotely, that the remote can be
-- added afterwards. Returns the VCS to use, or reports why not.
preflightRemote :: String -> IO (Maybe LocalVcs)
preflightRemote n = do
mvcs <- detectVcs
case mvcs of
Nothing -> Nothing <$ hPutStrLn stderr
"gitit: --add-remote given, but the current directory is not inside a git or jj repo"
Just vcs -> do
taken <- remoteExists vcs n
if taken
then Nothing <$ hPutStrLn stderr
("gitit: the local repo already has a remote named " <> n
<> " (choose another with --remote-name NAME)")
else pure (Just vcs)
-- | Register the newly-created repo as a remote of the local one.
addLocalRemote :: LocalVcs -> String -> String -> IO ()
addLocalRemote vcs n url = do
let c@(cmd, args) = remoteAddCmd vcs n url
(code, _out, err) <- runQuiet cmd args
case code of
ExitSuccess -> putStrLn $ " Added remote " <> n <> " -> " <> url
ExitFailure k -> do
hPutStrLn stderr $ "gitit: adding the remote failed (" <> show k <> "): " <> trim err
hPutStrLn stderr $ " The repo was created; add it by hand with: " <> showCmd c
-- | "sak gitit create TARGET REPONAME [-r | -n NAME]" - create a repo in the
-- specified target, optionally registering it as a remote of the current repo.
createRepo :: GititConfig -> String -> String -> Maybe String -> IO ()
createRepo cfg t r mRemote = case mRemote of
Nothing -> create Nothing
Just n -> preflightRemote n >>= maybe (putStrLn "failed") (\vcs -> create (Just (vcs, n)))
where
create mAdd = do
result <- createRepo' cfg t r
case result of
Nothing -> putStrLn "failed"
Just url -> do
putStrLn ""
putStrLn $ " Creation of " <> url <> " worked."
case mAdd of
Just (vcs, n) -> addLocalRemote vcs n url
Nothing -> do
putStrLn " To add this remote to your local repo, execute:"
putStrLn $ " git remote add " <> t <> " " <> url
putStrLn " or rerun with --add-remote (-r) to have gitit do it."
createRepo' :: GititConfig -> String -> String -> IO (Maybe String)
createRepo' cfg targetName repoName =
case findTarget cfg targetName of
Nothing -> Nothing <$ hPutStrLn stderr ("No target named " <> targetName)
Just t -> case t.kind of
Local -> createLocal t repoName
GitHub -> createGitHub t repoName
GitLab -> createGitLab t repoName
Bitbucket -> createBitbucket t repoName
where
createLocal
, createGitHub
, createGitLab
, createBitbucket :: Target -> String -> IO (Maybe String)
-- Local ----------------------------------------------------------------
createLocal t n = do
-- absolute, so the URL still works as a remote from any directory
repo <- makeAbsolute (t.path <> "/" <> n <> ".git")
pk <- pathKind repo
case pk of
Missing -> initRepo repo
IsFile -> Nothing <$ hPutStrLn stderr (repo <> " already exists and is a file.")
IsDir -> Nothing <$ hPutStrLn stderr (repo <> " already exists.")
initRepo repo = do
putStrLn $ "Creating new repo " <> repo
createDirectory repo
(code
, _out
, err) <- readCreateProcessWithExitCode ((proc "git" ["init", "--bare"]) { cwd = Just repo }) ""
case code of
ExitSuccess -> do
putStrLn $ "Initialized bare repo at " <> repo
return $ Just repo
ExitFailure n -> do
hPutStrLn stderr $ "git init failed (" <> show n <> "): " <> err
return Nothing
-- GitHub ---------------------------------------------------------------
createGitHub _t n = do
mtok <- githubToken
case mtok of
Nothing -> Nothing <$ hPutStrLn stderr
"gitit: no GitHub token (set GITHUB_TOKEN or run `gh auth login`)"
Just tok -> do
res <- createGitHub' tok n False
maybe (pure Nothing) (extractOr "GitHub" (parseMaybe (withObject "repo" (.: "ssh_url")))) res
createGitHub' :: B.ByteString -> String -> Bool -> IO (Maybe Value)
createGitHub' token name priv = do
let req = setRequestMethod "POST"
$ setRequestHeader "Authorization" ["Bearer " <> token]
$ setRequestHeader "Accept" ["application/vnd.github+json"]
$ setRequestHeader "X-GitHub-Api-Version" ["2022-11-28"]
$ setRequestHeader "User-Agent" ["swiss-army-knife-gitit"] -- GitHub rejects requests without one
$ setRequestBodyJSON (object [ "name" .= name
, "private" .= priv ])
$ "https://api.github.com/user/repos"
apiCall "GitHub" req
-- GitLab ---------------------------------------------------------------
createGitLab _t n = do
host <- gitlabHost
mtok <- gitlabToken host
case mtok of
Nothing -> Nothing <$ hPutStrLn stderr
"gitit: no GitLab token (set GITLAB_TOKEN or run `glab auth login`)"
Just tok -> do
res <- createGitLab' host tok n False
maybe (pure Nothing) (extractOr "GitLab" (parseMaybe (withObject "project" (.: "ssh_url_to_repo")))) res
createGitLab' :: String -> B.ByteString -> String -> Bool -> IO (Maybe Value)
createGitLab' host token name priv = do
let vis = if priv then "private" else "public" :: String
req = setRequestHeader "PRIVATE-TOKEN" [token]
$ setRequestHeader "User-Agent" ["swiss-army-knife-gitit"]
$ setRequestBodyJSON (object [ "name" .= name
, "path" .= name
, "visibility" .= vis ])
$ parseRequest_ ("POST https://" <> host <> "/api/v4/projects")
apiCall "GitLab" req
-- Bitbucket ------------------------------------------------------------
createBitbucket _t n = do
mws <- nonEmptyEnv "BITBUCKET_WORKSPACE"
mem <- nonEmptyEnv "BITBUCKET_EMAIL"
mtok <- nonEmptyEnv "BITBUCKET_TOKEN"
mprj <- nonEmptyEnv "BITBUCKET_PROJECT" -- optional project key
case (,,) <$> mws <*> mem <*> mtok of
Nothing -> Nothing <$ hPutStrLn stderr
"gitit: Bitbucket needs BITBUCKET_WORKSPACE, BITBUCKET_EMAIL and BITBUCKET_TOKEN"
Just (ws, em, tok) -> do
res <- createBitbucket' ws em tok mprj n False
maybe (pure Nothing) (extractOr "Bitbucket" bitbucketSshUrl) res
-- Every Bitbucket repo belongs to a project. Without one, Bitbucket
-- uses the workspace's default project, which may not allow creation.
createBitbucket' :: String -> String -> String -> Maybe String -> String -> Bool -> IO (Maybe Value)
createBitbucket' ws email token mProject name priv = do
let slug = map toLower name -- Bitbucket slugs are lowercase
req = setRequestBasicAuth (B.pack email) (B.pack token)
$ setRequestHeader "User-Agent" ["swiss-army-knife-gitit"]
$ setRequestBodyJSON (object ( [ "scm" .= ("git" :: String)
, "is_private" .= priv ]
<> maybe [] (\k -> [ "project" .= object [ "key" .= k ] ]) mProject ))
$ parseRequest_ ( "POST https://api.bitbucket.org/2.0/repositories/"
<> ws <> "/" <> slug )
apiCall "Bitbucket" req