Compare commits

...

14 Commits

Author SHA1 Message Date
2ed99f5493 Prettify 2020-02-02 15:42:03 +01:00
2ba3d01b9c Update README 2020-02-02 15:39:30 +01:00
97041d7012 Add listing gists 2020-02-02 15:34:20 +01:00
f1186c3b11 Add gist functionality 2020-02-02 14:43:22 +01:00
ee11c131ef Cleanup 2020-02-01 16:43:46 +01:00
cfd1fc531b Speed up getForks by using search API 2020-02-01 16:23:47 +01:00
fe9578c9d6 Always show help message 2020-02-01 14:58:54 +01:00
160928e228 Update freeze file 2020-02-01 00:39:04 +01:00
bd07ee8022 Update README 2020-02-01 00:38:44 +01:00
1bc5ae70d9 Add descriptions to commands 2020-02-01 00:38:33 +01:00
f8dd4b9f95 Fix repo parser 2020-02-01 00:26:40 +01:00
20a9fcd210 Fix parser 2020-01-31 23:41:47 +01:00
b031456619 Fix build and warnings 2020-01-31 23:25:14 +01:00
68b5be3edf Comments 2020-01-31 23:22:37 +01:00
5 changed files with 386 additions and 98 deletions

View File

@@ -1,3 +1,40 @@
# ghup
Simple Github helper for myself.
## Installation
If you don't have haskell GHC and cabal installed,
follow [ghcup](https://www.haskell.org/ghcup/) first.
Then issue:
```sh
$ cabal v2-install ghup
```
## Usage
First you need to set the github OAuth (no other method currently supported)
for API access:
```
ghup config --oauth "<your-github-token>"
```
Then follow the help page:
```
Usage: ghup COMMAND
Available options:
-h,--help Show this help text
Available commands:
fork Fork a repository
config Set ghup config (such as OAuth)
delete Delete a forked repository
list-forks List my forks
gistc Create gist
gistl List gists
```

View File

@@ -1,7 +1,6 @@
module Main where
import Control.Error.Util
import Control.Monad
import Control.Monad.Except
import Control.Monad.Reader
import Data.ByteString ( ByteString )
@@ -11,24 +10,22 @@ import Data.Dates ( getCurrentDateTime
, DateTime(..)
)
import Data.Functor ( (<&>) )
import Data.List
import Data.Maybe
import Data.Semigroup ( (<>) )
import qualified Data.Text as T
import qualified Data.Text.Encoding as E
import Data.Time.Calendar
import Data.Time.Clock
import Data.Time.Clock.POSIX
import Data.Time.Format
import Data.Time.Format.ISO8601
import Data.Traversable
import GHup
import GitHub.Auth
import GitHub.Data.Definitions
import GitHub.Data.Name
import GitHub.Data.Gists
import GitHub.Data.Repos
import GitHub.Data.URL
import HPath
import Options.Applicative
import Safe
import System.Console.Pretty
import System.Exit
import Text.Layout.Table
@@ -44,6 +41,8 @@ data Command
| Config ConfigOptions
| Del DelOptions
| ListForks ListForkOptions
| CreateGist CreateGistOptions
| ListGist ListGistOptions
data ForkOptions = ForkOptions
{
@@ -62,17 +61,75 @@ data ConfigOptions = ConfigOptions {
, bPath :: Maybe ByteString
}
data DelOptions = DelOptions {
del :: ByteString
}
data CreateGistOptions = CreateGistOptions {
input :: Input
, description :: Maybe ByteString
, private :: Bool
}
data Input
= FileInput [ByteString]
| StdInput
data ListGistOptions = ListGistOptions
{
lgSince :: Maybe ByteString
, lgDesc :: Bool
}
fileInput :: Parser Input
fileInput =
FileInput
<$> (some
(strOption
(long "file" <> short 'f' <> metavar "FILENAME" <> help
"Input one or more files"
)
)
)
stdInput :: Parser Input
stdInput = flag' StdInput (long "stdin" <> help "Read from stdin")
inputP :: Parser Input
inputP = fileInput <|> stdInput
opts :: Parser Command
opts = subparser
( command "fork" (Fork <$> (info (forkOpts <**> helper) idm))
<> command "config" (Config <$> (info (configOpts <**> helper) idm))
<> command "delete" (Del <$> (info (delOpts <**> helper) idm))
<> command "list-forks" (ListForks <$> (info (lForkOpts <**> helper) idm))
( command
"fork"
(Fork <$> (info (forkOpts <**> helper) (progDesc "Fork a repository")))
<> command
"config"
( Config
<$> (info (configOpts <**> helper)
(progDesc "Set ghup config (such as OAuth)")
)
)
<> command
"delete"
( Del
<$> (info (delOpts <**> helper)
(progDesc "Delete a forked repository")
)
)
<> command
"list-forks"
( ListForks
<$> (info (lForkOpts <**> helper) (progDesc "List my forks"))
)
<> command
"gistc"
(CreateGist <$> (info (cGistOpts <**> helper) (progDesc "Create gist"))
)
<> command
"gistl"
(ListGist <$> (info (lGistOpts <**> helper) (progDesc "List gists")))
)
configOpts :: Parser ConfigOptions
@@ -121,59 +178,157 @@ lForkOpts = ListForkOptions <$> optional
)
)
cGistOpts :: Parser CreateGistOptions
cGistOpts =
CreateGistOptions
<$> inputP
<*> optional
(strOption
(short 'd' <> long "description" <> metavar "DESCRIPTION" <> help
"The description of the gist (optional)"
)
)
<*> switch
(short 'p' <> long "private" <> help
"Whether gist should be private (default: public)"
)
lGistOpts :: Parser ListGistOptions
lGistOpts =
ListGistOptions
<$> optional
(strOption
(short 's' <> long "since" <> metavar "SINCE" <> help
"The repository to fork"
)
)
<*> switch
(short 'd' <> long "descriptions" <> help
"Whether to show descriptions (default: False)"
)
main :: IO ()
main = do
let run e = do
settings <- exceptT
(\_ -> die
-- wrapper to run effects with settings
let
run e = do
settings <-
exceptT
(\_ ->
die
. color Red
$ "Could not get settings, make sure to run 'ghup config' first"
)
pure
$ getSettings
(flip runReaderT) settings . runExceptT . withExceptT show $ e
e <- execParser (info (opts <**> helper) idm) >>= \case
Fork (ForkOptions {..}) -> run $ do
case repoBasePath of
Just rbp -> case parseAbs rbp of
Just p -> prepareRepoForPR' repo (Just p) newBranch
Nothing -> liftIO $ die (color Red $ "Repo path must be absolute")
Nothing -> prepareRepoForPR' repo Nothing newBranch
Config (ConfigOptions {..}) -> do
p <- maybe (pure Nothing) (fmap Just . parseAbs) bPath
writeSettings (Settings (OAuth oAuth) p) <&> Right
Del (DelOptions {..} ) -> run $ deleteFork' del
ListForks (ListForkOptions {..}) -> run $ do
mtime <- liftIO $ case lSince of
Just t -> do
dt <- getCurrentDateTime
let mt =
either (const Nothing) Just . parseDate dt . UTF8.toString $ t
pure $ mt >>= \t ->
(parseTimeM
True
defaultTimeLocale
"%Y-%-m-%-d"
(show (year t) <> "-" <> show (month t) <> "-" <> show (day t)) :: Maybe
UTCTime
)
Nothing -> pure Nothing
e <-
customExecParser (prefs showHelpOnError) (info (opts <**> helper) idm)
>>= \case
forks <- withExceptT show $ getForks mtime
let formatted =
gridString [column expand left def def
,column expand left def def]
$ fmap
(\Repo {..} ->
[ (T.unpack . getUrl $ repoHtmlUrl)
, formatShow (iso8601Format :: Format Day)
(utctDay $ fromJust repoUpdatedAt)
]
)
forks
liftIO $ putStrLn $ formatted
pure ()
-- fork
Fork (ForkOptions {..}) -> run $ do
case repoBasePath of
Just rbp -> case parseAbs rbp of
Just p -> prepareRepoForPR' repo (Just p) newBranch
Nothing ->
liftIO $ die (color Red $ "Repo path must be absolute")
Nothing -> prepareRepoForPR' repo Nothing newBranch
-- config
Config (ConfigOptions {..}) -> do
p <- maybe (pure Nothing) (fmap Just . parseAbs) bPath
writeSettings (Settings (OAuth oAuth) p) <&> Right
-- delete
Del (DelOptions {..} ) -> run $ deleteFork' del
-- list-forks
ListForks (ListForkOptions {..}) -> run $ do
mtime <- parseSince lSince
forks <- withExceptT show $ getForks mtime
let
formatted =
gridString
[column expand left def def, column expand left def def]
$ fmap
(\Repo {..} ->
[ (T.unpack . getUrl $ repoHtmlUrl)
, formatShow (iso8601Format :: Format Day)
(utctDay $ fromJust repoUpdatedAt)
]
)
forks
liftIO $ putStrLn $ formatted
pure ()
-- gistc
CreateGist (CreateGistOptions {..}) -> run $ do
let desc = maybe T.empty E.decodeUtf8 description
public = not private
gist <- case input of
StdInput -> postGistStdin desc public
FileInput files -> do
files' <- for files $ \file -> do
let absPath = parseAbs file
let relPath = parseRel file
case (absPath, relPath) of
(Just a, _) -> pure $ AnyPath $ a
(_, Just a) -> pure $ AnyPath $ a
_ -> throwError (uError "Could not parse path")
postGistFiles files' desc public
liftIO $ putStrLn $ T.unpack $ getUrl $ gistHtmlUrl gist
-- gistl
ListGist (ListGistOptions {..}) -> run $ do
mtime <- parseSince lgSince
gists <- listGists mtime
let
formatted =
gridString
( [column expand left def def]
<> (if lgDesc then [column expand left def def] else [])
<> [column expand left def def]
)
$ fmap
(\Gist {..} ->
[(T.unpack . getUrl $ gistHtmlUrl)]
<> (if lgDesc
then
[ T.unpack $ fromMaybe (T.pack "(No desc)")
gistDescription
]
else []
)
<> [ formatShow (iso8601Format :: Format Day)
(utctDay gistUpdatedAt)
]
)
gists
liftIO $ putStrLn $ formatted
-- print error, if any
case e of
Right () -> pure ()
Left t -> die (color Red $ t)
where
parseSince lSince = do
liftIO $ case lSince of
Just t' -> do
dt <- getCurrentDateTime
let mt =
either (const Nothing) Just . parseDate dt . UTF8.toString $ t'
pure $ mt >>= \t ->
(parseTimeM
True
defaultTimeLocale
"%Y-%-m-%-d"
(show (year t) <> "-" <> show (month t) <> "-" <> show (day t)) :: Maybe
UTCTime
)
Nothing -> pure Nothing

View File

@@ -106,7 +106,7 @@ constraints: any.Cabal ==3.0.0.0,
any.monad-control ==1.0.2.3,
any.mtl ==2.2.2,
any.network ==3.1.1.1,
any.network-uri ==2.6.1.0,
any.network-uri ==2.6.2.0,
any.optparse-applicative ==0.15.1.0,
any.parsec ==3.1.13.0,
any.pem ==0.2.4,

View File

@@ -18,23 +18,25 @@ library
exposed-modules: GHup
-- other-modules:
-- other-extensions:
build-depends: attoparsec ^>= 0.13
, base ^>= 4.12
, bytestring ^>= 0.10
, github ^>= 0.24
, hpath ^>= 0.11
, hpath-io ^>= 0.13.1
, http-client ^>= 0.6.4
, mtl ^>= 2.2
, pretty-terminal ^>= 0.1
, safe-exceptions ^>= 0.1
, streamly ^>= 0.7
, text ^>= 1.2
, time ^>= 1.9
, unix ^>= 2.7
, utf8-string ^>= 1.0
, vector ^>= 0.12
, word8 ^>= 0.1
build-depends: aeson ^>= 1.4
, attoparsec ^>= 0.13
, base ^>= 4.12
, bytestring ^>= 0.10
, github ^>= 0.24
, hpath ^>= 0.11
, hpath-io ^>= 0.13.1
, http-client ^>= 0.6.4
, mtl ^>= 2.2
, pretty-terminal ^>= 0.1
, safe-exceptions ^>= 0.1
, streamly ^>= 0.7
, text ^>= 1.2
, time ^>= 1.9
, unix ^>= 2.7
, unordered-containers ^>= 0.2
, utf8-string ^>= 1.0
, vector ^>= 0.12
, word8 ^>= 0.1
hs-source-dirs: lib
ghc-options: -Wall
default-language: Haskell2010
@@ -54,12 +56,12 @@ executable ghup
, mtl ^>= 2.2
, optparse-applicative ^>= 0.15
, pretty-terminal ^>= 0.1
, safe ^>= 0.3
, table-layout ^>= 0.8
, text ^>= 1.2
, time ^>= 1.9
, utf8-string ^>= 1.0
hs-source-dirs: app
ghc-options: -Wall
default-language: Haskell2010
default-extensions: LambdaCase, MultiWayIf, RecordWildCards, TupleSections

View File

@@ -1,16 +1,19 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeFamilies #-}
{-# OPTIONS_GHC -Wno-orphans #-}
module GHup
(
-- * Types
ForkResult(..)
AnyPath(..)
, ForkResult(..)
, CloneMethod(..)
, ProcessError(..)
, Settings(..)
@@ -27,6 +30,10 @@ module GHup
, deleteFork'
, deleteFork
, getForks
, postGistStdin
, postGistFiles
, postGist
, listGists
-- * Parsers
, parseURL
, ghURLParser
@@ -34,6 +41,7 @@ module GHup
, _info
, _warn
, _err
, uError
)
where
@@ -42,23 +50,36 @@ import Control.Exception.Safe
import Control.Monad.Except hiding ( fail )
import Control.Monad.Fail
import Control.Monad.Reader hiding ( fail )
import Data.Aeson
import Data.Aeson.TH
import Data.Attoparsec.ByteString
import Data.ByteString ( ByteString )
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy as L
import qualified Data.ByteString.UTF8 as UTF8
import qualified Data.ByteString.Lazy.UTF8 as LUTF8
import Data.Functor ( (<&>) )
import qualified Data.HashMap.Strict as H
import Data.HashMap.Strict ( HashMap )
import Data.List
import Data.Proxy
import qualified Data.Text as T
import qualified Data.Text.Encoding as E
import qualified Data.Text.IO as T
import Data.Traversable
import Data.Time.Clock
import Data.Time.Format.ISO8601
import qualified Data.Vector as V
import Data.Word8
import GHC.Exts ( toList )
import GitHub.Auth
import GitHub.Data.Gists
import GitHub.Data.Name
import GitHub.Data.URL
import GitHub.Data.Request
import GitHub.Endpoints.Repos
import GitHub.Endpoints.Search
import GitHub.Endpoints.Users
import GitHub.Request
import HPath
import HPath.IO
@@ -127,6 +148,8 @@ instance Read (Path Abs) where
_ -> []
data AnyPath = forall a . AnyPath (Path a)
@@ -199,9 +222,9 @@ prepareRepoForPR' :: ( MonadIO m
=> ByteString -- ^ string that contains repo url
-> Maybe (Path b) -- ^ base path where the repo should be cloned
-> Maybe ByteString -- ^ PR branch name to switch to
-> ExceptT String m ()
-> ExceptT Error m ()
prepareRepoForPR' repoString mRepobase branch = do
UrlParseResult {..} <- liftEither $ parseURL repoString
UrlParseResult {..} <- (liftEither $ parseURL repoString) ?* uError
repobase <- case mRepobase of
Just r -> fmap Just $ liftIO $ toAbs r
Nothing -> basePath
@@ -220,7 +243,7 @@ prepareRepoForPR :: ( MonadIO m
-> Name Repo
-> Maybe (Path b) -- ^ base path where the repo should be cloned
-> Maybe ByteString -- ^ PR branch name to switch to
-> ExceptT String m ()
-> ExceptT Error m ()
prepareRepoForPR owner repo repobase branch = do
repodest <- case repobase of
Just rb ->
@@ -228,11 +251,11 @@ prepareRepoForPR owner repo repobase branch = do
>>= liftIO
. toAbs
Nothing -> (parseRel $ E.encodeUtf8 $ untagName repo) >>= liftIO . toAbs
ForkResult {..} <- withExceptT show $ forkRepository owner repo
withExceptT show $ ExceptT $ cloneRepository CloneSSH downstream repodest
withExceptT show $ ExceptT $ setUpstream upstream repodest
ForkResult {..} <- (forkRepository owner repo) ?* (uError . show)
(ExceptT $ cloneRepository CloneSSH downstream repodest) ?* (uError . show)
(ExceptT $ setUpstream upstream repodest) ?* (uError . show)
case branch of
Just b -> withExceptT show $ ExceptT $ createBranch b repodest
Just b -> (ExceptT $ createBranch b repodest) ?* (uError . show)
Nothing -> pure ()
lift $ _info
( "To change to the repo dir, run:\n\tcd "
@@ -304,10 +327,10 @@ forkRepository owner repo = do
-- and parses the owner/repo from the given repo url string.
deleteFork' :: (MonadIO m, MonadReader Settings m)
=> ByteString
-> ExceptT String m ()
-> ExceptT Error m ()
deleteFork' repoString = do
UrlParseResult {..} <- liftEither $ parseURL repoString
withExceptT show $ deleteFork owner repo
UrlParseResult {..} <- (liftEither $ parseURL repoString) ?* uError
deleteFork owner repo
deleteFork :: (MonadIO m, MonadReader Settings m)
@@ -317,7 +340,7 @@ deleteFork :: (MonadIO m, MonadReader Settings m)
deleteFork owner repo = do
github_ (repositoryR owner repo) >>= \case
(Repo { repoFork = Just True }) -> pure ()
_ -> throwError (UserError $ T.pack "Not a fork")
_ -> throwError (uError "Not a fork")
githubAuth (deleteRepoR owner repo)
@@ -325,14 +348,67 @@ getForks :: (MonadIO m, MonadReader Settings m)
=> Maybe UTCTime
-> ExceptT Error m [Repo]
getForks mtime = do
repos <- githubAuth (currentUserReposR RepoPublicityAll FetchAll)
user <- githubAuth userInfoCurrentR
let userName = untagName $ userLogin user
repos <- github_
(searchReposR $ mconcat [T.pack "user:", userName, T.pack " fork:only"])
pure $ sortBy (\x y -> compare (repoUpdatedAt y) (repoUpdatedAt x)) $ filter
(\case
Repo { repoFork = Just True, repoUpdatedAt = Just t } ->
maybe True (t >=) mtime
_ -> False
)
(toList repos)
(toList $ searchResultResults repos)
data GistContent = GistContent {
content :: T.Text
} deriving (Show, Eq)
data GistRequest = GistRequest {
description :: T.Text
, public :: Bool
, files :: HashMap T.Text GistContent
} deriving (Show, Eq)
postGistStdin :: (MonadIO m, MonadReader Settings m, MonadThrow m)
=> T.Text -- ^ description
-> Bool -- ^ whether to be public
-> ExceptT Error m Gist
postGistStdin description public = do
content <- liftIO T.getContents
let files = H.fromList [(T.pack "stdout", GistContent content)]
postGist GistRequest { .. }
postGistFiles :: (MonadIO m, MonadReader Settings m, MonadThrow m)
=> [AnyPath] -- ^ files
-> T.Text -- ^ description
-> Bool -- ^ whether to be public
-> ExceptT Error m Gist
postGistFiles files' description public = do
files <- liftIO $ fmap H.fromList $ for files' $ \(AnyPath file) -> do
contents <- (E.decodeUtf8 . L.toStrict) <$> readFile file
filename <- (E.decodeUtf8 . toFilePath) <$> basename file
pure (filename, GistContent contents)
postGist GistRequest { .. }
postGist :: (MonadIO m, MonadReader Settings m)
=> GistRequest
-> ExceptT Error m Gist
postGist greq = githubAuth (command Post [T.pack "gists"] (encode greq))
listGists :: (MonadIO m, MonadReader Settings m)
=> Maybe UTCTime
-> ExceptT Error m [Gist]
listGists mtime = do
let queryString = case mtime of
Just time -> [(u8 "since", Just $ UTF8.fromString $ iso8601Show time)]
Nothing -> []
V.toList <$> githubAuth (pagedQuery [T.pack "gists"] queryString FetchAll)
@@ -358,14 +434,20 @@ ghURLParser =
<|> str "git@github.com:"
<|> empty'
)
*> takeWhile1 (\w -> (w /= _slash) && isAlphaNum w)
*> takeWhile1 (/= _slash)
<* word8 _slash
)
<*> (takeWhile1 isAlphaNum <* ((str ".git" <|> empty') <* endOfInput))
<*> parseRepoName
where
str = string . u8
empty' = str ""
parseRepoName :: Parser ByteString
parseRepoName = do
c <- fmap B.singleton anyWord8
r <- many1' ((str ".git" <* endOfInput) <|> fmap B.singleton anyWord8)
if last r == u8 ".git"
then pure $ mconcat (c : (init r))
else pure (mconcat (c : r)) <* endOfInput
@@ -441,3 +523,15 @@ github_ :: (MonadIO m, ParseResponse mt req, res ~ Either Error req, ro ~ 'RO)
-> ExceptT Error m req
github_ req = do
ExceptT $ liftIO $ github' req
-- | Flipped 'withExceptT'.
(?*) :: Functor m => ExceptT e m a -> (e -> e') -> ExceptT e' m a
(?*) = flip withExceptT
uError :: String -> Error
uError = UserError . T.pack
$(deriveJSON defaultOptions 'GistContent)
$(deriveJSON defaultOptions 'GistRequest)