@@ -56,66 +56,86 @@ data CredentialsCache = CredentialsCache
5656credentialsCacheFile :: Settings -> FilePath
5757credentialsCacheFile settings = settings. stateDirectory <> " /.github-token-cache.json"
5858
59- readCredentialsCache :: Settings -> IO ( Maybe ( T. Text , UTCTime ) )
60- readCredentialsCache settings = do
61- let cacheFile = credentialsCacheFile settings
59+ -- Try to read cache file (no locking - caller should hold lock )
60+ tryReadCache :: FilePath -> IO ( Maybe ( T. Text , UTCTime ))
61+ tryReadCache cacheFile = do
6262 exists <- doesFileExist cacheFile
6363 if exists then do
6464 result <- eitherDecodeFileStrict @ CredentialsCache cacheFile
6565 case result of
6666 Left _ -> pure Nothing
67- Right cache -> do
67+ Right cache ->
6868 case iso8601ParseM (toString cache. cachedExpiresAt) of
6969 Just expiresAt -> pure $ Just (cache. cachedToken, expiresAt)
7070 Nothing -> pure Nothing
7171 else
7272 pure Nothing
7373
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-
8574getClient :: AppState -> IO GithubClient
8675getClient appState = do
8776 mClient <- readIORef appState. githubClient
8877 case mClient of
8978 Just client -> do
90- -- Check if token is expired or expiring soon (within 5 minutes)
79+ -- Fast path: check if cached token is still valid
9180 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
99- Nothing -> do
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
81+ if diffUTCTime client. expiresAt now >= 300
82+ then pure client
83+ else do
84+ -- Token expiring, need to refresh
85+ logDebug appState $ " GitHub token expired or expiring soon (in " <> show (floor (diffUTCTime client. expiresAt now) :: Int ) <> " s), refreshing..."
86+ writeIORef appState. githubClient Nothing
87+ loadOrRefreshClient appState
88+ Nothing ->
89+ loadOrRefreshClient appState
90+
91+ loadOrRefreshClient :: AppState -> IO GithubClient
92+ loadOrRefreshClient appState = do
93+ let cacheFile = credentialsCacheFile appState. settings
94+ let lockFile = cacheFile <> " .lock"
95+
96+ client <- withFileLock lockFile Exclusive \ _ -> do
97+ -- Under EXCLUSIVE lock: read, check, refresh if needed
98+ mCached <- tryReadCache cacheFile
99+
100+ now <- getCurrentTime
101+ case mCached of
102+ Just (cachedToken, expiresAt)
103+ | diffUTCTime expiresAt now >= 300 -> do
104+ -- Valid cached token
112105 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
106+ buildClientWithToken appState cachedToken expiresAt
107+ | otherwise -> do
108+ -- Expired token, refresh
109+ logDebug appState " Cached token expired, refreshing"
110+ refreshToken appState cacheFile
111+ Nothing -> do
112+ -- No cache, create new token
113+ logDebug appState " No cached token, creating new one"
114+ refreshToken appState cacheFile
115+
116+ writeIORef appState. githubClient (Just client)
117+ pure client
118+
119+ -- Create new token and write to cache (caller should hold EXCLUSIVE lock)
120+ refreshToken :: AppState -> FilePath -> IO GithubClient
121+ refreshToken appState cacheFile = do
122+ tokenResponse <- createTokenFromGitHub appState
123+
124+ expiresAt <- case iso8601ParseM (toString tokenResponse. expires_at) of
125+ Just t -> pure t
126+ Nothing -> do
127+ logError appState $ " CommitStatus: Failed to parse expires_at: " <> tokenResponse. expires_at
128+ exitFailure
129+
130+ -- Write to cache (already under EXCLUSIVE lock, no additional locking needed)
131+ let cache = CredentialsCache
132+ { cachedToken = tokenResponse. token
133+ , cachedExpiresAt = T. pack $ iso8601Show expiresAt
134+ }
135+ BL. writeFile cacheFile (encode cache)
136+
137+ -- Build and return client
138+ buildClientWithToken appState tokenResponse. token expiresAt
119139
120140buildClientWithToken :: AppState -> T. Text -> UTCTime -> IO GithubClient
121141buildClientWithToken _appState accessToken expiresAt = do
@@ -140,80 +160,50 @@ buildClientWithToken _appState accessToken expiresAt = do
140160 , expiresAt = expiresAt
141161 }
142162
143- initClient :: AppState -> IO GithubClient
144- initClient appState = do
163+ -- Create a new GitHub App installation token from GitHub API
164+ createTokenFromGitHub :: AppState -> IO InstallationTokenResponse
165+ createTokenFromGitHub appState = do
145166 -- Load environment variables
146167 apiUrl <- fromMaybe " https://api.github.com" <$> lookupEnv " GITHUB_API_URL"
147168 appId <- getEnv " GITHUB_APP_ID"
148169 installationId <- getEnv " GITHUB_INSTALLATION_ID"
149170 privateKeyStr <- getEnv " GITHUB_APP_PRIVATE_KEY"
150- owner <- getEnv " GITHUB_REPOSITORY_OWNER"
151- repo <- getEnv " GITHUB_REPOSITORY"
171+
152172 -- Prepare the HTTP manager
153173 manager <- HTTP. newManager tlsManagerSettings
154174
155- let createToken = do
156- let privateKeyBytes = encodeUtf8 $ Text. replace " |" " \n " $ toText privateKeyStr
157- let privateKey = fromMaybe (error " Invalid github key" ) $ readRsaSecret privateKeyBytes
158-
159- -- Create the JWT token
160- now <- getPOSIXTime
161- let claims = mempty { iss = stringOrURI $ T. pack appId
162- , iat = numericDate now
163- , exp = numericDate (now + 5 * 60 )
164- }
165- let jwt = encodeSigned (EncodeRSAPrivateKey privateKey) (mempty { alg = Just RS256 }) claims
166-
167- -- Get the installation access token
168- let installUrl = apiUrl <> " /app/installations/" ++ installationId ++ " /access_tokens"
169- initRequest <- HTTP. parseRequest installUrl
170- let request = initRequest
171- { HTTP. method = " POST"
172- , HTTP. requestHeaders =
173- [ (" Authorization" , " Bearer " <> TE. encodeUtf8 jwt)
174- , (" Accept" , " application/vnd.github.v3+json" )
175- , (" User-Agent" , " restaumatic-bot" )
176- ]
177- }
178- response <- HTTP. httpLbs request manager
179- let mTokenResponse = eitherDecode @ InstallationTokenResponse (HTTP. responseBody response)
180- case mTokenResponse of
181- Left err -> do
182- logError appState $ " CommitStatus: Failed to parse installation token response: " <> show err
183- logError appState $ " CommitStatus: Response: " <> decodeUtf8 response. responseBody
184-
185- -- FIXME: handle the error better
186- exitFailure
187- Right tokenResponse ->
188- pure tokenResponse
175+ let privateKeyBytes = encodeUtf8 $ Text. replace " |" " \n " $ toText privateKeyStr
176+ let privateKey = fromMaybe (error " Invalid github key" ) $ readRsaSecret privateKeyBytes
189177
190- -- Create new token
191- tokenResponse <- createToken
178+ -- Create the JWT token
179+ now <- getPOSIXTime
180+ let claims = mempty { iss = stringOrURI $ T. pack appId
181+ , iat = numericDate now
182+ , exp = numericDate (now + 5 * 60 )
183+ }
184+ let jwt = encodeSigned (EncodeRSAPrivateKey privateKey) (mempty { alg = Just RS256 }) claims
192185
193- -- Parse expires_at to UTCTime
194- expiresAt <- case iso8601ParseM (toString tokenResponse. expires_at) of
195- Just t -> pure t
196- Nothing -> do
197- logError appState $ " CommitStatus: Failed to parse expires_at: " <> tokenResponse. expires_at
186+ -- Get the installation access token
187+ let installUrl = apiUrl <> " /app/installations/" ++ installationId ++ " /access_tokens"
188+ initRequest <- HTTP. parseRequest installUrl
189+ let request = initRequest
190+ { HTTP. method = " POST"
191+ , HTTP. requestHeaders =
192+ [ (" Authorization" , " Bearer " <> TE. encodeUtf8 jwt)
193+ , (" Accept" , " application/vnd.github.v3+json" )
194+ , (" User-Agent" , " restaumatic-bot" )
195+ ]
196+ }
197+ response <- HTTP. httpLbs request manager
198+ let mTokenResponse = eitherDecode @ InstallationTokenResponse (HTTP. responseBody response)
199+ case mTokenResponse of
200+ Left err -> do
201+ logError appState $ " CommitStatus: Failed to parse installation token response: " <> show err
202+ logError appState $ " CommitStatus: Response: " <> decodeUtf8 response. responseBody
203+ -- FIXME: handle the error better
198204 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
205+ Right tokenResponse ->
206+ pure tokenResponse
217207
218208updateCommitStatus :: MonadIO m => AppState -> StatusRequest -> m ()
219209updateCommitStatus appState statusRequest = liftIO do
0 commit comments