@@ -4,19 +4,24 @@ module CommitStatus where
44
55import Universum
66
7- import Data.Aeson (FromJSON (.. ), ToJSON (.. ), encode )
7+ import Data.Aeson (FromJSON (.. ), ToJSON (.. ), encode , eitherDecodeFileStrict )
88import Data.Time.Clock.POSIX (getPOSIXTime )
9+ import Data.Time.Clock (UTCTime , getCurrentTime , diffUTCTime )
10+ import Data.Time.Format.ISO8601 (iso8601ParseM , iso8601Show )
911import Web.JWT (Algorithm (RS256 ), JWTClaimsSet (.. ), encodeSigned , numericDate , stringOrURI , EncodeSigner (.. ), readRsaSecret , JOSEHeader (.. ))
1012import qualified Data.Text as T
1113import qualified Data.Text.Encoding as TE
1214import qualified Network.HTTP.Client as HTTP
1315import Network.HTTP.Client.TLS (tlsManagerSettings )
14- import System.Environment (getEnv , lookupEnv , setEnv )
16+ import System.Environment (getEnv , lookupEnv )
1517import Network.HTTP.Types.Status (Status (.. ))
1618import Data.Aeson.Decoding (eitherDecode )
1719import qualified Data.Text as Text
20+ import qualified Data.ByteString.Lazy as BL
21+ import System.FileLock (withFileLock , SharedExclusive (.. ))
22+ import System.Directory (doesFileExist )
1823import Utils (getCurrentCommit , logError , logDebug )
19- import Types (AppState (.. ), GithubClient (.. ))
24+ import Types (AppState (.. ), GithubClient (.. ), Settings ( .. ) )
2025
2126-- Define the data types for the status update
2227data StatusRequest = StatusRequest
@@ -35,20 +40,105 @@ data StatusResponse = StatusResponse
3540 deriving anyclass (FromJSON )
3641
3742-- Define the data type for the installation token response
38- newtype InstallationTokenResponse = InstallationTokenResponse
43+ data InstallationTokenResponse = InstallationTokenResponse
3944 { token :: T. Text
45+ , expires_at :: T. Text
4046 } deriving (Show , Generic )
4147 deriving anyclass (FromJSON )
4248
49+ -- Cache file for storing credentials across processes
50+ data CredentialsCache = CredentialsCache
51+ { cachedToken :: T. Text
52+ , cachedExpiresAt :: T. Text
53+ } deriving (Show , Generic )
54+ deriving anyclass (FromJSON , ToJSON )
55+
56+ credentialsCacheFile :: Settings -> FilePath
57+ credentialsCacheFile settings = settings. stateDirectory <> " /.github-token-cache.json"
58+
59+ readCredentialsCache :: Settings -> IO (Maybe (T. Text , UTCTime ))
60+ readCredentialsCache settings = do
61+ let cacheFile = credentialsCacheFile settings
62+ exists <- doesFileExist cacheFile
63+ if exists then do
64+ result <- eitherDecodeFileStrict @ CredentialsCache cacheFile
65+ case result of
66+ Left _ -> pure Nothing
67+ Right cache -> do
68+ case iso8601ParseM (toString cache. cachedExpiresAt) of
69+ Just expiresAt -> pure $ Just (cache. cachedToken, expiresAt)
70+ Nothing -> pure Nothing
71+ else
72+ pure Nothing
73+
74+ writeCredentialsCache :: Settings -> T. Text -> UTCTime -> IO ()
75+ writeCredentialsCache settings token expiresAt = do
76+ let cacheFile = credentialsCacheFile settings
77+ let lockFile = cacheFile <> " .lock"
78+ let cache = CredentialsCache
79+ { cachedToken = token
80+ , cachedExpiresAt = T. pack $ iso8601Show expiresAt
81+ }
82+ withFileLock lockFile Exclusive \ _ ->
83+ BL. writeFile cacheFile (encode cache)
84+
4385getClient :: AppState -> IO GithubClient
4486getClient appState = do
4587 mClient <- readIORef appState. githubClient
4688 case mClient of
47- Just client -> pure client
89+ Just client -> do
90+ -- Check if token is expired or expiring soon (within 5 minutes)
91+ now <- getCurrentTime
92+ let secondsUntilExpiry = diffUTCTime client. expiresAt now
93+ if secondsUntilExpiry < 300 then do
94+ logDebug appState $ " GitHub token expired or expiring soon (in " <> show (floor secondsUntilExpiry :: Int ) <> " s), refreshing..."
95+ writeIORef appState. githubClient Nothing
96+ getClient appState
97+ else
98+ pure client
4899 Nothing -> do
49- client <- initClient appState
50- writeIORef appState. githubClient $ Just client
51- pure client
100+ -- Try reading from cache file first (for child processes)
101+ mCached <- readCredentialsCache appState. settings
102+ case mCached of
103+ Just (cachedToken, expiresAt) -> do
104+ now <- getCurrentTime
105+ let secondsUntilExpiry = diffUTCTime expiresAt now
106+ if secondsUntilExpiry < 300 then do
107+ -- Cached token is expired, create new one
108+ logDebug appState " Cached GitHub token expired, creating new one"
109+ initClient appState
110+ else do
111+ -- Use cached token, build client
112+ logDebug appState " Using cached GitHub token from file"
113+ client <- buildClientWithToken appState cachedToken expiresAt
114+ writeIORef appState. githubClient (Just client)
115+ pure client
116+ Nothing -> do
117+ -- No cache, create new token
118+ initClient appState
119+
120+ buildClientWithToken :: AppState -> T. Text -> UTCTime -> IO GithubClient
121+ buildClientWithToken _appState accessToken expiresAt = do
122+ -- Load environment variables
123+ apiUrl <- fromMaybe " https://api.github.com" <$> lookupEnv " GITHUB_API_URL"
124+ appId <- getEnv " GITHUB_APP_ID"
125+ installationId <- getEnv " GITHUB_INSTALLATION_ID"
126+ privateKeyStr <- getEnv " GITHUB_APP_PRIVATE_KEY"
127+ owner <- getEnv " GITHUB_REPOSITORY_OWNER"
128+ repo <- getEnv " GITHUB_REPOSITORY"
129+ manager <- HTTP. newManager tlsManagerSettings
130+
131+ pure $ GithubClient
132+ { apiUrl = T. pack apiUrl
133+ , appId = T. pack appId
134+ , installationId = T. pack installationId
135+ , privateKey = T. pack privateKeyStr
136+ , owner = T. pack owner
137+ , repo = T. pack repo
138+ , manager = manager
139+ , accessToken = accessToken
140+ , expiresAt = expiresAt
141+ }
52142
53143initClient :: AppState -> IO GithubClient
54144initClient appState = do
@@ -95,26 +185,35 @@ initClient appState = do
95185 -- FIXME: handle the error better
96186 exitFailure
97187 Right tokenResponse ->
98- pure tokenResponse. token
188+ pure tokenResponse
189+
190+ -- Create new token
191+ tokenResponse <- createToken
99192
100- -- Try to read token from environment variable
101- -- Otherwise generate a new one, and set env for future uses (also in child processes)
102- accessToken <- lookupEnv " _taskrunner_github_access_token" >>= \ case
103- Just token -> pure $ T. pack token
193+ -- Parse expires_at to UTCTime
194+ expiresAt <- case iso8601ParseM (toString tokenResponse. expires_at) of
195+ Just t -> pure t
104196 Nothing -> do
105- token <- createToken
106- setEnv " _taskrunner_github_access_token" $ T. unpack token
107- pure token
108-
109- pure $ GithubClient { apiUrl = T. pack apiUrl
110- , appId = T. pack appId
111- , installationId = T. pack installationId
112- , privateKey = T. pack privateKeyStr
113- , owner = T. pack owner
114- , repo = T. pack repo
115- , manager = manager
116- , accessToken = accessToken
117- }
197+ logError appState $ " CommitStatus: Failed to parse expires_at: " <> tokenResponse. expires_at
198+ exitFailure
199+
200+ -- Write to cache file for child processes
201+ writeCredentialsCache appState. settings tokenResponse. token expiresAt
202+
203+ let client = GithubClient
204+ { apiUrl = T. pack apiUrl
205+ , appId = T. pack appId
206+ , installationId = T. pack installationId
207+ , privateKey = T. pack privateKeyStr
208+ , owner = T. pack owner
209+ , repo = T. pack repo
210+ , manager = manager
211+ , accessToken = tokenResponse. token
212+ , expiresAt = expiresAt
213+ }
214+
215+ writeIORef appState. githubClient (Just client)
216+ pure client
118217
119218updateCommitStatus :: MonadIO m => AppState -> StatusRequest -> m ()
120219updateCommitStatus appState statusRequest = liftIO do
0 commit comments