Skip to content

Commit f38a0dc

Browse files
feat: byot. (#21)
1 parent b62a722 commit f38a0dc

15 files changed

Lines changed: 1155 additions & 4 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ browser acts as the runtime host for render, lint, and typecheck flows.
3737
- Live IDE: https://knightedcodemonkey.github.io/develop/
3838
- Source repository: https://github.com/knightedcodemonkey/develop
3939

40+
## BYOT Guide
41+
42+
- GitHub PAT setup and usage: [docs/byot.md](docs/byot.md)
43+
4044
## License
4145

4246
MIT

docs/byot.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# BYOT Setup for GitHub in @knighted/develop
2+
3+
This guide explains how to create and use a fine-grained GitHub Personal Access Token (PAT) for the BYOT flow in `@knighted/develop`.
4+
5+
## What BYOT does in the app
6+
7+
When the AI/BYOT feature is enabled, the token is used to:
8+
9+
- authenticate GitHub API requests
10+
- load repositories where you have write access
11+
- let you choose which repository to work with
12+
13+
As additional AI/PR features roll out, the same token is also used for model and repository operations that require the configured permissions.
14+
15+
## Privacy and storage behavior
16+
17+
- Your token is stored only in your browser `localStorage`.
18+
- The token is never sent to any service except the GitHub endpoints required by the feature.
19+
- You can remove it at any time using the delete button in the BYOT controls.
20+
21+
## Enable the BYOT feature
22+
23+
Use one of these options:
24+
25+
1. Add `?feature-ai=true` to the app URL.
26+
2. Set `localStorage` key `knighted:develop:feature:ai-assistant` to `true`.
27+
28+
## Create a fine-grained PAT
29+
30+
Create a fine-grained PAT in GitHub settings and grant the permissions below.
31+
32+
- Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png)
33+
- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png)
34+
35+
<img src="media/byot-repo-perms.png" alt="Repository PAT permissions" width="560" />
36+
<img src="media/byot-model-perms.png" alt="Models PAT permission" width="560" />
37+
38+
### Repository permissions
39+
40+
- Contents: Read and write
41+
- Pull requests: Read and write
42+
- Metadata: Read-only (required)
43+
44+
### Account permissions
45+
46+
- Models: Read-only
47+
48+
### Repository access scope
49+
50+
Use either of these scopes depending on your needs:
51+
52+
- Only select repositories
53+
- All repositories
54+
55+
`@knighted/develop` will only show repositories where your token has write access.
56+
57+
## Recommended setup flow
58+
59+
1. Create token with the permissions above.
60+
2. Open `@knighted/develop` with `?feature-ai=true`.
61+
3. Paste token into the BYOT input and click add.
62+
4. Verify repository list loads.
63+
5. Select your target repository.
64+
65+
## Screenshots
66+
67+
The screenshots above show the recommended repository and account permission settings.

docs/media/byot-model-perms.png

89.3 KB
Loading

docs/media/byot-repo-perms.png

150 KB
Loading

docs/next-steps.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,19 @@ Focused follow-up work for `@knighted/develop`.
1818
- Ensure the deterministic lane still exercises the same user-facing flows (render, typecheck, lint, diagnostics drawer/button states), only swapping the source of runtime artifacts.
1919
- Suggested implementation prompt:
2020
- "Add a deterministic E2E execution mode for `@knighted/develop` that serves pinned runtime artifacts locally (instead of live CDN fetches) and wire it into CI as a required check on every PR. Keep a separate lightweight CDN-smoke E2E check for real-network coverage. Validate with `npm run lint`, deterministic Playwright PR checks, and one CDN-smoke Playwright run."
21+
22+
4. **Issue #18 continuation (resume from Phase 2)**
23+
- Continue the GitHub AI assistant rollout after completed Phases 0-1:
24+
- Phase 0 complete: feature flag + scaffolding.
25+
- Phase 1 complete: BYOT token flow, localStorage persistence, writable repo discovery/filtering.
26+
- Implement the next slice first:
27+
- Phase 2: chat drawer UX with streaming responses first, plus non-streaming fallback.
28+
- Add selected repository state plumbing now so Phase 4 (PR write flow) can reuse it.
29+
- Add README documentation for fine-grained PAT setup (reuse existing screenshots referenced in docs/byot.md).
30+
- Keep behavior and constraints aligned with current implementation:
31+
- Keep everything behind the existing browser-only AI feature flag.
32+
- Preserve BYOT token semantics (localStorage persistence until user deletes).
33+
- Keep CDN-first runtime behavior and existing fallback model.
34+
- Do not add dependencies without explicit approval.
35+
- Suggested implementation prompt:
36+
- "Continue Issue #18 in @knighted/develop from the current Phase 1 baseline. Implement Phase 2 by adding a separate AI chat drawer with streaming response rendering (primary) and a non-streaming fallback path. Wire selected repository state as shared app state for upcoming Phase 4 PR actions. Update README with a concise fine-grained PAT setup section that links to existing BYOT screenshot assets/docs. Keep all AI/BYOT UI and behavior behind the existing browser-only feature flag, preserve current token persistence and repo filtering behavior, and validate with npm run lint plus targeted Playwright coverage for chat drawer visibility, streaming/fallback behavior, and repo-context selection plumbing."

playwright/app.spec.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import type { Page } from '@playwright/test'
44
const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev'
55
const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html'
66

7-
const waitForInitialRender = async (page: Page) => {
8-
await page.goto(appEntryPath)
7+
const waitForAppReady = async (page: Page, path = appEntryPath) => {
8+
await page.goto(path)
99
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
10-
await expect(page.locator('#status')).toHaveText('Rendered')
1110
await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '')
1211
}
1312

13+
const waitForInitialRender = async (page: Page) => {
14+
await waitForAppReady(page)
15+
await expect(page.locator('#status')).toHaveText('Rendered')
16+
}
17+
1418
const expectPreviewHasRenderedContent = async (page: Page) => {
1519
const previewHost = page.locator('#preview-host')
1620
await expect(previewHost.locator('pre')).toHaveCount(0)
@@ -126,6 +130,70 @@ const expectCollapseButtonState = async (
126130
}
127131
}
128132

133+
test('BYOT controls stay hidden when feature flag is disabled', async ({ page }) => {
134+
await waitForAppReady(page)
135+
136+
const byotControls = page.locator('#github-ai-controls')
137+
await expect(byotControls).toHaveAttribute('hidden', '')
138+
await expect(byotControls).toBeHidden()
139+
})
140+
141+
test('BYOT controls render when feature flag is enabled by query param', async ({
142+
page,
143+
}) => {
144+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
145+
146+
const byotControls = page.locator('#github-ai-controls')
147+
await expect(byotControls).toBeVisible()
148+
await expect(page.locator('#github-token-input')).toBeVisible()
149+
await expect(page.locator('#github-token-add')).toBeVisible()
150+
})
151+
152+
test('BYOT remembers selected repository across reloads', async ({ page }) => {
153+
await page.route('https://api.github.com/user/repos**', async route => {
154+
await route.fulfill({
155+
status: 200,
156+
contentType: 'application/json',
157+
body: JSON.stringify([
158+
{
159+
id: 2,
160+
owner: { login: 'knightedcodemonkey' },
161+
name: 'develop',
162+
full_name: 'knightedcodemonkey/develop',
163+
default_branch: 'main',
164+
permissions: { push: true },
165+
},
166+
{
167+
id: 1,
168+
owner: { login: 'knightedcodemonkey' },
169+
name: 'css',
170+
full_name: 'knightedcodemonkey/css',
171+
default_branch: 'main',
172+
permissions: { push: true },
173+
},
174+
]),
175+
})
176+
})
177+
178+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
179+
180+
await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
181+
await page.locator('#github-token-add').click()
182+
183+
const repoSelect = page.locator('#github-repo-select')
184+
await expect(repoSelect).toBeEnabled()
185+
await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories')
186+
187+
await repoSelect.selectOption('knightedcodemonkey/develop')
188+
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
189+
190+
await page.reload()
191+
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
192+
await expect(page.locator('#github-token-add')).toBeHidden()
193+
await expect(page.locator('#github-token-delete')).toBeVisible()
194+
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
195+
})
196+
129197
test('renders default playground preview', async ({ page }) => {
130198
await waitForInitialRender(page)
131199

src/app.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
import { createCodeMirrorEditor } from './modules/editor-codemirror.js'
88
import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js'
99
import { createDiagnosticsUiController } from './modules/diagnostics-ui.js'
10+
import { isAiAssistantFeatureEnabled } from './modules/feature-flags.js'
11+
import { createGitHubByotControls } from './modules/github-byot-controls.js'
1012
import { createLayoutThemeController } from './modules/layout-theme.js'
1113
import { createLintDiagnosticsController } from './modules/lint-diagnostics.js'
1214
import { createPreviewBackgroundController } from './modules/preview-background.js'
@@ -15,6 +17,13 @@ import { createTypeDiagnosticsController } from './modules/type-diagnostics.js'
1517

1618
const statusNode = document.getElementById('status')
1719
const appGrid = document.querySelector('.app-grid')
20+
const githubAiControls = document.getElementById('github-ai-controls')
21+
const githubTokenInput = document.getElementById('github-token-input')
22+
const githubTokenInfo = document.getElementById('github-token-info')
23+
const githubTokenAdd = document.getElementById('github-token-add')
24+
const githubTokenDelete = document.getElementById('github-token-delete')
25+
const githubRepoWrap = document.getElementById('github-repo-wrap')
26+
const githubRepoSelect = document.getElementById('github-repo-select')
1827
const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]')
1928
const appThemeButtons = document.querySelectorAll('[data-app-theme]')
2029
const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]')
@@ -384,6 +393,21 @@ const {
384393
updateUiIssueIndicators,
385394
} = diagnosticsUi
386395

396+
const aiAssistantFeatureEnabled = isAiAssistantFeatureEnabled()
397+
398+
createGitHubByotControls({
399+
featureEnabled: aiAssistantFeatureEnabled,
400+
controlsRoot: githubAiControls,
401+
tokenInput: githubTokenInput,
402+
tokenInfoButton: githubTokenInfo,
403+
tokenAddButton: githubTokenAdd,
404+
tokenDeleteButton: githubTokenDelete,
405+
repoSelect: githubRepoSelect,
406+
repoWrap: githubRepoWrap,
407+
onRepositoryChange: () => {},
408+
setStatus,
409+
})
410+
387411
const getStyleEditorLanguage = mode => {
388412
if (mode === 'less') return 'less'
389413
if (mode === 'sass') return 'sass'

src/index.html

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,78 @@ <h1>
3333

3434
<main class="app-grid">
3535
<div class="app-grid-layout-controls" role="group" aria-label="App grid layout">
36+
<div
37+
class="app-grid-ai-controls"
38+
id="github-ai-controls"
39+
role="group"
40+
aria-label="GitHub AI controls"
41+
hidden
42+
>
43+
<div class="github-token-control-wrap">
44+
<button
45+
class="hint-icon shadow-hint github-token-info"
46+
id="github-token-info"
47+
type="button"
48+
aria-label="About GitHub token"
49+
aria-describedby="github-token-privacy-note"
50+
data-tooltip="This token is stored only in your browser and is sent only to GitHub APIs you invoke."
51+
>
52+
i
53+
</button>
54+
<label class="sr-only" for="github-token-input">GitHub token</label>
55+
<input
56+
class="github-token-input"
57+
id="github-token-input"
58+
type="text"
59+
autocomplete="off"
60+
autocapitalize="off"
61+
spellcheck="false"
62+
placeholder="GitHub BYOT"
63+
aria-label="GitHub token"
64+
aria-describedby="github-token-privacy-note"
65+
/>
66+
<span class="sr-only" id="github-token-privacy-note"
67+
>This token is stored only in your browser and is sent only to GitHub APIs
68+
you invoke.</span
69+
>
70+
<button
71+
class="layout-toggle github-token-add"
72+
id="github-token-add"
73+
type="button"
74+
aria-label="Add GitHub token"
75+
title="Add GitHub token"
76+
>
77+
<svg viewBox="0 0 24 24" aria-hidden="true">
78+
<path d="M12 5v14"></path>
79+
<path d="M5 12h14"></path>
80+
</svg>
81+
</button>
82+
<button
83+
class="layout-toggle github-token-delete"
84+
id="github-token-delete"
85+
type="button"
86+
aria-label="Delete GitHub token"
87+
title="Delete GitHub token"
88+
hidden
89+
>
90+
<svg viewBox="0 0 24 24" aria-hidden="true">
91+
<path d="M4 7h16"></path>
92+
<path d="M10 11v6"></path>
93+
<path d="M14 11v6"></path>
94+
<path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"></path>
95+
<path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3"></path>
96+
</svg>
97+
</button>
98+
</div>
99+
100+
<div class="github-repo-wrap" id="github-repo-wrap" hidden>
101+
<label class="sr-only" for="github-repo-select">Repository</label>
102+
<select id="github-repo-select" aria-label="Repository" disabled>
103+
<option selected>Connect a token to load repositories</option>
104+
</select>
105+
</div>
106+
</div>
107+
36108
<div class="app-grid-diagnostics-controls" role="group" aria-label="Diagnostics">
37109
<button
38110
class="diagnostics-toggle"

src/modules/feature-flags.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const aiFeatureStorageKey = 'knighted:develop:feature:ai-assistant'
2+
const aiFeatureQueryKey = 'feature-ai'
3+
4+
const parseBooleanLikeValue = value => {
5+
if (typeof value !== 'string') {
6+
return null
7+
}
8+
9+
const normalized = value.trim().toLowerCase()
10+
11+
if (['1', 'true', 'on', 'yes', 'enabled'].includes(normalized)) {
12+
return true
13+
}
14+
15+
if (['0', 'false', 'off', 'no', 'disabled'].includes(normalized)) {
16+
return false
17+
}
18+
19+
return null
20+
}
21+
22+
const readBooleanFromLocalStorage = key => {
23+
try {
24+
const storedValue = localStorage.getItem(key)
25+
return parseBooleanLikeValue(storedValue)
26+
} catch {
27+
return null
28+
}
29+
}
30+
31+
const readBooleanFromQueryParam = key => {
32+
if (typeof window === 'undefined') {
33+
return null
34+
}
35+
36+
const params = new URLSearchParams(window.location.search)
37+
if (!params.has(key)) {
38+
return null
39+
}
40+
41+
return parseBooleanLikeValue(params.get(key))
42+
}
43+
44+
export const isAiAssistantFeatureEnabled = () => {
45+
const queryValue = readBooleanFromQueryParam(aiFeatureQueryKey)
46+
if (queryValue !== null) {
47+
return queryValue
48+
}
49+
50+
const localStorageValue = readBooleanFromLocalStorage(aiFeatureStorageKey)
51+
if (localStorageValue !== null) {
52+
return localStorageValue
53+
}
54+
55+
return false
56+
}
57+
58+
export const setAiAssistantFeatureEnabled = isEnabled => {
59+
try {
60+
localStorage.setItem(aiFeatureStorageKey, isEnabled ? 'true' : 'false')
61+
} catch {
62+
/* noop */
63+
}
64+
}

0 commit comments

Comments
 (0)