@@ -0,0 +1,381 @@
1+{-# LANGUAGE TypeApplications #-}
2+{-# OPTIONS_GHC -Wno-orphans #-}
10+import Control.AppRequestable
11+import Control.Concurrent
12+import Control.GithubCloner
13+import Control.HasGithubStatus
14+import Control.Monad.Except
15+import Control.Monad.IO.Unlift
16+import Control.Monad.Reader
17+import Control.Task.Scheduler
18+import Crypto.PubKey.RSA.Read
20+import Data.ByteString
21+import qualified Data.ByteString as BS
22+import qualified Data.ByteString.Char8 as BSC
23+import qualified Data.ByteString.Lazy.Char8 as BSCL
24+import qualified Data.Csv as Csv
27+import qualified Data.Map as M
28+import Data.Submission
29+import Data.Submission.Query
30+import Data.Tasks.Build
31+import Data.Tasks.StatusUpdate
32+import qualified Data.Text as T
33+import Data.Time.Clock
36+import GitHub hiding (Accept)
37+import GitHub.App.Auth
38+import GitHub.App.Request
39+import GitHub.Auth (Auth (OAuth))
40+import GitHub.Data.Name
41+import GitHub.Data.Webhooks.Events
42+import GitHub.Data.Webhooks.Payload hiding (URL (..))
43+import Network.HTTP.Client.TLS (newTlsManager)
44+import Network.HTTP.Media.MediaType
45+import Network.Wai.Handler.Warp
47+import Servant.GitHub.Webhook
48+import Servant.HTML.Blaze
52+import Squeal.PostgreSQL
53+import Text.Blaze.Html
54+import UnliftIO.Exception (catch, displayException, handleAny, throwIO, try)
58+ :> ( GitHubEvent '[ 'WebhookInstallationEvent]
59+ :> GitHubSignedReqBody '[JSON] InstallationEvent
61+ :<|> GitHubEvent '[ 'WebhookPushEvent]
62+ :> GitHubSignedReqBody '[JSON] PushEvent
66+ :> Capture "user name" String
67+ :> Capture "repo name" String
68+ :> Capture "sha" String
69+ :> Get '[HTML] Markup
71+ :> Capture "user name" String
72+ :> Capture "repo name" String
73+ :> Capture "sha" String
75+ :> Post '[HTML] Markup
76+ :<|> "results" :> Capture "task name" String :> Get '[CSV, HTML] [Score]
78+webhookInstallation :: WithLog env Message m => RepoWebhookEvent -> ((), InstallationEvent) -> m ()
79+webhookInstallation _ ((), ev) =
80+ logInfo . T.pack . show $ ev
84+ MonadReader (ServerData m) m,
87+ MonadError ServerError m,
88+ WithLog env Message m
93+webhookPushEvent _ ((), ev) = do
94+ let repo = evPushRepository ev
95+ user = T.unpack . whUserLogin . evPushSender $ ev
96+ fullRepoName = whRepoFullName repo
97+ (owner, repoName) <- case T.splitOn "/" fullRepoName of
98+ [a, b] -> return (a, b)
99+ _ -> throwError err422
100+ sha <- maybe (throwError err422) return $ evPushHeadSha ev
104+ M.takeWhileAntitone (repoName >) tasks
106+ Just (name, TaskConfig {..}) | T.take (T.length name) repoName == name -> do
107+ nowTime <- liftIO getCurrentTime
108+ if nowTime > deadline
110+ deadlineHasPassedStatus (N owner) (N repoName) (N sha)
114+ (T.unpack fullRepoName)
117+ (Jsonb PastDeadline)
119+ schedulePendingStatus (N owner) (N repoName) (N sha)
123+ (T.unpack fullRepoName)
126+ (Jsonb BuildScheduled)
127+ scheduleTask $ Build prebuild dockerfile (N owner) (N repoName) (N sha) timeoutMinutes
128+ logInfo . T.pack . show $ ev
132+ (StaticPQ m, MonadReader (ServerData m) m, MonadUnliftIO m, HasTasks m, MonadHasBaseUrl m) =>
137+retestSubmission owner repoName sha = do
138+ let fullRepoName = owner <> "/" <> repoName
139+ repoName' = T.pack repoName
141+ owner' = T.pack owner
142+ getSubmission fullRepoName sha >>= \case
143+ Nothing -> return ()
148+ M.takeWhileAntitone (repoName' >) tasks
150+ Just (name, TaskConfig {..}) | T.take (T.length name) repoName' == name -> do
151+ schedulePendingStatus (N owner') (N repoName') (N sha')
152+ updateSubmissionStatus fullRepoName sha BuildScheduled
153+ scheduleTask $ Build prebuild dockerfile (N owner') (N repoName') (N sha') timeoutMinutes
155+ redirectToSubmission owner repoName sha
157+newtype ServerM (schema :: SchemasType) a
159+ { unApp :: ReaderT (ServerData (ServerM schema)) (PQ schema schema IO) a
166+ MonadReader (ServerData (ServerM schema)),
171+instance MonadError ServerError (ServerM schema) where
172+ throwError = throwIO
175+server :: ServerT API (ServerM Schema)
176+server = (webhookInstallation :<|> webhookPushEvent) :<|> getSubmissionR :<|> retestSubmission :<|> getResults
178+runServer :: FilePath -> IO ()
179+runServer config = do
180+ c@Config {..} <- getConfig config
182+ pem <- BS.readFile appPkPemPath
183+ appPkPem <- case readRsaPem pem of
185+ Left e -> error (show e)
186+ auth <- mkInstallationAuth (AppAuth appId appPkPem) installationId
187+ ts <- newIORef mempty
188+ pool <- createConnectionPool databseUrl 1 0.5 10
190+ updateTasks ts tasksPath
191+ threadDelay $ 5 * 60 * (10 ^ (6 :: Int))
193+ gitPolyHubKey (return webhookSecret)
197+ { githubAppAuth = auth,
199+ logger = cfilter ((logSeverity <=) . msgSeverity) richMessageAction,
200+ baseUrl = baseSiteUrl,
201+ dockerUrl = T.pack cfgDockerUrl,
202+ context = githubContext
204+ repeatIfNotEmpty n f = f >>= \m -> do
205+ when (m == 0) $ liftIO $ threadDelay n
206+ repeatIfNotEmpty n f
208+ handleAny (logError . T.pack . displayException >=> const (return 0))
209+ usingConnectionPool pool
210+ . (`runReaderT` serverData)
212+ . handleAny (error . displayException)
214+ replicateM_ builderCount
215+ $ forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
216+ . usingConnectionPool pool
217+ . (`runReaderT` serverData)
220+ $ runTasks @'[Build] 1
222+ forkIO . repeatIfNotEmpty (10 ^ (6 :: Int))
223+ . usingConnectionPool pool
224+ . (`runReaderT` serverData)
227+ $ runTasks @'[StatusUpdate] 5
228+ run port $ serveWithContext (Proxy @API) context $
229+ hoistServerWithContext
231+ (Proxy @'[PolyGitHubKey])
232+ (Handler . ExceptT . try . usingConnectionPool pool . (`runReaderT` serverData) . unApp)
235+updateTasks :: IORef TasksConfig -> FilePath -> IO ()
236+updateTasks ref path =
237+ decodeFileEither path >>= \case
238+ Right a -> writeIORef ref a
243+ { githubAppAuth :: !InstallationAuth,
244+ tasks :: IORef TasksConfig,
245+ logger :: LogAction m Message,
247+ dockerUrl :: T.Text,
251+newtype PolyGitHubKey = PolyGitHubKey (forall result. GitHubKey result)
253+gitPolyHubKey :: IO ByteString -> PolyGitHubKey
254+gitPolyHubKey k = PolyGitHubKey (Servant.GitHub.Webhook.gitHubKey k)
256+instance HasContextEntry '[PolyGitHubKey] (GitHubKey result) where
257+ getContextEntry (PolyGitHubKey x :. _) = x
259+instance (MonadReader (ServerData n) m, MonadIO m, MonadError ServerError m) => GithubCloner m where
260+ getGithubInstallationToken =
262+ (\e -> throwError err500 {errBody = BSCL.pack . displayException $ e})
266+ iAuth <- asks githubAppAuth
267+ manager <- newTlsManager
268+ liftIO (obtainAccessToken manager iAuth) >>= either throwError return >>= \case
269+ OAuth token -> return $ InstallationToken (BSC.unpack token)
270+ _ -> throwError $ UserError "Not OAuth token. This should never happen."
272+instance (MonadIO m, MonadReader (ServerData n) m) => AppRequestable m where
274+ auth <- asks githubAppAuth
275+ liftIO $ executeAppRequest auth req
277+instance MonadReader (ServerData n) m => MonadHasBaseUrl m where
278+ getBaseUrl = asks baseUrl
280+instance (MonadIO m, MonadReader (ServerData n) m) => HasTasks m where
281+ getTasks = asks tasks >>= liftIO . readIORef
283+instance (MonadReader (ServerData m) m, StaticPQ m, MonadUnliftIO m) => HasGithubStatus m where
284+ schedulePendingStatus nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
294+ (Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha)
298+ scheduleTestedStatus TestResult {..} nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
301+ let total = M.size tests
302+ passed = M.size . M.filter id $ tests
303+ description = T.pack $ show passed <> "/" <> show total <> if passed == total then " ✅" else " ❌"
311+ if total == passed then StatusSuccess else StatusFailure,
312+ newStatusTargetUrl = Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha,
313+ newStatusDescription = Just description,
314+ newStatusContext = Just c
317+ scheduleFailedStatus _ nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
326+ { newStatusState = StatusError,
327+ newStatusTargetUrl = Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha,
328+ newStatusDescription = Just "Build failed ☠️",
329+ newStatusContext = Just c
332+ deadlineHasPassedStatus nOwner@(N owner) nRepo@(N repo) nCommit@(N sha) = do
341+ { newStatusState = StatusError,
342+ newStatusTargetUrl = Just . URL $ h <> "/submission/" <> owner <> "/" <> repo <> "/" <> sha,
343+ newStatusDescription = Just "The deadline has passed ⏰",
344+ newStatusContext = Just c
348+instance (MonadReader (ServerData n) m) => HasDockerInfo m where
349+ getDockerUrl = asks dockerUrl
351+type TasksConfig = Map T.Text TaskConfig
355+ { prebuild :: String,
356+ dockerfile :: String,
357+ timeoutMinutes :: Int,
360+ deriving stock (Show, Eq, Generic)
361+ deriving anyclass (FromJSON, ToJSON)
363+class HasTasks m where
364+ getTasks :: m TasksConfig
366+instance HasLog (ServerData m) Message m where
367+ getLogAction :: ServerData m -> LogAction m Message
368+ getLogAction = logger
369+ {-# INLINE getLogAction #-}
371+ setLogAction :: LogAction m Message -> ServerData m -> ServerData m
372+ setLogAction newLogAction env = env {logger = newLogAction}
373+ {-# INLINE setLogAction #-}
377+instance Accept CSV where
378+ contentType Proxy = "text" // "csv"
380+instance Csv.ToRecord a => MimeRender CSV [a] where
381+ mimeRender Proxy = Csv.encode