@playrunner/github 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  GitHub authentication and repository access for Playrunner workflows.
4
4
 
5
+ [![npm version](https://img.shields.io/npm/v/@playrunner/github.svg)](https://www.npmjs.com/package/@playrunner/github)
6
+
7
+ [View source on GitHub](https://github.com/playrunner/playrunner/tree/main/packages/github)
8
+
5
9
  ## Install
6
10
 
7
11
  ```bash
@@ -14,6 +18,7 @@ Add the package as a direct production dependency of the Playrunner frontend and
14
18
 
15
19
  - `@playrunner/github` exports the GitHub integration, icon, and settings UI.
16
20
  - `@playrunner/github/api` exports token exchange and refresh API routes.
21
+ - `@playrunner/github/e2e` exports the package-owned E2E contribution.
17
22
  - `@playrunner/github/assets/github.svg` exports the package-owned icon.
18
23
 
19
24
  ```ts
@@ -21,6 +26,22 @@ import githubIntegration, { GithubSettingsModal } from '@playrunner/github';
21
26
  import githubApiContribution from '@playrunner/github/api';
22
27
  ```
23
28
 
29
+ ## Testing
30
+
31
+ Run the package checks from the repository root:
32
+
33
+ ```bash
34
+ npm run format:check --prefix packages/github
35
+ npm run lint --prefix packages/github
36
+ npm run typecheck --prefix packages/github
37
+ npm run test:e2e:mock -- --grep @github
38
+ ```
39
+
40
+ The E2E scenario validates the GitHub OAuth setup form. It runs in deterministic
41
+ mock mode through the shared Playrunner browser harness, which uses the real
42
+ frontend, API, authentication, and dedicated E2E database. No GitHub
43
+ credentials or live provider requests are required.
44
+
24
45
  ## Documentation
25
46
 
26
47
  See the [GitHub integration documentation](https://playrunner.dev/docs/integration-packages/github/) for GitHub App setup, exports, and Playrunner build integration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playrunner/github",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "GitHub integration package for Playrunner.",
5
5
  "keywords": [
6
6
  "playrunner",
@@ -27,7 +27,8 @@
27
27
  "integration": {
28
28
  "id": "github",
29
29
  "frontend": ".",
30
- "api": "./api"
30
+ "api": "./api",
31
+ "e2e": "./e2e"
31
32
  }
32
33
  },
33
34
  "exports": {
@@ -49,6 +50,12 @@
49
50
  "require": "./src/api/index.ts",
50
51
  "default": "./src/api/index.ts"
51
52
  },
53
+ "./e2e": {
54
+ "types": "./src/e2e/index.ts",
55
+ "import": "./src/e2e/index.ts",
56
+ "require": "./src/e2e/index.ts",
57
+ "default": "./src/e2e/index.ts"
58
+ },
52
59
  "./assets/github.svg": "./assets/github.svg"
53
60
  },
54
61
  "files": [
@@ -68,11 +75,15 @@
68
75
  "express": "^5.2.1"
69
76
  },
70
77
  "peerDependencies": {
78
+ "@playwright/test": "^1.61.0",
71
79
  "@playrunner/integration-sdk": "^0.1.0",
72
80
  "lucide-react": "^0.546.0",
73
81
  "react": "^19.0.0"
74
82
  },
75
83
  "peerDependenciesMeta": {
84
+ "@playwright/test": {
85
+ "optional": true
86
+ },
76
87
  "@playrunner/integration-sdk": {
77
88
  "optional": true
78
89
  },
@@ -85,6 +96,7 @@
85
96
  },
86
97
  "devDependencies": {
87
98
  "@eslint/js": "^9.39.4",
99
+ "@playwright/test": "1.61.0",
88
100
  "@types/express": "^5.0.6",
89
101
  "@types/react": "^19.2.14",
90
102
  "eslint": "^9.39.4",
package/src/api/index.ts CHANGED
@@ -1,4 +1,10 @@
1
1
  import { Router } from 'express';
2
+ import type { IntegrationCredentialStore } from '@playrunner/integration-sdk/api';
3
+
4
+ function credentialStore(req: unknown): IntegrationCredentialStore | undefined {
5
+ return (req as { integrationCredentials?: IntegrationCredentialStore })
6
+ .integrationCredentials;
7
+ }
2
8
 
3
9
  export const githubRouter = Router();
4
10
 
@@ -6,13 +12,151 @@ export const githubApiContribution = {
6
12
  id: 'github',
7
13
  mountPath: '/api/github',
8
14
  router: githubRouter,
15
+ prepareCredentials: refreshGithubCredentials,
9
16
  };
10
17
 
11
18
  export default githubApiContribution;
12
19
 
20
+ async function refreshGithubCredentials(
21
+ store: IntegrationCredentialStore,
22
+ _kind?: 'cloud' | 'integration',
23
+ force = false,
24
+ ) {
25
+ const connection = await store.resolve('integration', 'github');
26
+ if (!connection) return;
27
+ const expiresAt = connection.secrets.expiresAt;
28
+ if (
29
+ !force &&
30
+ (typeof expiresAt !== 'number' || Date.now() < expiresAt - 5 * 60 * 1000)
31
+ )
32
+ return;
33
+ const { clientId, clientSecret, refreshToken } = connection.secrets;
34
+ const hasRefreshCredentials = [clientId, clientSecret, refreshToken].every(
35
+ (value) => typeof value === 'string' && value,
36
+ );
37
+ if (!hasRefreshCredentials) {
38
+ if (force)
39
+ throw new Error('Saved GitHub refresh credentials are incomplete.');
40
+ return;
41
+ }
42
+ const response = await fetch('https://github.com/login/oauth/access_token', {
43
+ method: 'POST',
44
+ headers: {
45
+ 'Content-Type': 'application/json',
46
+ Accept: 'application/json',
47
+ 'User-Agent': 'Playrunner-App',
48
+ },
49
+ body: JSON.stringify({
50
+ client_id: clientId,
51
+ client_secret: clientSecret,
52
+ refresh_token: refreshToken,
53
+ grant_type: 'refresh_token',
54
+ }),
55
+ });
56
+ const data = (await response.json()) as Record<string, any>;
57
+ if (!response.ok || typeof data.access_token !== 'string')
58
+ throw new Error('GitHub authorization has expired. Reconnect GitHub.');
59
+ await store.updateSecrets('integration', 'github', {
60
+ accessToken: data.access_token,
61
+ refreshToken: data.refresh_token || refreshToken,
62
+ expiresAt: data.expires_in
63
+ ? Date.now() + data.expires_in * 1000
64
+ : undefined,
65
+ refreshTokenExpiresAt: data.refresh_token_expires_in
66
+ ? Date.now() + data.refresh_token_expires_in * 1000
67
+ : undefined,
68
+ });
69
+ }
70
+
71
+ async function getGithubConnection(req: unknown) {
72
+ const store = credentialStore(req);
73
+ if (!store) {
74
+ throw Object.assign(new Error('Credential storage is unavailable.'), {
75
+ statusCode: 500,
76
+ });
77
+ }
78
+ await refreshGithubCredentials(store);
79
+ const connection = await store.resolve('integration', 'github');
80
+ const accessToken = connection?.secrets.accessToken;
81
+ if (typeof accessToken !== 'string' || !accessToken) {
82
+ throw Object.assign(new Error('GitHub is not connected.'), {
83
+ statusCode: 401,
84
+ });
85
+ }
86
+ return { accessToken, connection, store };
87
+ }
88
+
89
+ async function githubGet(accessToken: string, url: string) {
90
+ const response = await fetch(url, {
91
+ headers: {
92
+ Authorization: `Bearer ${accessToken}`,
93
+ Accept: 'application/vnd.github+json',
94
+ 'X-GitHub-Api-Version': '2022-11-28',
95
+ },
96
+ });
97
+ const data = await response.json().catch(() => ({}));
98
+ if (!response.ok) {
99
+ const message =
100
+ data &&
101
+ typeof data === 'object' &&
102
+ 'message' in data &&
103
+ typeof data.message === 'string'
104
+ ? data.message
105
+ : 'GitHub API request failed.';
106
+ throw Object.assign(new Error(message), { statusCode: response.status });
107
+ }
108
+ return data;
109
+ }
110
+
111
+ function errorStatusCode(error: unknown) {
112
+ return error &&
113
+ typeof error === 'object' &&
114
+ 'statusCode' in error &&
115
+ typeof error.statusCode === 'number'
116
+ ? error.statusCode
117
+ : undefined;
118
+ }
119
+
120
+ async function createGithubApiClient(req: unknown) {
121
+ const {
122
+ accessToken: initialAccessToken,
123
+ connection,
124
+ store,
125
+ } = await getGithubConnection(req);
126
+ let accessToken = initialAccessToken;
127
+ let refreshPromise: Promise<void> | undefined;
128
+
129
+ async function refreshAccessTokenOnce() {
130
+ refreshPromise ??= (async () => {
131
+ await refreshGithubCredentials(store, 'integration', true);
132
+ const refreshed = await store.resolve('integration', 'github');
133
+ const refreshedAccessToken = refreshed?.secrets.accessToken;
134
+ if (typeof refreshedAccessToken !== 'string' || !refreshedAccessToken) {
135
+ throw new Error('GitHub authorization has expired. Reconnect GitHub.');
136
+ }
137
+ accessToken = refreshedAccessToken;
138
+ })();
139
+ await refreshPromise;
140
+ }
141
+
142
+ return {
143
+ connection,
144
+ async get(url: string) {
145
+ try {
146
+ return await githubGet(accessToken, url);
147
+ } catch (error) {
148
+ if (errorStatusCode(error) !== 401) throw error;
149
+ await refreshAccessTokenOnce();
150
+ return githubGet(accessToken, url);
151
+ }
152
+ },
153
+ };
154
+ }
155
+
13
156
  // Proxy endpoint to exchange GitHub OAuth code for an access token to bypass CORS
14
157
  githubRouter.post('/token', async (req, res) => {
15
- const { code, client_id, client_secret } = req.body;
158
+ const { code, client_id, client_secret, app_name, installation_id } =
159
+ req.body;
16
160
 
17
161
  try {
18
162
  const gRes = await fetch('https://github.com/login/oauth/access_token', {
@@ -32,12 +176,41 @@ githubRouter.post('/token', async (req, res) => {
32
176
  const text = await gRes.text();
33
177
  try {
34
178
  const data = JSON.parse(text);
35
- res.json(data);
179
+ if (!gRes.ok || !data.access_token) {
180
+ return res
181
+ .status(gRes.status)
182
+ .json({ error: 'GitHub token exchange failed.' });
183
+ }
184
+ const store = credentialStore(req);
185
+ if (!store) {
186
+ return res
187
+ .status(500)
188
+ .json({ error: 'Credential storage is unavailable.' });
189
+ }
190
+ await store.save('integration', 'github', {
191
+ provider: 'github',
192
+ config: {
193
+ appName: app_name,
194
+ appSlug: app_name,
195
+ ...(installation_id ? { installationId: installation_id } : {}),
196
+ },
197
+ secrets: {
198
+ clientId: client_id,
199
+ clientSecret: client_secret,
200
+ accessToken: data.access_token,
201
+ refreshToken: data.refresh_token,
202
+ expiresAt: data.expires_in
203
+ ? Date.now() + data.expires_in * 1000
204
+ : undefined,
205
+ refreshTokenExpiresAt: data.refresh_token_expires_in
206
+ ? Date.now() + data.refresh_token_expires_in * 1000
207
+ : undefined,
208
+ },
209
+ });
210
+ return res.json({ connected: true });
36
211
  } catch {
37
- console.error('Token exchange failed. GitHub returned non-JSON:', text);
38
- res
39
- .status(500)
40
- .json({ error: 'Failed to exchange token', details: text });
212
+ console.error('Token exchange failed. GitHub returned non-JSON.');
213
+ res.status(500).json({ error: 'Failed to exchange token' });
41
214
  }
42
215
  } catch (err) {
43
216
  console.error('Token exchange error:', err);
@@ -47,34 +220,129 @@ githubRouter.post('/token', async (req, res) => {
47
220
 
48
221
  // Proxy endpoint to refresh GitHub OAuth token
49
222
  githubRouter.post('/refresh', async (req, res) => {
50
- const { refresh_token, client_id, client_secret } = req.body;
223
+ try {
224
+ const store = credentialStore(req);
225
+ if (!store)
226
+ return res
227
+ .status(500)
228
+ .json({ error: 'Credential storage is unavailable.' });
229
+ await refreshGithubCredentials(store, 'integration', true);
230
+ return res.json({ connected: true });
231
+ } catch (err) {
232
+ console.error('Token refresh error:', err);
233
+ return res.status(500).json({ error: 'Failed to refresh token' });
234
+ }
235
+ });
51
236
 
237
+ githubRouter.get('/repositories', async (req, res) => {
52
238
  try {
53
- const gRes = await fetch('https://github.com/login/oauth/access_token', {
54
- method: 'POST',
55
- headers: {
56
- 'Content-Type': 'application/json',
57
- Accept: 'application/json',
58
- 'User-Agent': 'Playrunner-App',
59
- },
60
- body: JSON.stringify({
61
- client_id,
62
- client_secret,
63
- refresh_token,
64
- grant_type: 'refresh_token',
239
+ const github = await createGithubApiClient(req);
240
+ const { connection } = github;
241
+ const savedInstallationId = connection.config.installationId;
242
+ let installationIds: string[] = [];
243
+
244
+ if (
245
+ (typeof savedInstallationId === 'string' && savedInstallationId) ||
246
+ typeof savedInstallationId === 'number'
247
+ ) {
248
+ installationIds = [String(savedInstallationId)];
249
+ } else {
250
+ const installations = (await github.get(
251
+ 'https://api.github.com/user/installations?per_page=100',
252
+ )) as { installations?: Array<{ id?: string | number }> };
253
+ installationIds = (installations.installations ?? [])
254
+ .map((installation) => installation.id)
255
+ .filter(
256
+ (id): id is string | number =>
257
+ (typeof id === 'string' && Boolean(id)) || typeof id === 'number',
258
+ )
259
+ .map(String);
260
+ }
261
+
262
+ const repositoryLists = await Promise.all(
263
+ installationIds.map(async (installationId) => {
264
+ const data = (await github.get(
265
+ `https://api.github.com/user/installations/${encodeURIComponent(installationId)}/repositories?per_page=100`,
266
+ )) as {
267
+ repositories?: Array<{ id?: string | number; full_name?: string }>;
268
+ };
269
+ return data.repositories ?? [];
65
270
  }),
66
- });
271
+ );
272
+ const repositories = repositoryLists
273
+ .flat()
274
+ .filter(
275
+ (
276
+ repository,
277
+ ): repository is { id: string | number; full_name: string } =>
278
+ (typeof repository.id === 'string' ||
279
+ typeof repository.id === 'number') &&
280
+ typeof repository.full_name === 'string',
281
+ )
282
+ .map((repository) => ({
283
+ id: String(repository.id),
284
+ full_name: repository.full_name,
285
+ }))
286
+ .sort((left, right) => left.full_name.localeCompare(right.full_name));
67
287
 
68
- const text = await gRes.text();
69
- try {
70
- const data = JSON.parse(text);
71
- res.json(data);
72
- } catch {
73
- console.error('Token refresh failed. GitHub returned non-JSON:', text);
74
- res.status(500).json({ error: 'Failed to refresh token', details: text });
288
+ return res.json({ repositories });
289
+ } catch (error) {
290
+ console.error('Failed to fetch GitHub repositories:', error);
291
+ const statusCode =
292
+ error &&
293
+ typeof error === 'object' &&
294
+ 'statusCode' in error &&
295
+ typeof error.statusCode === 'number'
296
+ ? error.statusCode
297
+ : 500;
298
+ const message =
299
+ error instanceof Error
300
+ ? error.message
301
+ : 'Failed to fetch GitHub repositories.';
302
+ return res.status(statusCode).json({ error: message });
303
+ }
304
+ });
305
+
306
+ githubRouter.get('/branches', async (req, res) => {
307
+ try {
308
+ const repository =
309
+ typeof req.query.repository === 'string'
310
+ ? req.query.repository.trim()
311
+ : '';
312
+ const parts = repository.split('/');
313
+ if (parts.length !== 2 || parts.some((part) => !part)) {
314
+ return res
315
+ .status(400)
316
+ .json({ error: 'Repository must use the owner/name format.' });
75
317
  }
76
- } catch (err) {
77
- console.error('Token refresh error:', err);
78
- res.status(500).json({ error: 'Failed to refresh token' });
318
+
319
+ const github = await createGithubApiClient(req);
320
+ const data = (await github.get(
321
+ `https://api.github.com/repos/${encodeURIComponent(parts[0])}/${encodeURIComponent(parts[1])}/branches?per_page=100`,
322
+ )) as Array<{ name?: string }>;
323
+ const branches = Array.isArray(data)
324
+ ? data
325
+ .filter(
326
+ (branch): branch is { name: string } =>
327
+ typeof branch.name === 'string',
328
+ )
329
+ .map((branch) => ({ name: branch.name }))
330
+ : [];
331
+
332
+ return res.json({ branches });
333
+ } catch (error) {
334
+ console.error('Failed to fetch GitHub branches:', error);
335
+ const statusCode =
336
+ error &&
337
+ typeof error === 'object' &&
338
+ 'statusCode' in error &&
339
+ typeof error.statusCode === 'number'
340
+ ? error.statusCode
341
+ : 500;
342
+ const message =
343
+ error instanceof Error
344
+ ? error.message
345
+ : 'Failed to fetch GitHub branches.';
346
+ return res.status(statusCode).json({ error: message });
79
347
  }
80
348
  });
@@ -0,0 +1,31 @@
1
+ import type { Locator, Page } from '@playwright/test';
2
+ import type { PlayrunnerE2EHost } from '@playrunner/integration-sdk/e2e';
3
+
4
+ export class GithubE2EPom {
5
+ readonly appNameInput: Locator;
6
+ readonly authenticateButton: Locator;
7
+ readonly clientIdInput: Locator;
8
+ readonly clientSecretInput: Locator;
9
+ readonly dialog: Locator;
10
+ readonly setupGuideLink: Locator;
11
+
12
+ constructor(
13
+ readonly page: Page,
14
+ private readonly host: PlayrunnerE2EHost,
15
+ ) {
16
+ this.dialog = page.getByRole('dialog', { name: 'Connect to GitHub' });
17
+ this.appNameInput = this.dialog.getByLabel('GitHub App Name (URL Slug)');
18
+ this.clientIdInput = this.dialog.getByLabel('Client ID');
19
+ this.clientSecretInput = this.dialog.getByLabel('Client Secret');
20
+ this.authenticateButton = this.dialog.getByRole('button', {
21
+ name: 'Authenticate',
22
+ });
23
+ this.setupGuideLink = this.dialog.getByRole('link', {
24
+ name: 'Open GitHub setup guide',
25
+ });
26
+ }
27
+
28
+ async open() {
29
+ await this.host.openIntegration({ id: 'github', name: 'GitHub' });
30
+ }
31
+ }
@@ -0,0 +1,18 @@
1
+ import type { PlayrunnerE2EDataContext } from '@playrunner/integration-sdk/e2e';
2
+
3
+ export interface GithubE2EData {
4
+ appName: string;
5
+ clientId: string;
6
+ clientSecret: string;
7
+ }
8
+
9
+ export function createGithubE2EData({
10
+ runId,
11
+ }: PlayrunnerE2EDataContext): GithubE2EData {
12
+ const suffix = runId.replace(/[^a-zA-Z0-9]/g, '-');
13
+ return {
14
+ appName: `playrunner-e2e-${suffix}`,
15
+ clientId: `github-client-${suffix}`,
16
+ clientSecret: `github-secret-${suffix}`,
17
+ };
18
+ }
@@ -0,0 +1,38 @@
1
+ import { definePlayrunnerE2EContribution } from '@playrunner/integration-sdk/e2e';
2
+ import { createGithubE2EData } from './data';
3
+ import { GithubE2EPom } from './GithubE2EPom';
4
+
5
+ export const githubE2EContribution = definePlayrunnerE2EContribution({
6
+ id: 'github',
7
+ createData: createGithubE2EData,
8
+ createPom: ({ host, page }) => new GithubE2EPom(page, host),
9
+ scenarios: [
10
+ {
11
+ id: 'oauth-setup',
12
+ mode: 'mock',
13
+ title: 'validates the GitHub OAuth setup form',
14
+ tags: ['@github', '@integration'],
15
+ async run({ data, expect, pom }) {
16
+ await pom.open();
17
+
18
+ await expect(pom.setupGuideLink).toHaveAttribute('target', '_blank');
19
+ await expect(pom.clientSecretInput).toHaveAttribute('type', 'password');
20
+ await expect(pom.authenticateButton).toBeDisabled();
21
+
22
+ await pom.appNameInput.click();
23
+ await pom.appNameInput.fill(data.appName);
24
+ await pom.clientIdInput.click();
25
+ await pom.clientIdInput.fill(data.clientId);
26
+ await pom.clientSecretInput.click();
27
+ await pom.clientSecretInput.fill(data.clientSecret);
28
+ await expect(pom.authenticateButton).toBeEnabled();
29
+ },
30
+ },
31
+ ],
32
+ });
33
+
34
+ export default githubE2EContribution;
35
+
36
+ export { createGithubE2EData } from './data';
37
+ export type { GithubE2EData } from './data';
38
+ export { GithubE2EPom } from './GithubE2EPom';
@@ -51,6 +51,7 @@ export function GithubSettingsModal({
51
51
  const [githubAppSlug, setGithubAppSlug] = useState<string | null>(null);
52
52
  const [isAuthenticating, setIsAuthenticating] = useState(false);
53
53
  const [authSuccess, setAuthSuccess] = useState(false);
54
+ const [authError, setAuthError] = useState('');
54
55
  const [copiedUrl, setCopiedUrl] = useState(false);
55
56
  const popupRef = React.useRef<Window | null>(null);
56
57
 
@@ -72,18 +73,19 @@ export function GithubSettingsModal({
72
73
  'github',
73
74
  );
74
75
  if (data && isMounted) {
75
- if (data.clientId && data.accessToken) {
76
- setGithubAppName(data.appName || data.appSlug || '');
77
- setGithubClientId(data.clientId);
78
- setGithubClientSecret(data.clientSecret || '');
79
- if (data.appSlug) setGithubAppSlug(data.appSlug);
80
- setAuthSuccess(true);
81
- } else if (data.clientId) {
82
- setGithubAppName(data.appName || data.appSlug || '');
83
- setGithubClientId(data.clientId);
84
- setGithubClientSecret(data.clientSecret || '');
85
- if (data.appSlug) setGithubAppSlug(data.appSlug);
86
- }
76
+ const appName =
77
+ typeof data.config?.appName === 'string'
78
+ ? data.config.appName
79
+ : typeof data.config?.appSlug === 'string'
80
+ ? data.config.appSlug
81
+ : '';
82
+ const appSlug =
83
+ typeof data.config?.appSlug === 'string'
84
+ ? data.config.appSlug
85
+ : null;
86
+ setGithubAppName(appName);
87
+ setGithubAppSlug(appSlug);
88
+ setAuthSuccess(Boolean(data.credentialStatus?.configured));
87
89
  }
88
90
  } catch (error) {
89
91
  console.error('Failed to fetch Github credentials', error);
@@ -95,6 +97,7 @@ export function GithubSettingsModal({
95
97
  fetchCredentials();
96
98
  } else {
97
99
  setAuthSuccess(false);
100
+ setAuthError('');
98
101
  setIsAuthenticating(false);
99
102
  setGithubAppName('');
100
103
  setGithubClientId('');
@@ -110,28 +113,26 @@ export function GithubSettingsModal({
110
113
  const handleAuthenticateGithub = async () => {
111
114
  try {
112
115
  setIsAuthenticating(true);
113
-
114
- const currentUser = auth.currentUser;
115
-
116
- if (currentUser) {
117
- await store.saveIntegration(currentUser.uid, 'github', {
118
- appName: githubAppName,
119
- clientId: githubClientId,
120
- clientSecret: githubClientSecret,
121
- updatedAt: new Date().toISOString(),
122
- });
123
- }
116
+ setAuthError('');
124
117
 
125
118
  let isProcessing = false;
119
+ let installationId: string | undefined;
120
+ const oauthState = crypto.randomUUID();
126
121
  const messageListener = async (event: MessageEvent) => {
127
122
  if (event.origin !== window.location.origin) return;
128
123
  if (event.data?.type === 'oauth_callback' && event.data?.success) {
129
124
  if (isProcessing) return;
130
125
  isProcessing = true;
131
126
 
127
+ let didConnect = false;
132
128
  if (auth.currentUser) {
133
129
  if (event.data?.params?.code) {
134
130
  try {
131
+ if (event.data.params.state !== oauthState) {
132
+ throw new Error(
133
+ 'GitHub returned an invalid OAuth state. Try connecting again.',
134
+ );
135
+ }
135
136
  // Exchange the code for an access token
136
137
  const token = await auth.currentUser.getIdToken();
137
138
  const tokenRes = await fetch('/api/github/token', {
@@ -144,47 +145,22 @@ export function GithubSettingsModal({
144
145
  code: event.data.params.code,
145
146
  client_id: githubClientId,
146
147
  client_secret: githubClientSecret,
148
+ app_name: githubAppName,
149
+ installation_id:
150
+ event.data.params.installation_id || installationId,
147
151
  }),
148
152
  });
149
153
 
150
154
  const tokenData = await tokenRes.json();
151
155
 
152
- if (!tokenData.access_token) {
156
+ if (!tokenRes.ok || !tokenData.connected) {
153
157
  throw new Error(
154
158
  `Failed to retrieve access token: ${JSON.stringify(tokenData)}`,
155
159
  );
156
160
  }
157
161
 
158
- const integrationData: any = {
159
- appName: githubAppName,
160
- clientId: githubClientId,
161
- clientSecret: githubClientSecret,
162
- code: event.data.params.code,
163
- accessToken: tokenData.access_token,
164
- refreshToken: tokenData.refresh_token,
165
- expiresAt: tokenData.expires_in
166
- ? Date.now() + tokenData.expires_in * 1000
167
- : undefined,
168
- refreshTokenExpiresAt: tokenData.refresh_token_expires_in
169
- ? Date.now() + tokenData.refresh_token_expires_in * 1000
170
- : undefined,
171
- appSlug: githubAppName,
172
- updatedAt: new Date().toISOString(),
173
- };
174
-
175
- // Store installation_id if it was provided
176
- if (event.data.params.installation_id) {
177
- integrationData.installationId =
178
- event.data.params.installation_id;
179
- }
180
-
181
- await store.saveIntegration(
182
- auth.currentUser.uid,
183
- 'github',
184
- integrationData,
185
- );
186
-
187
162
  setGithubAppSlug(githubAppName);
163
+ didConnect = true;
188
164
  if (popupRef.current)
189
165
  popupRef.current.postMessage(
190
166
  { type: 'oauth_close' },
@@ -192,24 +168,10 @@ export function GithubSettingsModal({
192
168
  );
193
169
  } catch (err) {
194
170
  console.error('Failed to save auth code:', err);
195
- if (popupRef.current)
196
- popupRef.current.postMessage(
197
- { type: 'oauth_close' },
198
- window.location.origin,
199
- );
200
- }
201
- } else if (event.data?.params?.installation_id) {
202
- if (event.data?.params?.setup_action === 'update') {
203
- // Just an update to the installation (e.g. adding repositories)
204
- if (popupRef.current)
205
- popupRef.current.postMessage(
206
- { type: 'oauth_close' },
207
- window.location.origin,
208
- );
209
- } else {
210
- // If there is an installation ID but no code, they probably forgot to check "Request user authorization (OAuth) during installation"
211
- alert(
212
- "Installation successful, but no OAuth code found. Please ensure you checked 'Request user authorization (OAuth) during installation' in your GitHub App settings.",
171
+ setAuthError(
172
+ err instanceof Error
173
+ ? err.message
174
+ : 'Failed to save the GitHub connection.',
213
175
  );
214
176
  if (popupRef.current)
215
177
  popupRef.current.postMessage(
@@ -217,25 +179,51 @@ export function GithubSettingsModal({
217
179
  window.location.origin,
218
180
  );
219
181
  }
182
+ } else if (event.data?.params?.installation_id) {
183
+ installationId = event.data.params.installation_id;
184
+ const authorizeUrl = new URL(
185
+ 'https://github.com/login/oauth/authorize',
186
+ );
187
+ authorizeUrl.searchParams.set('client_id', githubClientId);
188
+ authorizeUrl.searchParams.set('redirect_uri', callbackUrl);
189
+ authorizeUrl.searchParams.set('state', oauthState);
190
+ popupRef.current?.postMessage(
191
+ {
192
+ type: 'oauth_install_redirect',
193
+ url: authorizeUrl.toString(),
194
+ },
195
+ window.location.origin,
196
+ );
197
+ isProcessing = false;
198
+ return;
199
+ } else {
200
+ setAuthError(
201
+ 'GitHub returned no OAuth code. Check the GitHub App callback and OAuth settings, then try again.',
202
+ );
220
203
  }
204
+ } else {
205
+ setAuthError('You must be signed in to connect GitHub.');
221
206
  }
222
207
 
223
208
  setIsAuthenticating(false);
224
- setAuthSuccess(true);
209
+ setAuthSuccess(didConnect);
225
210
  window.removeEventListener('message', messageListener);
226
211
  }
227
212
  };
228
213
 
229
214
  window.addEventListener('message', messageListener);
230
215
 
231
- const authUrl = `https://github.com/apps/${githubAppName}/installations/new`;
216
+ const installationUrl = new URL(
217
+ `https://github.com/apps/${githubAppName}/installations/new`,
218
+ );
219
+ installationUrl.searchParams.set('state', oauthState);
232
220
 
233
221
  const width = 800;
234
222
  const height = 700;
235
223
  const left = window.screen.width / 2 - width / 2;
236
224
  const top = window.screen.height / 2 - height / 2;
237
225
  popupRef.current = window.open(
238
- authUrl,
226
+ installationUrl.toString(),
239
227
  'GithubOAuth',
240
228
  `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${width}, height=${height}, top=${top}, left=${left}`,
241
229
  );
@@ -249,6 +237,11 @@ export function GithubSettingsModal({
249
237
  }, 500);
250
238
  } catch (error) {
251
239
  console.error('Failed to save credentials', error);
240
+ setAuthError(
241
+ error instanceof Error
242
+ ? error.message
243
+ : 'Failed to authenticate with GitHub.',
244
+ );
252
245
  setIsAuthenticating(false);
253
246
  }
254
247
  };
@@ -365,6 +358,15 @@ export function GithubSettingsModal({
365
358
  <div className="flex flex-col gap-6">
366
359
  <IntegrationConnectionAutofillGuard connectionId="github" />
367
360
 
361
+ {authError ? (
362
+ <div
363
+ role="alert"
364
+ className="rounded-xl border border-red-500/20 bg-red-500/10 p-3 text-sm text-red-500"
365
+ >
366
+ {authError}
367
+ </div>
368
+ ) : null}
369
+
368
370
  <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-hover)] p-4 text-left">
369
371
  <div className="flex items-start gap-3">
370
372
  <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--background)]">
@@ -1,7 +1,6 @@
1
1
  import type { Integration } from '@playrunner/integration-sdk';
2
2
  import { GithubSettingsModal } from './GithubSettingsModal';
3
3
  import { GithubIcon } from './GithubIcon';
4
- import { refreshGithubTokenIfNeeded } from './tokenRefresh';
5
4
 
6
5
  export const githubIntegration: Integration = {
7
6
  id: 'github',
@@ -13,7 +12,6 @@ export const githubIntegration: Integration = {
13
12
  iconRenderMode: 'mask',
14
13
  getAuthPath: (uid) => `users/${uid}/integrations/github`,
15
14
  SettingsModal: GithubSettingsModal,
16
- refreshStoredIntegration: refreshGithubTokenIfNeeded,
17
15
  };
18
16
 
19
17
  export default githubIntegration;
@@ -1,79 +0,0 @@
1
- import type { IntegrationRefreshContext } from '@playrunner/integration-sdk';
2
-
3
- function isBadGithubRefreshTokenError(tokenData: any) {
4
- return tokenData?.error === 'bad_refresh_token';
5
- }
6
-
7
- export async function refreshGithubTokenIfNeeded({
8
- integrationData,
9
- getApiHeaders,
10
- saveIntegration,
11
- deleteIntegration,
12
- }: IntegrationRefreshContext) {
13
- if (
14
- !integrationData ||
15
- !integrationData.accessToken ||
16
- !integrationData.refreshToken ||
17
- !integrationData.expiresAt
18
- ) {
19
- return integrationData;
20
- }
21
-
22
- if (Date.now() + 5 * 60 * 1000 <= integrationData.expiresAt) {
23
- return integrationData;
24
- }
25
-
26
- try {
27
- console.log('GitHub token is expired or expiring soon, refreshing...');
28
- const res = await fetch('/api/github/refresh', {
29
- method: 'POST',
30
- headers: {
31
- 'Content-Type': 'application/json',
32
- ...(await getApiHeaders()),
33
- },
34
- body: JSON.stringify({
35
- refresh_token: integrationData.refreshToken,
36
- client_id: integrationData.clientId,
37
- client_secret: integrationData.clientSecret,
38
- }),
39
- });
40
-
41
- const tokenData = await res.json();
42
-
43
- if (tokenData.access_token) {
44
- integrationData.accessToken = tokenData.access_token;
45
- if (tokenData.refresh_token) {
46
- integrationData.refreshToken = tokenData.refresh_token;
47
- }
48
- if (tokenData.expires_in) {
49
- integrationData.expiresAt = Date.now() + tokenData.expires_in * 1000;
50
- }
51
- if (tokenData.refresh_token_expires_in) {
52
- integrationData.refreshTokenExpiresAt =
53
- Date.now() + tokenData.refresh_token_expires_in * 1000;
54
- }
55
- integrationData.updatedAt = new Date().toISOString();
56
-
57
- await saveIntegration(integrationData);
58
- console.log('GitHub token refreshed successfully.');
59
- return integrationData;
60
- }
61
-
62
- if (isBadGithubRefreshTokenError(tokenData)) {
63
- console.warn(
64
- 'GitHub refresh token is invalid or expired. Clearing the saved GitHub connection; reconnect GitHub to continue.',
65
- );
66
- await deleteIntegration();
67
- return null;
68
- }
69
-
70
- console.error(
71
- 'Failed to refresh GitHub token, no access_token returned:',
72
- tokenData,
73
- );
74
- } catch (error) {
75
- console.error('Failed to refresh GitHub token:', error);
76
- }
77
-
78
- return integrationData;
79
- }