@playrunner/github 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,134 @@
1
+ Playrunner Sustainable Use License v1.0
2
+
3
+ Copyright (c) 2026 Concept AI PTY LTD
4
+
5
+ This Playrunner Sustainable Use License v1.0 (the "License") applies to the
6
+ source code and related repository materials for Playrunner that are owned by
7
+ Concept AI PTY LTD, unless a file or directory states a different license.
8
+
9
+ 1. Definitions
10
+
11
+ "Licensor" means Concept AI PTY LTD.
12
+
13
+ "Licensed Work" means the Playrunner source code and related repository
14
+ materials made available by Licensor under this License, excluding any material
15
+ that is expressly made available under a different license or other third-party
16
+ terms.
17
+
18
+ "You" means the individual or legal entity exercising rights under this
19
+ License.
20
+
21
+ "Internal Business Purposes" means use solely to support Your own internal
22
+ operations. It does not include making the Licensed Work, or a material part of
23
+ its functionality, available to third parties as a product or service.
24
+
25
+ 2. License Grant
26
+
27
+ Subject to this License, Licensor grants You a worldwide, non-exclusive,
28
+ royalty-free, non-transferable, non-sublicensable license to use,
29
+ copy, run, modify, create derivative works of, and distribute the Licensed
30
+ Work.
31
+
32
+ 3. Permitted Uses
33
+
34
+ You may exercise the rights granted in Section 2 only for:
35
+
36
+ a. Your Internal Business Purposes; or
37
+ b. personal, educational, evaluation, or other non-commercial purposes.
38
+
39
+ For clarity, use by Your employees, contractors, and controlled affiliates on
40
+ Your behalf for Your Internal Business Purposes is permitted under this
41
+ License.
42
+
43
+ 4. Redistribution Conditions
44
+
45
+ If You distribute the Licensed Work or a modified version, You must:
46
+
47
+ a. include a copy of this License;
48
+ b. preserve all copyright, attribution, and license notices;
49
+ c. clearly identify that You changed the Licensed Work; and
50
+ d. distribute it only free of charge and only for uses otherwise permitted by
51
+ Section 3.
52
+
53
+ 5. Prohibited Uses
54
+
55
+ Unless Licensor gives You separate written permission, You may not:
56
+
57
+ a. sell, license, sublicense, rent, lease, or otherwise monetize the Licensed
58
+ Work or access to it;
59
+ b. provide the Licensed Work, or a service substantially based on it, to third
60
+ parties on a hosted, managed, white-label, or software-as-a-service basis;
61
+ c. embed, bundle, or integrate the Licensed Work into a commercial product or
62
+ service where a material part of the value delivered to third parties comes
63
+ from the Licensed Work or its functionality, except where the Licensed Work
64
+ is used only as an internal tool supporting Your own operations;
65
+ d. remove, obscure, or alter any ownership, attribution, or license notices; or
66
+ e. use Licensor's names, logos, or trademarks except for reasonable nominative
67
+ reference or as separately authorized in writing.
68
+
69
+ 6. Patent License
70
+
71
+ Subject to this License, Licensor grants You a worldwide, non-exclusive,
72
+ royalty-free patent license under patent claims that Licensor can license and
73
+ that are necessarily infringed by the unmodified Licensed Work, solely to make,
74
+ use, run, and import the Licensed Work for the permitted uses in this License.
75
+
76
+ This patent license does not apply to:
77
+
78
+ a. Your modifications to the Licensed Work;
79
+ b. combinations of the Licensed Work with other software, products, or services
80
+ unless such combination is part of the unmodified Licensed Work as released
81
+ by Licensor; or
82
+ c. any activity that is prohibited by this License.
83
+
84
+ If You bring or assist in bringing a patent claim against Licensor or any
85
+ recipient of the Licensed Work alleging that the Licensed Work infringes a
86
+ patent, Your rights under this License terminate immediately.
87
+
88
+ 7. Contributions
89
+
90
+ Unless Licensor agrees otherwise in writing, any contribution intentionally
91
+ submitted by You for inclusion in the Licensed Work is provided under this
92
+ License, and You represent that You have the right to submit it on those terms.
93
+
94
+ 8. Third-Party Materials
95
+
96
+ The Licensed Work may include or depend on third-party software or other
97
+ materials that are licensed under separate terms. Those third-party components
98
+ remain subject to their own licenses, notices, and obligations.
99
+
100
+ 9. Termination
101
+
102
+ If You violate this License, Your rights under it terminate automatically.
103
+ Licensor may reinstate those rights if You fully cure the violation within 30
104
+ days after receiving written notice from Licensor. Termination does not limit
105
+ any other remedies available to Licensor.
106
+
107
+ 10. No Warranty
108
+
109
+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS
110
+ PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTIES OR CONDITIONS OF ANY
111
+ KIND, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHERWISE, INCLUDING ANY IMPLIED
112
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
113
+ NON-INFRINGEMENT.
114
+
115
+ 11. Limitation of Liability
116
+
117
+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL LICENSOR OR
118
+ ITS AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, OR CONTRIBUTORS BE LIABLE FOR
119
+ ANY INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, PUNITIVE, OR CONSEQUENTIAL
120
+ DAMAGES, OR FOR ANY LOSS OF DATA, PROFITS, REVENUE, GOODWILL, OR BUSINESS
121
+ INTERRUPTION, ARISING OUT OF OR RELATING TO THIS LICENSE OR THE LICENSED WORK,
122
+ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
123
+
124
+ TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, LICENSOR'S TOTAL LIABILITY
125
+ ARISING OUT OF OR RELATING TO THIS LICENSE OR THE LICENSED WORK WILL NOT EXCEED
126
+ ONE HUNDRED AUSTRALIAN DOLLARS (AUD $100).
127
+
128
+ 12. General
129
+
130
+ This License is the complete agreement between You and Licensor regarding the
131
+ Licensed Work. If any provision of this License is held unenforceable, the
132
+ remaining provisions will remain in effect to the fullest extent permitted by
133
+ law. A waiver of any breach or default under this License is effective only if
134
+ made in writing by Licensor.
package/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # @playrunner/github
2
+
3
+ GitHub authentication and repository access for Playrunner workflows.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @playrunner/github
9
+ ```
10
+
11
+ Add the package as a direct production dependency of the Playrunner frontend and API. Playwright installations also use this package for repository authentication.
12
+
13
+ ## Package surfaces
14
+
15
+ - `@playrunner/github` exports the GitHub integration, icon, and settings UI.
16
+ - `@playrunner/github/api` exports token exchange and refresh API routes.
17
+ - `@playrunner/github/assets/github.svg` exports the package-owned icon.
18
+
19
+ ```ts
20
+ import githubIntegration, { GithubSettingsModal } from '@playrunner/github';
21
+ import githubApiContribution from '@playrunner/github/api';
22
+ ```
23
+
24
+ ## Documentation
25
+
26
+ See the [GitHub integration documentation](https://playrunner.dev/docs/integration-packages/github/) for GitHub App setup, exports, and Playrunner build integration.
27
+
28
+ ## License
29
+
30
+ Licensed under the [Playrunner Sustainable Use License](https://github.com/playrunner/playrunner/blob/main/LICENSE).
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
2
+ <title>GitHub</title>
3
+ <path d="M12,2A10,10 0 0,0 2,12c0 4.42 2.87 8.17 6.84 9.49.5.09.68-.22.68-.49 0-.24-.01-.87-.01-1.71-2.78.62-3.37-1.23-3.37-1.23-.45-1.18-1.11-1.49-1.11-1.49-.91-.63.07-.62.07-.62 1 .07 1.53 1.07 1.53 1.07.9 1.56 2.36 1.11 2.94.85.09-.66.35-1.11.63-1.37-2.22-.26-4.55-1.14-4.55-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.31.1-2.73 0 0 .84-.27 2.75 1.05a9.38 9.38 0 0 1 5 0c1.91-1.32 2.75-1.05 2.75-1.05.55 1.42.2 2.47.1 2.73.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.06.36.31.68.92.68 1.86 0 1.34-.01 2.41-.01 2.74 0 .27.18.59.69.49C19.14 20.41 22 16.66 22 12.24A10.24 10.24 0 0 0 12,2z"/>
4
+ </svg>
5
+
package/package.json ADDED
@@ -0,0 +1,101 @@
1
+ {
2
+ "name": "@playrunner/github",
3
+ "version": "0.1.0",
4
+ "description": "GitHub integration package for Playrunner.",
5
+ "keywords": [
6
+ "playrunner",
7
+ "github",
8
+ "integration"
9
+ ],
10
+ "license": "SEE LICENSE IN LICENSE",
11
+ "author": "Concept AI PTY LTD",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/playrunner/playrunner.git",
15
+ "directory": "packages/github"
16
+ },
17
+ "homepage": "https://playrunner.dev/docs/integration-packages/github/",
18
+ "bugs": {
19
+ "url": "https://github.com/playrunner/playrunner/issues"
20
+ },
21
+ "type": "module",
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org",
24
+ "access": "public"
25
+ },
26
+ "playrunner": {
27
+ "integration": {
28
+ "id": "github",
29
+ "frontend": ".",
30
+ "api": "./api"
31
+ }
32
+ },
33
+ "exports": {
34
+ ".": {
35
+ "types": "./src/frontend/index.tsx",
36
+ "import": "./src/frontend/index.tsx",
37
+ "require": "./src/frontend/index.tsx",
38
+ "default": "./src/frontend/index.tsx"
39
+ },
40
+ "./frontend": {
41
+ "types": "./src/frontend/index.tsx",
42
+ "import": "./src/frontend/index.tsx",
43
+ "require": "./src/frontend/index.tsx",
44
+ "default": "./src/frontend/index.tsx"
45
+ },
46
+ "./api": {
47
+ "types": "./src/api/index.ts",
48
+ "import": "./src/api/index.ts",
49
+ "require": "./src/api/index.ts",
50
+ "default": "./src/api/index.ts"
51
+ },
52
+ "./assets/github.svg": "./assets/github.svg"
53
+ },
54
+ "files": [
55
+ "LICENSE",
56
+ "README.md",
57
+ "assets",
58
+ "src"
59
+ ],
60
+ "scripts": {
61
+ "lint": "eslint .",
62
+ "lint:fix": "eslint . --fix",
63
+ "format": "prettier --write .",
64
+ "format:check": "prettier --check .",
65
+ "typecheck": "tsc --noEmit"
66
+ },
67
+ "dependencies": {
68
+ "express": "^5.2.1"
69
+ },
70
+ "peerDependencies": {
71
+ "@playrunner/integration-sdk": "^0.1.0",
72
+ "lucide-react": "^0.546.0",
73
+ "react": "^19.0.0"
74
+ },
75
+ "peerDependenciesMeta": {
76
+ "@playrunner/integration-sdk": {
77
+ "optional": true
78
+ },
79
+ "lucide-react": {
80
+ "optional": true
81
+ },
82
+ "react": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "devDependencies": {
87
+ "@eslint/js": "^9.39.4",
88
+ "@types/express": "^5.0.6",
89
+ "@types/react": "^19.2.14",
90
+ "eslint": "^9.39.4",
91
+ "eslint-config-prettier": "10.1.8",
92
+ "eslint-plugin-react": "^7.37.5",
93
+ "eslint-plugin-react-hooks": "^7.1.1",
94
+ "eslint-plugin-react-refresh": "^0.5.3",
95
+ "globals": "^17.6.0",
96
+ "lucide-react": "^0.546.0",
97
+ "prettier": "3.8.4",
98
+ "typescript": "~5.8.2",
99
+ "typescript-eslint": "^8.61.1"
100
+ }
101
+ }
@@ -0,0 +1,80 @@
1
+ import { Router } from 'express';
2
+
3
+ export const githubRouter = Router();
4
+
5
+ export const githubApiContribution = {
6
+ id: 'github',
7
+ mountPath: '/api/github',
8
+ router: githubRouter,
9
+ };
10
+
11
+ export default githubApiContribution;
12
+
13
+ // Proxy endpoint to exchange GitHub OAuth code for an access token to bypass CORS
14
+ githubRouter.post('/token', async (req, res) => {
15
+ const { code, client_id, client_secret } = req.body;
16
+
17
+ try {
18
+ const gRes = await fetch('https://github.com/login/oauth/access_token', {
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ Accept: 'application/json',
23
+ 'User-Agent': 'Playrunner-App',
24
+ },
25
+ body: JSON.stringify({
26
+ client_id,
27
+ client_secret,
28
+ code,
29
+ }),
30
+ });
31
+
32
+ const text = await gRes.text();
33
+ try {
34
+ const data = JSON.parse(text);
35
+ res.json(data);
36
+ } 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 });
41
+ }
42
+ } catch (err) {
43
+ console.error('Token exchange error:', err);
44
+ res.status(500).json({ error: 'Failed to exchange token' });
45
+ }
46
+ });
47
+
48
+ // Proxy endpoint to refresh GitHub OAuth token
49
+ githubRouter.post('/refresh', async (req, res) => {
50
+ const { refresh_token, client_id, client_secret } = req.body;
51
+
52
+ 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',
65
+ }),
66
+ });
67
+
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 });
75
+ }
76
+ } catch (err) {
77
+ console.error('Token refresh error:', err);
78
+ res.status(500).json({ error: 'Failed to refresh token' });
79
+ }
80
+ });
@@ -0,0 +1,13 @@
1
+ export function GithubIcon({ className }: { className?: string }) {
2
+ return (
3
+ <svg
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ viewBox="0 0 24 24"
6
+ fill="currentColor"
7
+ aria-hidden="true"
8
+ className={className}
9
+ >
10
+ <path d="M12,2A10,10 0 0,0 2,12c0 4.42 2.87 8.17 6.84 9.49.5.09.68-.22.68-.49 0-.24-.01-.87-.01-1.71-2.78.62-3.37-1.23-3.37-1.23-.45-1.18-1.11-1.49-1.11-1.49-.91-.63.07-.62.07-.62 1 .07 1.53 1.07 1.53 1.07.9 1.56 2.36 1.11 2.94.85.09-.66.35-1.11.63-1.37-2.22-.26-4.55-1.14-4.55-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.31.1-2.73 0 0 .84-.27 2.75 1.05a9.38 9.38 0 0 1 5 0c1.91-1.32 2.75-1.05 2.75-1.05.55 1.42.2 2.47.1 2.73.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.81-4.57 5.06.36.31.68.92.68 1.86 0 1.34-.01 2.41-.01 2.74 0 .27.18.59.69.49C19.14 20.41 22 16.66 22 12.24A10.24 10.24 0 0 0 12,2z" />
11
+ </svg>
12
+ );
13
+ }
@@ -0,0 +1,475 @@
1
+ import React, { useState } from 'react';
2
+ import {
3
+ BookOpen,
4
+ Check,
5
+ ChevronRight,
6
+ Copy,
7
+ ExternalLink,
8
+ Loader2,
9
+ } from 'lucide-react';
10
+ import {
11
+ IntegrationConnectionAutofillGuard,
12
+ IntegrationConnectionInput,
13
+ useIntegrationHost,
14
+ } from '@playrunner/integration-sdk';
15
+ import { GithubIcon } from './GithubIcon';
16
+
17
+ interface GithubSettingsModalProps {
18
+ isOpen: boolean;
19
+ onClose: () => void;
20
+ }
21
+
22
+ const DEFAULT_DOCS_URL = 'https://playrunner.dev';
23
+ const GITHUB_SETUP_DOCS_URL = getDocsUrl('docs/tutorials/connect-github');
24
+
25
+ type DocsImportMeta = ImportMeta & {
26
+ env?: {
27
+ VITE_DOCS_URL?: string;
28
+ };
29
+ };
30
+
31
+ function getDocsUrl(path = '') {
32
+ const baseUrl = (
33
+ (import.meta as DocsImportMeta).env?.VITE_DOCS_URL || DEFAULT_DOCS_URL
34
+ )
35
+ .trim()
36
+ .replace(/\/+$/, '');
37
+ const normalizedPath = path.trim().replace(/^\/+/, '');
38
+
39
+ return normalizedPath ? `${baseUrl}/${normalizedPath}` : baseUrl;
40
+ }
41
+
42
+ export function GithubSettingsModal({
43
+ isOpen,
44
+ onClose,
45
+ }: GithubSettingsModalProps) {
46
+ const { auth, store, ui } = useIntegrationHost();
47
+ const Modal = ui.Modal;
48
+ const [githubAppName, setGithubAppName] = useState('');
49
+ const [githubClientId, setGithubClientId] = useState('');
50
+ const [githubClientSecret, setGithubClientSecret] = useState('');
51
+ const [githubAppSlug, setGithubAppSlug] = useState<string | null>(null);
52
+ const [isAuthenticating, setIsAuthenticating] = useState(false);
53
+ const [authSuccess, setAuthSuccess] = useState(false);
54
+ const [copiedUrl, setCopiedUrl] = useState(false);
55
+ const popupRef = React.useRef<Window | null>(null);
56
+
57
+ const callbackUrl = `${window.location.origin}/oauth/callback/github`;
58
+
59
+ const handleCopyUrl = () => {
60
+ navigator.clipboard.writeText(callbackUrl);
61
+ setCopiedUrl(true);
62
+ setTimeout(() => setCopiedUrl(false), 2000);
63
+ };
64
+
65
+ React.useEffect(() => {
66
+ let isMounted = true;
67
+ const fetchCredentials = async () => {
68
+ if (isOpen && auth.currentUser) {
69
+ try {
70
+ const data = await store.getIntegration(
71
+ auth.currentUser.uid,
72
+ 'github',
73
+ );
74
+ 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
+ }
87
+ }
88
+ } catch (error) {
89
+ console.error('Failed to fetch Github credentials', error);
90
+ }
91
+ }
92
+ };
93
+
94
+ if (isOpen) {
95
+ fetchCredentials();
96
+ } else {
97
+ setAuthSuccess(false);
98
+ setIsAuthenticating(false);
99
+ setGithubAppName('');
100
+ setGithubClientId('');
101
+ setGithubClientSecret('');
102
+ setGithubAppSlug(null);
103
+ }
104
+
105
+ return () => {
106
+ isMounted = false;
107
+ };
108
+ }, [auth, isOpen, store]);
109
+
110
+ const handleAuthenticateGithub = async () => {
111
+ try {
112
+ 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
+ }
124
+
125
+ let isProcessing = false;
126
+ const messageListener = async (event: MessageEvent) => {
127
+ if (event.origin !== window.location.origin) return;
128
+ if (event.data?.type === 'oauth_callback' && event.data?.success) {
129
+ if (isProcessing) return;
130
+ isProcessing = true;
131
+
132
+ if (auth.currentUser) {
133
+ if (event.data?.params?.code) {
134
+ try {
135
+ // Exchange the code for an access token
136
+ const token = await auth.currentUser.getIdToken();
137
+ const tokenRes = await fetch('/api/github/token', {
138
+ method: 'POST',
139
+ headers: {
140
+ 'Content-Type': 'application/json',
141
+ Authorization: `Bearer ${token}`,
142
+ },
143
+ body: JSON.stringify({
144
+ code: event.data.params.code,
145
+ client_id: githubClientId,
146
+ client_secret: githubClientSecret,
147
+ }),
148
+ });
149
+
150
+ const tokenData = await tokenRes.json();
151
+
152
+ if (!tokenData.access_token) {
153
+ throw new Error(
154
+ `Failed to retrieve access token: ${JSON.stringify(tokenData)}`,
155
+ );
156
+ }
157
+
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
+ setGithubAppSlug(githubAppName);
188
+ if (popupRef.current)
189
+ popupRef.current.postMessage(
190
+ { type: 'oauth_close' },
191
+ window.location.origin,
192
+ );
193
+ } catch (err) {
194
+ 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.",
213
+ );
214
+ if (popupRef.current)
215
+ popupRef.current.postMessage(
216
+ { type: 'oauth_close' },
217
+ window.location.origin,
218
+ );
219
+ }
220
+ }
221
+ }
222
+
223
+ setIsAuthenticating(false);
224
+ setAuthSuccess(true);
225
+ window.removeEventListener('message', messageListener);
226
+ }
227
+ };
228
+
229
+ window.addEventListener('message', messageListener);
230
+
231
+ const authUrl = `https://github.com/apps/${githubAppName}/installations/new`;
232
+
233
+ const width = 800;
234
+ const height = 700;
235
+ const left = window.screen.width / 2 - width / 2;
236
+ const top = window.screen.height / 2 - height / 2;
237
+ popupRef.current = window.open(
238
+ authUrl,
239
+ 'GithubOAuth',
240
+ `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
+ );
242
+
243
+ const checkPopup = setInterval(() => {
244
+ if (!popupRef.current || popupRef.current.closed) {
245
+ clearInterval(checkPopup);
246
+ setIsAuthenticating(false);
247
+ window.removeEventListener('message', messageListener);
248
+ }
249
+ }, 500);
250
+ } catch (error) {
251
+ console.error('Failed to save credentials', error);
252
+ setIsAuthenticating(false);
253
+ }
254
+ };
255
+
256
+ const handleDisconnect = async () => {
257
+ if (!auth.currentUser) return;
258
+ try {
259
+ await store.deleteIntegration(auth.currentUser.uid, 'github');
260
+ setAuthSuccess(false);
261
+ setGithubAppName('');
262
+ setGithubClientId('');
263
+ setGithubClientSecret('');
264
+ setGithubAppSlug(null);
265
+ } catch (error) {
266
+ console.error('Failed to disconnect Github', error);
267
+ }
268
+ };
269
+
270
+ return (
271
+ <Modal
272
+ isOpen={isOpen}
273
+ onClose={onClose}
274
+ zIndex={70}
275
+ title="Connect to GitHub"
276
+ icon={<GithubIcon className="w-5 h-5 text-[var(--foreground)]" />}
277
+ footer={
278
+ !authSuccess ? (
279
+ <button
280
+ onClick={handleAuthenticateGithub}
281
+ disabled={
282
+ !githubAppName ||
283
+ !githubClientId ||
284
+ !githubClientSecret ||
285
+ isAuthenticating
286
+ }
287
+ className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--accent)] hover:bg-[var(--accent)]/90 text-[var(--accent-foreground)] font-medium text-sm transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
288
+ >
289
+ {isAuthenticating ? (
290
+ <>
291
+ <Loader2 className="w-4 h-4 animate-spin" />
292
+ Authenticating...
293
+ </>
294
+ ) : (
295
+ <>
296
+ Authenticate
297
+ <ChevronRight className="w-4 h-4" />
298
+ </>
299
+ )}
300
+ </button>
301
+ ) : null
302
+ }
303
+ >
304
+ {authSuccess ? (
305
+ <div className="flex flex-col items-center justify-center text-center gap-4 py-8">
306
+ <div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center animate-in zoom-in duration-300">
307
+ <Check className="w-8 h-8 text-green-500" />
308
+ </div>
309
+ <div>
310
+ <h3 className="text-xl font-semibold text-[var(--foreground)] mb-2">
311
+ GitHub Connected Successfully
312
+ </h3>
313
+ <p className="text-muted text-sm max-w-[320px] mx-auto mb-4">
314
+ Your GitHub App is connected. You can now use it to manage your
315
+ repositories.
316
+ </p>
317
+
318
+ <div className="bg-[var(--control-bg)] border border-[var(--border)] rounded-lg p-4 text-left max-w-sm mx-auto mb-6">
319
+ <h4 className="text-sm font-medium text-[var(--foreground)] mb-2">
320
+ Manage Repositories
321
+ </h4>
322
+
323
+ <div className="space-y-3 text-xs text-muted">
324
+ <p>
325
+ You can change which repositories the app has access to at any
326
+ time.
327
+ </p>
328
+ {githubAppSlug && (
329
+ <a
330
+ href={`https://github.com/apps/${githubAppSlug}/installations/new`}
331
+ target="_blank"
332
+ rel="noopener noreferrer"
333
+ className="flex justify-center items-center gap-2 px-3 py-2 mt-2 w-full rounded-md bg-[var(--foreground)] text-[var(--background)] hover:bg-[var(--foreground)]/90 font-medium transition-colors"
334
+ >
335
+ Configure App Repositories
336
+ </a>
337
+ )}
338
+ </div>
339
+ </div>
340
+
341
+ <div className="flex items-center justify-center gap-4">
342
+ <button
343
+ onClick={() => setAuthSuccess(false)}
344
+ className="text-xs text-muted hover:text-[var(--foreground)] underline transition-colors"
345
+ >
346
+ Change Credentials
347
+ </button>
348
+ <span className="text-muted text-xs">/</span>
349
+ <button
350
+ onClick={handleDisconnect}
351
+ className="text-xs text-red-500 hover:text-red-400 underline transition-colors"
352
+ >
353
+ Disconnect
354
+ </button>
355
+ </div>
356
+ </div>
357
+ <button
358
+ onClick={onClose}
359
+ className="mt-4 flex items-center gap-2 px-4 py-2 rounded-lg bg-surface border border-subtle hover:bg-surface-hover text-[var(--foreground)] font-medium text-sm transition-colors shadow-sm"
360
+ >
361
+ Close Menu
362
+ </button>
363
+ </div>
364
+ ) : (
365
+ <div className="flex flex-col gap-6">
366
+ <IntegrationConnectionAutofillGuard connectionId="github" />
367
+
368
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--surface-hover)] p-4 text-left">
369
+ <div className="flex items-start gap-3">
370
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--background)]">
371
+ <BookOpen className="h-4 w-4 text-muted" />
372
+ </div>
373
+ <div className="min-w-0 flex-1">
374
+ <p className="text-sm font-medium text-[var(--foreground)]">
375
+ GitHub App setup
376
+ </p>
377
+ <p className="mt-1 text-xs leading-relaxed text-muted">
378
+ Use the setup guide to create the GitHub App, configure
379
+ repository permissions, and find the app slug, client ID, and
380
+ client secret.
381
+ </p>
382
+ <a
383
+ href={GITHUB_SETUP_DOCS_URL}
384
+ target="_blank"
385
+ rel="noopener noreferrer"
386
+ className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-[var(--foreground)] underline underline-offset-4 hover:text-muted"
387
+ >
388
+ Open GitHub setup guide
389
+ <ExternalLink className="h-3.5 w-3.5" />
390
+ </a>
391
+ </div>
392
+ </div>
393
+ </div>
394
+
395
+ <div className="space-y-2">
396
+ <p className="text-xs font-medium text-muted">Callback URL</p>
397
+ <p className="text-xs leading-relaxed text-muted">
398
+ Use this value when the setup guide asks for the callback URL and
399
+ setup URL.
400
+ </p>
401
+ <div className="relative">
402
+ <code className="block p-3 pr-10 bg-[var(--background)] border border-subtle rounded text-xs select-all font-mono text-blue-400 overflow-x-auto whitespace-nowrap">
403
+ {callbackUrl}
404
+ </code>
405
+ <button
406
+ type="button"
407
+ onClick={handleCopyUrl}
408
+ className="absolute top-2 right-2 p-1 rounded bg-[var(--background)] text-muted hover:text-[var(--foreground)] hover:bg-surface-hover transition-colors"
409
+ title="Copy URL"
410
+ >
411
+ {copiedUrl ? (
412
+ <Check className="w-3.5 h-3.5 text-green-500" />
413
+ ) : (
414
+ <Copy className="w-3.5 h-3.5" />
415
+ )}
416
+ </button>
417
+ </div>
418
+ </div>
419
+
420
+ <div className="space-y-4 pt-2 border-t border-subtle">
421
+ <div>
422
+ <label
423
+ htmlFor="github-connection-field-a"
424
+ className="block text-xs font-medium text-muted mb-1.5"
425
+ >
426
+ GitHub App Name (URL Slug)
427
+ </label>
428
+ <IntegrationConnectionInput
429
+ id="github-connection-field-a"
430
+ connectionId="github"
431
+ fieldSlot="a"
432
+ placeholder="e.g. my-awesome-app"
433
+ value={githubAppName}
434
+ onChange={(e) => setGithubAppName(e.target.value)}
435
+ />
436
+ </div>
437
+ <div>
438
+ <label
439
+ htmlFor="github-connection-field-b"
440
+ className="block text-xs font-medium text-muted mb-1.5"
441
+ >
442
+ Client ID
443
+ </label>
444
+ <IntegrationConnectionInput
445
+ id="github-connection-field-b"
446
+ connectionId="github"
447
+ fieldSlot="b"
448
+ placeholder="Paste value from GitHub app"
449
+ value={githubClientId}
450
+ onChange={(e) => setGithubClientId(e.target.value)}
451
+ />
452
+ </div>
453
+ <div>
454
+ <label
455
+ htmlFor="github-connection-field-c"
456
+ className="block text-xs font-medium text-muted mb-1.5"
457
+ >
458
+ Client Secret
459
+ </label>
460
+ <IntegrationConnectionInput
461
+ id="github-connection-field-c"
462
+ connectionId="github"
463
+ fieldSlot="c"
464
+ mode="secret"
465
+ placeholder="Paste value from GitHub app"
466
+ value={githubClientSecret}
467
+ onChange={(e) => setGithubClientSecret(e.target.value)}
468
+ />
469
+ </div>
470
+ </div>
471
+ </div>
472
+ )}
473
+ </Modal>
474
+ );
475
+ }
@@ -0,0 +1,2 @@
1
+ export const githubIconUrl = new URL('../../assets/github.svg', import.meta.url)
2
+ .href;
@@ -0,0 +1,23 @@
1
+ import type { Integration } from '@playrunner/integration-sdk';
2
+ import { GithubSettingsModal } from './GithubSettingsModal';
3
+ import { GithubIcon } from './GithubIcon';
4
+ import { refreshGithubTokenIfNeeded } from './tokenRefresh';
5
+
6
+ export const githubIntegration: Integration = {
7
+ id: 'github',
8
+ name: 'GitHub',
9
+ category: 'Version Control',
10
+ description: 'Sync repositories and trigger actions',
11
+ icon: GithubIcon,
12
+ nodeType: 'trigger',
13
+ iconRenderMode: 'mask',
14
+ getAuthPath: (uid) => `users/${uid}/integrations/github`,
15
+ SettingsModal: GithubSettingsModal,
16
+ refreshStoredIntegration: refreshGithubTokenIfNeeded,
17
+ };
18
+
19
+ export default githubIntegration;
20
+
21
+ export { GithubIcon } from './GithubIcon';
22
+ export { GithubSettingsModal } from './GithubSettingsModal';
23
+ export { githubIconUrl } from './icon';
@@ -0,0 +1,79 @@
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
+ }