github-backup 1.20120126 → 1.20120131
raw patch · 7 files changed
+492/−176 lines, 7 files
Files
- Github/Data/Readable.hs +12/−0
- Makefile +10/−2
- README.md +48/−22
- Utility/State.hs +26/−0
- github-backup.1 +17/−0
- github-backup.cabal +7/−6
- github-backup.hs +372/−146
+ Github/Data/Readable.hs view
@@ -0,0 +1,12 @@+{-# LANGUAGE StandaloneDeriving #-}++-- | This module re-exports the @Github.Data.Definitions@ module, adding+-- instances of @Read@ to it.++module Github.Data.Readable (module Github.Data.Definitions) where++import Github.Data.Definitions++deriving instance Read GithubDate+deriving instance Read GithubUser+deriving instance Read Repo
Makefile view
@@ -1,5 +1,7 @@-GHCFLAGS=-O2 -Wall+BASEFLAGS=-Wall -fno-warn-orphans+GHCFLAGS=-O2 $(BASEFLAGS) bins=github-backup+mans=github-backup.1 all=$(bins) ifdef PROFILE@@ -16,11 +18,17 @@ all: $(all) # Disables optimisation. Not for production use.-fast: GHCFLAGS=-Wall+fast: GHCFLAGS=$(BASEFLAGS) fast: $(bins) $(bins): $(GHCMAKE) $@++install: all+ install -d $(DESTDIR)$(PREFIX)/bin+ install $(bins) $(DESTDIR)$(PREFIX)/bin+ install -d $(DESTDIR)$(PREFIX)/share/man/man1+ install -m 0644 $(mans) $(DESTDIR)$(PREFIX)/share/man/man1 clean: rm -f $(bins)
README.md view
@@ -1,43 +1,51 @@ github-backup is a simple tool you run in a git repository you cloned from-Github. It backs up everything Github knows about the repository, including-other forks, issues, comments, milestones, pull requests, and watchers.+GitHub. It backs up everything GitHub publishes about the repository,+including other forks, issues, comments, wikis, milestones, pull requests,+and watchers. ## Installation - git clone git://github.com/joeyh/github-backup- cd github-backup- make- ./github-backup+ git clone git://github.com/joeyh/github-backup+ cd github-backup+ make Or use cabal: - cabal install github-backup --bindir=$HOME/bin+ cabal install github-backup --bindir=$HOME/bin -## Why backup Github+## Use + Run `github-backup` with no parameters, inside a git repository cloned+ from GitHub to back up that repository.++ Or, run `github-backup username` to clone and back up all of a GitHub+ user's repositories.++## Why backup GitHub repositories+ There are a couple of reasons to want to back this stuff up: -* In case something happens to Github. More generally because+* In case something happens to GitHub. More generally because keeping your data in the cloud *and* relying on the cloud to back it up is foolish. * So you can keep working on your repository while on a plane, or- on a remote beach or mountiaintop. Just like Linus intended.+ on a remote beach or mountaintop. Just like Linus intended. ## What to expect -Each time you run github-backup, it will find any new forks of your project-on github. It will add remotes to your repository for the forks, using names-like `github_linus_divemonitor`. It will fetch from every fork.+Each time you run github-backup, it will find any new forks on GitHub. It+will add remotes to your repository for the forks, using names like+`github_linus_divemonitor`. It will fetch from every fork. -Then the next pass will download metadata from each fork. This is stored+It downloads metadata from each fork. This is stored into a branch named "github". Each fork gets a directory in there, like `linus_divemonitor`. Inside the directory there will be some-files, like `linus_divemonitor/watchers`. There maybe be further+files, like `linus_divemonitor/watchers`. There may be further directories, like for comments: `linus_divemonitor/comments/1`. You can follow the commits to the github branch to see what information-changed on github over time.+changed on GitHub over time. The format of the files in the github branch is currently Haskell serialized data types. This is plain text, and readable, if you squint.@@ -45,18 +53,36 @@ ## Limitations github-backup is repository-focused. It does not try to back up other-information from Github. In particular, social network stuff, like+information from GitHub. In particular, social network stuff, like users who are following you, is not backed up. -github-backup will find and backup all forks of a repository, and all forks+github-backup will find and backup forks of a repository, and all forks of those forks, etc. However, it cannot go *up* the fork tree. So if-your Github repositoriy is a fork of something else, the something else+your GitHub repositoriy is a fork of something else, the something else won't be backed up. There is an easy solution though. Just add the parent as a git remote. Then github-backup will find it, and back it up. -Currently, github-backup re-downloads all issues, comments, and so on-each time it's run. This may be slow if your repo has a lot of them.+Currently, only 30 of each thing will be returned. This is a bug in +the haskell github library I'm using. Hope to get it fixed soon. +Currently, the GitHub API does not seem to provide a way to access notes+added to commits and notes added to lines of code. So those notes won't get+backed up. The GitHub folks have been told about this limitation of their API.++The labels that can be added to issues and milestones are not backed up.+Neither are the hooks. They could be, but don't seem important+enough for the extra work involved. Yell if you need them.++github-backup re-downloads all issues, comments, and so on+each time it's run. This may be slow if your repo has a lot of them,+or even if it just has a lot of forks.++Bear in mind that this uses the GitHub API; don't run it every 5 minutes.+GitHub [rate limits](http://developer.github.com/v3/#rate-limiting) the+API to 5000 requests per hour. However, github-backup *does* do an+incremental backup, picking up where it left off, so will complete the+backup eventually even if it's rate limited.+ ## Author github-backup was written by Joey Hess <joey@kitenet.net>@@ -64,4 +90,4 @@ It is made possible thanks to: * Mike Burns's [haskell github library](http://hackage.haskell.org/package/github)-* Github, for providing an API exposing this data. +* GitHub, for providing an API exposing this data.
+ Utility/State.hs view
@@ -0,0 +1,26 @@+{- state monad support+ -+ - Copyright 2012 Joey Hess <joey@kitenet.net>+ -+ - Licensed under the GNU GPL version 3 or higher.+ -}++module Utility.State where++import Control.Monad.State.Strict++{- Modifies Control.Monad.State's state, forcing a strict update.+ - This avoids building thunks in the state and leaking.+ - Why it's not the default, I don't know.+ -+ - Example: changeState $ \s -> s { foo = bar }+ -}+changeState :: MonadState s m => (s -> s) -> m ()+changeState f = do+ x <- get+ put $! f x++{- Gets a value from the internal state, selected by the passed value+ - constructor. -}+getState :: MonadState s m => (s -> a) -> m a+getState = gets
+ github-backup.1 view
@@ -0,0 +1,17 @@+.\" -*- nroff -*-+.TH github-backup 1 "Commands"+.SH NAME+github-backup \- backs up data from GitHub+.SH SYNOPSIS+.B github-backup [\fIusername\fP]+.SH DESCRIPTION+.I github-backup+is a simple tool you run in a git repository you cloned from+GitHub. It backs up everything GitHub publishes about the repository,+including other forks, issues, comments, wikis, milestones, pull requests,+and watchers.+.PP+Alternately, if you pass it the username of a GitHub user, it will check+out, and back up, all that user's repositories.+.SH AUTHOR +Joey Hess <joey@kitenet.net>
github-backup.cabal view
@@ -1,5 +1,5 @@ Name: github-backup-Version: 1.20120126+Version: 1.20120131 Cabal-Version: >= 1.6 License: GPL Maintainer: Joey Hess <joey@kitenet.net>@@ -8,11 +8,12 @@ Copyright: 2012 Joey Hess License-File: GPL Build-Type: Simple-Extra-Source-Files: README.md Makefile Git/Construct.hs Git/Command.hs- Git/Branch.hs Git/Url.hs Git/Ref.hs Git/Sha.hs Git/Types.hs- Git/Config.hs Git/Queue.hs Utility/Misc.hs Common.hs- Utility/TempFile.hs Utility/Monad.hs Utility/PartialPrelude.hs- Utility/Path.hs Utility/Directory.hs Utility/SafeCommand.hs Git.hs+Extra-Source-Files: README.md Makefile github-backup.1+ Common.hs github-backup.hs Git/Url.hs Git/Types.hs Git/Queue.hs Git/Ref.hs+ Git/Branch.hs Git/Sha.hs Git/Config.hs Git/Command.hs Git/Construct.hs+ Setup.hs Github/Data/Readable.hs Utility/Path.hs Utility/TempFile.hs+ Utility/State.hs Utility/Monad.hs Utility/Misc.hs Utility/SafeCommand.hs+ Utility/Directory.hs Utility/PartialPrelude.hs Git.hs Homepage: https://github.com/joeyh/github-backup Category: Utility Synopsis: backs up everything github knows about a repository, to the repository
github-backup.hs view
@@ -5,13 +5,18 @@ - Licensed under the GNU GPL version 3 or higher. -} +{-# LANGUAGE GeneralizedNewtypeDeriving #-}+ module Main where +import qualified Data.Map as M+import qualified Data.Set as S+import Data.Either import System.Environment-import Control.Exception (bracket)-import System.Posix.Directory (changeWorkingDirectory)+import Control.Exception (bracket, try, SomeException) import Text.Show.Pretty-import qualified Github.Data.Definitions as Github+import Control.Monad.State.Strict+import qualified Github.Data.Readable as Github import qualified Github.Repos as Github import qualified Github.Repos.Forks as Github import qualified Github.PullRequests as Github@@ -21,6 +26,7 @@ import qualified Github.Issues.Milestones import Common+import Utility.State import qualified Git import qualified Git.Construct import qualified Git.Config@@ -28,69 +34,212 @@ import qualified Git.Command import qualified Git.Ref import qualified Git.Branch-import qualified Git.Queue -usage :: String-usage = "usage: github-backup [directory]"--getLocalRepo :: [String] -> IO Git.Repo-getLocalRepo [] = Git.Construct.fromCwd-getLocalRepo (d:[]) = Git.Construct.fromPath d-getLocalRepo _ = error usage- -- A github user and repo. data GithubUserRepo = GithubUserRepo String String--instance Show (GithubUserRepo) where- show (GithubUserRepo user remote) = "git://github.com/" ++ user ++ "/" ++ remote ++ ".git"+ deriving (Eq, Show, Read, Ord) toGithubUserRepo :: Github.Repo -> GithubUserRepo toGithubUserRepo r = GithubUserRepo (Github.githubUserLogin $ Github.repoOwner r) (Github.repoName r) -remoteFor :: Github.Repo -> String-remoteFor r = "github_" ++- (Github.githubUserLogin $ Github.repoOwner r) ++ "_" ++- (Github.repoName r)+repoUrl :: GithubUserRepo -> String+repoUrl (GithubUserRepo user remote) =+ "git://github.com/" ++ user ++ "/" ++ remote ++ ".git" -showRepo :: Github.Repo -> String-showRepo = show . toGithubUserRepo+repoWikiUrl :: GithubUserRepo -> String+repoWikiUrl (GithubUserRepo user remote) =+ "git://github.com/" ++ user ++ "/" ++ remote ++ ".wiki.git" -runRepoWith :: (String -> String -> b -> IO (Either Github.Error v)) -> b -> Github.Repo -> IO (Maybe v)-runRepoWith a b = run (\user repo -> a user repo b) . toGithubUserRepo+-- A name for a github api call.+type ApiName = String -runRepo :: (String -> String -> IO (Either Github.Error v)) -> Github.Repo -> IO (Maybe v)-runRepo a = run a . toGithubUserRepo+-- A request to make of github. It may have an extra parameter.+data Request = RequestSimple ApiName GithubUserRepo+ | RequestNum ApiName GithubUserRepo Int+ deriving (Eq, Show, Read, Ord) -run :: (String -> String -> IO (Either Github.Error v)) -> GithubUserRepo -> IO (Maybe v)-run a r@(GithubUserRepo user repo) = handle =<< a user repo+requestRepo :: Request -> GithubUserRepo+requestRepo (RequestSimple _ repo) = repo+requestRepo (RequestNum _ repo _) = repo++requestName :: Request -> String+requestName (RequestSimple name _) = name+requestName (RequestNum name _ _) = name++data BackupState = BackupState+ { failedRequests :: S.Set Request+ , retriedRequests :: S.Set Request+ , backupRepo :: Git.Repo+ }++{- Our monad. -}+newtype Backup a = Backup { runBackup :: StateT BackupState IO a }+ deriving (+ Monad,+ MonadState BackupState,+ MonadIO,+ Functor,+ Applicative+ )++inRepo :: (Git.Repo -> IO a) -> Backup a+inRepo a = liftIO . a =<< getState backupRepo++failedRequest :: Request -> Github.Error-> Backup ()+failedRequest req e = unless (ignorable e) $ do+ set <- getState failedRequests+ changeState $ \s -> s { failedRequests = S.insert req set } where- handle (Right v) = return $ Just v- handle (Left e) = do- hPutStrLn stderr $ "problem accessing API for " ++ show r ++ ": " ++ show e- return Nothing+ ignorable (Github.JsonError m) =+ "disabled for this repo" `isInfixOf` m+ ignorable _ = False -{- Finds already configured remotes that use github.- - Fetches from them because it would be surprising if a github backup- - didn't do so, and this is the only place the remote names are available. -}-gitHubRemotes :: Git.Repo -> IO [Github.Repo]-gitHubRemotes r = do- let (repos, remotes) = unzip $ gitHubUserRepos r- when (null remotes) $ error "no github remotes found"- forM_ repos $ \repo ->- Git.Command.runBool "fetch" [Param $ fromJust $ Git.Types.remoteName repo] r- catMaybes <$> mapM (run Github.userRepo) remotes+runRequest :: Request -> Backup ()+runRequest req = do+ -- avoid re-running requests that were already retried+ retried <- getState retriedRequests+ unless (S.member req retried) $+ (lookupApi req) req -gitHubUserRepos :: Git.Repo -> [(Git.Repo, GithubUserRepo)]-gitHubUserRepos = mapMaybe check . Git.Types.remotes+type Storer = Request -> Backup ()+data ApiListItem = ApiListItem ApiName Storer Bool+apiList :: [ApiListItem]+apiList = + [ ApiListItem "userrepo" userrepoStore True+ , ApiListItem "watchers" watchersStore True+ , ApiListItem "pullrequests" pullrequestsStore True+ , ApiListItem "pullrequest" pullrequestStore False+ , ApiListItem "milestones" milestonesStore True+ , ApiListItem "issues" issuesStore True+ , ApiListItem "issuecomments" issuecommentsStore False+ -- comes last because it recurses on to the forks+ , ApiListItem "forks" forksStore True+ ]++{- Map of Github api calls we can make to store their data. -}+api :: M.Map ApiName Storer+api = M.fromList $ map (\(ApiListItem n s _) -> (n, s)) apiList++{- List of toplevel api calls that are followed to get all data. -}+toplevelApi :: [ApiName]+toplevelApi = map (\(ApiListItem n _ _) -> n) $+ filter (\(ApiListItem _ _ toplevel) -> toplevel) apiList++lookupApi :: Request -> Storer+lookupApi req = fromMaybe bad $ M.lookup name api where+ name = requestName req+ bad = error $ "internal error: bad api call: " ++ name++userrepoStore :: Storer+userrepoStore = simpleHelper Github.userRepo $ \req r -> do+ when (Github.repoHasWiki r == Just True) $+ updateWiki $ toGithubUserRepo r+ store "repo" req r++watchersStore :: Storer+watchersStore = simpleHelper Github.watchersFor $ storeSorted "watchers"++pullrequestsStore :: Storer+pullrequestsStore = simpleHelper Github.pullRequestsFor $+ forValues $ \req r -> do+ let repo = requestRepo req+ let n = Github.pullRequestNumber r+ runRequest $ RequestNum "pullrequest" repo n++pullrequestStore :: Storer+pullrequestStore = numHelper Github.pullRequest $ \n ->+ store ("pullrequest" </> show n)++milestonesStore :: Storer+milestonesStore = simpleHelper Github.Issues.Milestones.milestones $+ forValues $ \req m -> do+ let n = Github.milestoneNumber m+ store ("milestone" </> show n) req m++issuesStore :: Storer+issuesStore = withHelper Github.issuesForRepo [] $ forValues $ \req i -> do+ let repo = requestRepo req+ let n = Github.issueNumber i+ store ("issue" </> show n) req i+ runRequest (RequestNum "issuecomments" repo n)++issuecommentsStore :: Storer+issuecommentsStore = numHelper Github.Issues.Comments.comments $ \n ->+ forValues $ \req c -> do+ let i = Github.issueCommentId c+ store ("issue" </> show n ++ "_comment" </> show i) req c++forksStore :: Storer+forksStore = simpleHelper Github.forksFor $ \req fs -> do+ storeSorted "forks" req fs+ mapM_ (traverse . toGithubUserRepo) fs+ where+ traverse fork = whenM (addFork fork) $+ gatherMetaData fork++forValues :: (Request -> v -> Backup ()) -> Request -> [v] -> Backup ()+forValues handle req vs = forM_ vs (handle req)++type ApiCall v = String -> String -> IO (Either Github.Error v)+type ApiWith v b = String -> String -> b -> IO (Either Github.Error v)+type ApiNum v = ApiWith v Int+type Handler v = Request -> v -> Backup ()+type Helper = Request -> Backup ()++simpleHelper :: ApiCall v -> Handler v -> Helper+simpleHelper call handle req@(RequestSimple _ (GithubUserRepo user repo)) =+ either (failedRequest req) (handle req) =<< liftIO (call user repo)+simpleHelper _ _ r = badRequest r++withHelper :: ApiWith v b -> b -> Handler v -> Helper+withHelper call b handle req@(RequestSimple _ (GithubUserRepo user repo)) =+ either (failedRequest req) (handle req) =<< liftIO (call user repo b)+withHelper _ _ _ r = badRequest r++numHelper :: ApiNum v -> (Int -> Handler v) -> Helper+numHelper call handle req@(RequestNum _ (GithubUserRepo user repo) num) =+ either (failedRequest req) (handle num req) =<< liftIO (call user repo num)+numHelper _ _ r = badRequest r++badRequest :: Request -> a+badRequest r = error $ "internal error: bad request type " ++ show r++store :: Show a => FilePath -> Request -> a -> Backup ()+store filebase req val = do+ file <- location (requestRepo req) <$> workDir+ liftIO $ do+ createDirectoryIfMissing True (parentDir file)+ writeFile file (ppShow val)+ where+ location (GithubUserRepo user repo) workdir =+ workdir </> user ++ "_" ++ repo </> filebase++workDir :: Backup FilePath+workDir = (</>)+ <$> (Git.gitDir <$> getState backupRepo)+ <*> pure "github-backup.tmp"++storeSorted :: Ord a => Show a => FilePath -> Request -> [a] -> Backup ()+storeSorted file req val = store file req (sort val)++gitHubRepos :: Backup [Git.Repo]+gitHubRepos = fst . unzip . gitHubPairs <$> getState backupRepo++gitHubRemotes :: Backup [GithubUserRepo]+gitHubRemotes = snd . unzip . gitHubPairs <$> getState backupRepo++gitHubPairs :: Git.Repo -> [(Git.Repo, GithubUserRepo)]+gitHubPairs = filter (not . wiki ) . mapMaybe check . Git.Types.remotes+ where check r@Git.Repo { Git.Types.location = Git.Types.Url u } = headMaybe $ mapMaybe (checkurl r $ show u) gitHubUrlPrefixes check _ = Nothing checkurl r u prefix | prefix `isPrefixOf` u && length bits == 2 =- Just $ (r,+ Just (r, GithubUserRepo (bits !! 0) (dropdotgit $ bits !! 1)) | otherwise = Nothing@@ -100,6 +249,7 @@ dropdotgit s | ".git" `isSuffixOf` s = take (length s - length ".git") s | otherwise = s+ wiki (_, GithubUserRepo _ u) = ".wiki" `isSuffixOf` u {- All known prefixes for urls to github repos. -} gitHubUrlPrefixes :: [String]@@ -111,133 +261,209 @@ , "ssh://git@github.com/~/" ] -{- Recursively look for forks, and forks of forks, of the repos.- - Note that this guards against cycles in the fork tree, although- - presumably that should never happen. -}-findForks :: [Github.Repo] -> IO [Github.Repo]-findForks = findForks' []-findForks' :: [Github.Repo] -> [Github.Repo] -> IO [Github.Repo]-findForks' _ [] = return []-findForks' done rs = do- forks <- filter new . concat . catMaybes <$>- mapM (runRepo Github.forksFor) rs- forks' <- findForks' (done++rs) forks- return $ nub $ forks ++ forks'- where- new r = not $ r `elem` rs || r `elem` done--{- Adds new forks, fetching from them. -}-addForks :: Git.Repo -> [Github.Repo] -> IO ()-addForks _ [] = putStrLn "no new forks"-addForks r forks = do - putStrLn $ "new forks: " ++ unwords (map showRepo forks)- forM_ forks $ \fork -> do- Git.Command.runBool "remote"- [ Param "add"- , Param "-f"- , Param $ remoteFor fork- , Param $ Github.repoGitUrl fork- ] r- onGithubBranch :: Git.Repo -> IO () -> IO () onGithubBranch r a = bracket prep cleanup (const a) where prep = do oldbranch <- Git.Branch.current r- exists <- Git.Ref.matching (Git.Ref $ "refs/heads/" ++ branchname) r+ when (oldbranch == Just branchref) $+ error $ "it's not currently safe to run github-backup while the " +++ branchname ++ " branch is checked out!"+ exists <- Git.Ref.matching branchref r if null exists- then Git.Command.run "checkout"- [Param "--orphan", Param branchname] r- else Git.Command.run "checkout"- [Param branchname] r+ then checkout [Param "--orphan", Param branchname]+ else checkout [Param branchname] return oldbranch cleanup Nothing = return () cleanup (Just oldbranch) | name == branchname = return ()- | otherwise = Git.Command.run "checkout" [Param name] r+ | otherwise = checkout [Param "--force", Param name] where name = show $ Git.Ref.base oldbranch+ checkout params = Git.Command.run "checkout" (Param "-q" : params) r branchname = "github"+ branchref = Git.Ref $ "refs/heads/" ++ branchname -commitFiles :: Git.Repo -> [FilePath] -> IO ()-commitFiles _ [] = return ()-commitFiles r files = do- mass "add" [Param "-f"]- mass "commit" [Param "-m", Param "github-backup"]- where- mass subcommand params = do- let q = Git.Queue.add Git.Queue.new subcommand params files- _ <- Git.Queue.flush q r+{- Commits all files in the workDir into git, and deletes it. -}+commitWorkDir :: Backup ()+commitWorkDir = do+ dir <- workDir+ r <- getState backupRepo+ let git_false_worktree ps = boolSystem "git" $+ [ Param ("--work-tree=" ++ dir)+ , Param ("--git-dir=" ++ Git.gitDir r)+ ] ++ ps+ liftIO $ whenM (doesDirectoryExist dir) $ onGithubBranch r $ do+ _ <- git_false_worktree [ Param "add", Param "." ]+ _ <- git_false_worktree [ Param "commit",+ Param "-a", Param "-m", Param "github-backup"]+ removeDirectoryRecursive dir++updateWiki :: GithubUserRepo -> Backup ()+updateWiki fork = do+ remotes <- Git.remotes <$> getState backupRepo+ if any (\r -> Git.remoteName r == Just remote) remotes+ then do+ _ <- fetchwiki return ()+ else do+ -- github often does not really have a wiki,+ -- don't bloat config if there is none+ unlessM (addRemote remote $ repoWikiUrl fork) $+ removeRemote remote+ return ()+ where+ fetchwiki = inRepo $ Git.Command.runBool "fetch" [Param remote]+ remote = remoteFor fork+ remoteFor (GithubUserRepo user repo) =+ "github_" ++ user ++ "_" ++ repo ++ ".wiki" -saveMetaData :: Show a => Github.Repo -> String -> a -> IO FilePath-saveMetaData repo file val = do- let file' = remoteFor repo </> file- createDirectoryIfMissing True (parentDir file')- writeFile file' (ppShow val)- return file'+addFork :: GithubUserRepo -> Backup Bool+addFork fork = do+ remotes <- gitHubRemotes+ if fork `elem` remotes+ then return False+ else do+ liftIO $ putStrLn $ "New fork: " ++ repoUrl fork+ _ <- addRemote (remoteFor fork) (repoUrl fork)+ return True+ where+ remoteFor (GithubUserRepo user repo) =+ "github_" ++ user ++ "_" ++ repo -gatherMetaData :: Github.Repo -> IO [FilePath]-gatherMetaData repo = do- putStrLn $ "gathering metadata for " ++ showRepo repo- concat <$> mapM (\a -> a repo)- [ pullRequests- , watchers- , issues- , milestones+{- Adds a remote, also fetching from it. -}+addRemote :: String -> String -> Backup Bool+addRemote remotename remoteurl =+ inRepo $ Git.Command.runBool "remote"+ [ Param "add"+ , Param "-f"+ , Param remotename+ , Param remoteurl ] -pullRequests :: Github.Repo -> IO [FilePath]-pullRequests repo = runRepo Github.pullRequestsFor repo >>= collect- where- collect Nothing = return []- collect (Just rs) = forM rs $ \r -> do- let n = Github.pullRequestNumber r- details <- runRepoWith Github.pullRequest n repo- saveMetaData repo ("pullrequest" </> show n) details+removeRemote :: String -> Backup ()+removeRemote remotename = do+ _ <- inRepo $ Git.Command.runBool "remote"+ [ Param "rm"+ , Param remotename+ ]+ return () -watchers :: Github.Repo -> IO [FilePath]-watchers repo = runRepo Github.watchersFor repo >>= collect- where- collect Nothing = return []- collect (Just ws) = do- f <- saveMetaData repo "watchers" $ sort ws- return [f]+{- Fetches from the github remote. Done by github-backup, just because+ - it would be weird for a backup to not fetch all available data.+ - Even though its real focus is on metadata not stored in git. -}+fetchRepo :: Git.Repo -> Backup Bool+fetchRepo repo = inRepo $+ Git.Command.runBool "fetch"+ [Param $ fromJust $ Git.Types.remoteName repo] -issues :: Github.Repo -> IO [FilePath]-issues repo = runRepoWith Github.issuesForRepo [] repo >>= collect+{- Gathers metadata for the repo. Retuns a list of files written+ - and a list that may contain requests that need to be retried later. -}+gatherMetaData :: GithubUserRepo -> Backup ()+gatherMetaData repo = do+ liftIO $ putStrLn $ "Gathering metadata for " ++ repoUrl repo ++ " ..."+ mapM_ call toplevelApi where- collect Nothing = return []- collect (Just is) = concat <$> forM is get- get i = do- let n = Github.issueNumber i- f <- saveMetaData repo ("issue" </> show n) i- fs <- issueComments n repo- return $ f:fs+ call name = runRequest $ RequestSimple name repo -issueComments :: Int -> Github.Repo -> IO [FilePath]-issueComments n repo = runRepoWith Github.Issues.Comments.comments n repo >>= collect+storeRetry :: [Request] -> Git.Repo -> IO ()+storeRetry [] r = do+ _ <- try $ removeFile (retryFile r) :: IO (Either SomeException ()) + return ()+storeRetry retryrequests r = writeFile (retryFile r) (show retryrequests)++loadRetry :: Git.Repo -> IO [Request]+loadRetry r = maybe [] (fromMaybe [] . readish)+ <$> catchMaybeIO (readFileStrict (retryFile r))++retryFile :: Git.Repo -> FilePath+retryFile r = Git.gitDir r </> "github-backup.todo"++retry :: Backup (S.Set Request)+retry = do+ todo <- inRepo loadRetry+ unless (null todo) $ do+ liftIO $ putStrLn $+ "Retrying " ++ show (length todo) +++ " requests that failed last time..."+ mapM_ runRequest todo+ retriedfailed <- getState failedRequests+ changeState $ \s -> s+ { failedRequests = S.empty+ , retriedRequests = S.fromList todo+ }+ return retriedfailed++summarizeRequests :: [Request] -> [String]+summarizeRequests = go M.empty where- collect Nothing = return []- collect (Just cs) = forM cs $ \c -> do- let i = Github.issueCommentId c- saveMetaData repo ("issue" </> show n ++ "_comment" </> show i) c+ go m [] = map format $ sort $ map swap $ M.toList m+ go m (r:rs) = go (M.insertWith (+) (requestName r) (1 :: Integer) m) rs+ format (num, name) = show num ++ "\t" ++ name+ swap (a, b) = (b, a) -milestones :: Github.Repo -> IO [FilePath]-milestones repo = runRepo Github.Issues.Milestones.milestones repo >>= collect+{- Save all backup data. Files that were written to the workDir are committed.+ - Requests that failed are saved for next time. Requests that were retried+ - this time and failed are ordered last, to ensure that we don't get stuck+ - retrying the same requests and not making progress when run again.+ -}+save :: S.Set Request -> Backup ()+save retriedfailed = do+ commitWorkDir+ failed <- getState failedRequests+ let toretry = S.toList failed ++ S.toList retriedfailed+ inRepo $ storeRetry toretry+ unless (null toretry) $+ error $ unlines $+ ["Backup may be incomplete; " ++ + show (length toretry) ++ " requests failed:"+ ] ++ map (" " ++) (summarizeRequests toretry) ++ + [ "Run again later."+ ]++newState :: Git.Repo -> BackupState+newState = BackupState S.empty S.empty++backup :: Git.Repo -> IO ()+backup repo = evalStateT (runBackup go) . newState =<< Git.Config.read repo where- collect Nothing = return []- collect (Just ms) = forM ms $ \m -> do- let n = Github.milestoneNumber m- saveMetaData repo ("milestone" </> show n) m+ go = do+ retriedfailed <- retry+ remotes <- gitHubPairs <$> getState backupRepo+ when (null remotes) $+ error "no github remotes found"+ forM_ remotes $ \(r, remote) -> do+ _ <- fetchRepo r+ gatherMetaData remote+ save retriedfailed +backupUser :: String -> IO ()+backupUser username = do+ repos <- either (error . show) id <$>+ Github.userRepos username Github.All+ when (null repos) $+ error $ "No GitHub repositories found for user " ++ username+ status <- forM repos $ \repo -> do+ let dir = Github.repoName repo+ unlessM (doesDirectoryExist dir) $ do+ putStrLn $ "New repository: " ++ dir+ ok <- boolSystem "git"+ [ Param "clone"+ , Param (Github.repoGitUrl repo)+ , Param dir+ ]+ unless ok $ error "clone failed"+ try (backup =<< Git.Construct.fromPath dir)+ :: IO (Either SomeException ())+ unless (null $ lefts status) $+ error "Failed to successfully back everything up. Run again later."++usage :: String+usage = "usage: github-backup [username]"+ main :: IO ()-main = do- r <- Git.Config.read =<< getLocalRepo =<< getArgs- changeWorkingDirectory $ Git.repoLocation r- remotes <- gitHubRemotes r- forks <- findForks remotes- addForks r forks- onGithubBranch r $- concat <$> forM (forks ++ remotes) gatherMetaData- >>= commitFiles r+main = getArgs >>= go+ where+ go [] = backup =<< Git.Construct.fromCwd+ go (username:[]) = backupUser username+ go _= error usage