@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 +21 -0
- package/package.json +14 -2
- package/src/api/index.ts +298 -30
- package/src/e2e/GithubE2EPom.ts +31 -0
- package/src/e2e/data.ts +18 -0
- package/src/e2e/index.ts +38 -0
- package/src/frontend/GithubSettingsModal.tsx +76 -74
- package/src/frontend/index.tsx +0 -2
- package/src/frontend/tokenRefresh.ts +0 -79
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
GitHub authentication and repository access for Playrunner workflows.
|
|
4
4
|
|
|
5
|
+
[](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.
|
|
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 } =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
}
|
package/src/e2e/data.ts
ADDED
|
@@ -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
|
+
}
|
package/src/e2e/index.ts
ADDED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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.
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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)]">
|
package/src/frontend/index.tsx
CHANGED
|
@@ -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
|
-
}
|