diff --git a/README.md b/README.md index a05972d..5fb3141 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ browser acts as the runtime host for render, lint, and typecheck flows. - Live IDE: https://knightedcodemonkey.github.io/develop/ - Source repository: https://github.com/knightedcodemonkey/develop +## BYOT Guide + +- GitHub PAT setup and usage: [docs/byot.md](docs/byot.md) + ## License MIT diff --git a/docs/byot.md b/docs/byot.md new file mode 100644 index 0000000..41a4c86 --- /dev/null +++ b/docs/byot.md @@ -0,0 +1,67 @@ +# BYOT Setup for GitHub in @knighted/develop + +This guide explains how to create and use a fine-grained GitHub Personal Access Token (PAT) for the BYOT flow in `@knighted/develop`. + +## What BYOT does in the app + +When the AI/BYOT feature is enabled, the token is used to: + +- authenticate GitHub API requests +- load repositories where you have write access +- let you choose which repository to work with + +As additional AI/PR features roll out, the same token is also used for model and repository operations that require the configured permissions. + +## Privacy and storage behavior + +- Your token is stored only in your browser `localStorage`. +- The token is never sent to any service except the GitHub endpoints required by the feature. +- You can remove it at any time using the delete button in the BYOT controls. + +## Enable the BYOT feature + +Use one of these options: + +1. Add `?feature-ai=true` to the app URL. +2. Set `localStorage` key `knighted:develop:feature:ai-assistant` to `true`. + +## Create a fine-grained PAT + +Create a fine-grained PAT in GitHub settings and grant the permissions below. + +- Repository permissions screenshot: [docs/media/byot-repo-perms.png](docs/media/byot-repo-perms.png) +- Models permission screenshot: [docs/media/byot-model-perms.png](docs/media/byot-model-perms.png) + +Repository PAT permissions +Models PAT permission + +### Repository permissions + +- Contents: Read and write +- Pull requests: Read and write +- Metadata: Read-only (required) + +### Account permissions + +- Models: Read-only + +### Repository access scope + +Use either of these scopes depending on your needs: + +- Only select repositories +- All repositories + +`@knighted/develop` will only show repositories where your token has write access. + +## Recommended setup flow + +1. Create token with the permissions above. +2. Open `@knighted/develop` with `?feature-ai=true`. +3. Paste token into the BYOT input and click add. +4. Verify repository list loads. +5. Select your target repository. + +## Screenshots + +The screenshots above show the recommended repository and account permission settings. diff --git a/docs/media/byot-model-perms.png b/docs/media/byot-model-perms.png new file mode 100644 index 0000000..e29ebf2 Binary files /dev/null and b/docs/media/byot-model-perms.png differ diff --git a/docs/media/byot-repo-perms.png b/docs/media/byot-repo-perms.png new file mode 100644 index 0000000..e9f4631 Binary files /dev/null and b/docs/media/byot-repo-perms.png differ diff --git a/docs/next-steps.md b/docs/next-steps.md index 9a32b0a..fd76952 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -18,3 +18,19 @@ Focused follow-up work for `@knighted/develop`. - 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. - Suggested implementation prompt: - "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." + +4. **Issue #18 continuation (resume from Phase 2)** + - Continue the GitHub AI assistant rollout after completed Phases 0-1: + - Phase 0 complete: feature flag + scaffolding. + - Phase 1 complete: BYOT token flow, localStorage persistence, writable repo discovery/filtering. + - Implement the next slice first: + - Phase 2: chat drawer UX with streaming responses first, plus non-streaming fallback. + - Add selected repository state plumbing now so Phase 4 (PR write flow) can reuse it. + - Add README documentation for fine-grained PAT setup (reuse existing screenshots referenced in docs/byot.md). + - Keep behavior and constraints aligned with current implementation: + - Keep everything behind the existing browser-only AI feature flag. + - Preserve BYOT token semantics (localStorage persistence until user deletes). + - Keep CDN-first runtime behavior and existing fallback model. + - Do not add dependencies without explicit approval. + - Suggested implementation prompt: + - "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." diff --git a/playwright/app.spec.ts b/playwright/app.spec.ts index d56316f..eb4fdb0 100644 --- a/playwright/app.spec.ts +++ b/playwright/app.spec.ts @@ -4,13 +4,17 @@ import type { Page } from '@playwright/test' const webServerMode = process.env.PLAYWRIGHT_WEB_SERVER_MODE ?? 'dev' const appEntryPath = webServerMode === 'preview' ? '/index.html' : '/src/index.html' -const waitForInitialRender = async (page: Page) => { - await page.goto(appEntryPath) +const waitForAppReady = async (page: Page, path = appEntryPath) => { + await page.goto(path) await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() - await expect(page.locator('#status')).toHaveText('Rendered') await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '') } +const waitForInitialRender = async (page: Page) => { + await waitForAppReady(page) + await expect(page.locator('#status')).toHaveText('Rendered') +} + const expectPreviewHasRenderedContent = async (page: Page) => { const previewHost = page.locator('#preview-host') await expect(previewHost.locator('pre')).toHaveCount(0) @@ -126,6 +130,70 @@ const expectCollapseButtonState = async ( } } +test('BYOT controls stay hidden when feature flag is disabled', async ({ page }) => { + await waitForAppReady(page) + + const byotControls = page.locator('#github-ai-controls') + await expect(byotControls).toHaveAttribute('hidden', '') + await expect(byotControls).toBeHidden() +}) + +test('BYOT controls render when feature flag is enabled by query param', async ({ + page, +}) => { + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + + const byotControls = page.locator('#github-ai-controls') + await expect(byotControls).toBeVisible() + await expect(page.locator('#github-token-input')).toBeVisible() + await expect(page.locator('#github-token-add')).toBeVisible() +}) + +test('BYOT remembers selected repository across reloads', async ({ page }) => { + await page.route('https://api.github.com/user/repos**', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + id: 2, + owner: { login: 'knightedcodemonkey' }, + name: 'develop', + full_name: 'knightedcodemonkey/develop', + default_branch: 'main', + permissions: { push: true }, + }, + { + id: 1, + owner: { login: 'knightedcodemonkey' }, + name: 'css', + full_name: 'knightedcodemonkey/css', + default_branch: 'main', + permissions: { push: true }, + }, + ]), + }) + }) + + await waitForAppReady(page, `${appEntryPath}?feature-ai=true`) + + await page.locator('#github-token-input').fill('github_pat_fake_1234567890') + await page.locator('#github-token-add').click() + + const repoSelect = page.locator('#github-repo-select') + await expect(repoSelect).toBeEnabled() + await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories') + + await repoSelect.selectOption('knightedcodemonkey/develop') + await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') + + await page.reload() + await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible() + await expect(page.locator('#github-token-add')).toBeHidden() + await expect(page.locator('#github-token-delete')).toBeVisible() + await expect(repoSelect).toHaveValue('knightedcodemonkey/develop') +}) + test('renders default playground preview', async ({ page }) => { await waitForInitialRender(page) diff --git a/src/app.js b/src/app.js index 9926d41..03bd803 100644 --- a/src/app.js +++ b/src/app.js @@ -7,6 +7,8 @@ import { import { createCodeMirrorEditor } from './modules/editor-codemirror.js' import { defaultCss, defaultJsx, defaultReactJsx } from './modules/defaults.js' import { createDiagnosticsUiController } from './modules/diagnostics-ui.js' +import { isAiAssistantFeatureEnabled } from './modules/feature-flags.js' +import { createGitHubByotControls } from './modules/github-byot-controls.js' import { createLayoutThemeController } from './modules/layout-theme.js' import { createLintDiagnosticsController } from './modules/lint-diagnostics.js' import { createPreviewBackgroundController } from './modules/preview-background.js' @@ -15,6 +17,13 @@ import { createTypeDiagnosticsController } from './modules/type-diagnostics.js' const statusNode = document.getElementById('status') const appGrid = document.querySelector('.app-grid') +const githubAiControls = document.getElementById('github-ai-controls') +const githubTokenInput = document.getElementById('github-token-input') +const githubTokenInfo = document.getElementById('github-token-info') +const githubTokenAdd = document.getElementById('github-token-add') +const githubTokenDelete = document.getElementById('github-token-delete') +const githubRepoWrap = document.getElementById('github-repo-wrap') +const githubRepoSelect = document.getElementById('github-repo-select') const appGridLayoutButtons = document.querySelectorAll('[data-app-grid-layout]') const appThemeButtons = document.querySelectorAll('[data-app-theme]') const editorToolsButtons = document.querySelectorAll('[data-editor-tools-toggle]') @@ -384,6 +393,21 @@ const { updateUiIssueIndicators, } = diagnosticsUi +const aiAssistantFeatureEnabled = isAiAssistantFeatureEnabled() + +createGitHubByotControls({ + featureEnabled: aiAssistantFeatureEnabled, + controlsRoot: githubAiControls, + tokenInput: githubTokenInput, + tokenInfoButton: githubTokenInfo, + tokenAddButton: githubTokenAdd, + tokenDeleteButton: githubTokenDelete, + repoSelect: githubRepoSelect, + repoWrap: githubRepoWrap, + onRepositoryChange: () => {}, + setStatus, +}) + const getStyleEditorLanguage = mode => { if (mode === 'less') return 'less' if (mode === 'sass') return 'sass' diff --git a/src/index.html b/src/index.html index 9af6208..b5d4def 100644 --- a/src/index.html +++ b/src/index.html @@ -33,6 +33,78 @@

+ +