Skip to content

Commit d99740b

Browse files
feat: open a pr on github. (#30)
1 parent 23f96fa commit d99740b

9 files changed

Lines changed: 1918 additions & 52 deletions

File tree

docs/next-steps.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,21 @@ Focused follow-up work for `@knighted/develop`.
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."
2121

22-
4. **Issue #18 continuation (resume from Phase 3)**
22+
4. **Issue #18 continuation (finish remaining Phase 3 scope)**
2323
- Current rollout status:
2424
- Phase 0 complete: feature flag + scaffolding.
2525
- Phase 1 complete: BYOT token flow, localStorage persistence, writable repo discovery/filtering.
2626
- Phase 2 complete: separate AI chat drawer UX, streaming-first responses with non-stream fallback, selected repository context plumbing, and README fine-grained PAT setup links.
27-
- Implement the next slice first (Phase 3):
27+
- Phase 3 partially complete: PR-prep filename/path groundwork landed via the Open PR drawer with repository-scoped persistence and stricter path validation.
28+
- Phase 4 complete: open PR flow from editor content (branch creation, file upserts, PR creation), confirmation UX, loading/success states, and toast feedback.
29+
- Post-implementation hardening complete: traversal/path validation edge cases, trailing-slash rejection, writable-repo select reset behavior during loading/error states, and a JS-driven Playwright readiness check.
30+
- Implement the next slice (remaining Phase 3 assistant features):
2831
- Add mode-aware recommendation behavior so the assistant strongly adapts suggestions to current render mode and style mode.
2932
- Add an editor update workflow where the assistant can propose structured edits and the user can apply to Component and Styles editors with explicit confirmation.
30-
- Add filename groundwork for upcoming PR flows by allowing user-defined Component and Styles file names, persisted per selected repository.
3133
- Keep behavior and constraints aligned with current implementation:
3234
- Keep everything behind the existing browser-only AI feature flag.
3335
- Preserve BYOT token semantics (localStorage persistence until user deletes).
3436
- Keep CDN-first runtime behavior and existing fallback model.
3537
- Do not add dependencies without explicit approval.
36-
- Phase 3 mini-spec (agent implementation prompt):
37-
- "Continue Issue #18 in @knighted/develop from the current Phase 2 baseline. Implement Phase 3 with three deliverables. (1) Add mode-aware assistant guidance: when collecting AI context, include explicit policy hints derived from render mode and style mode, and ensure recommendations avoid incompatible patterns (for example, avoid React hook/state guidance in DOM mode unless user explicitly asks for React migration). (2) Add assistant-to-editor apply flow: support structured assistant responses that can propose edits for component and/or styles editors; render these as reviewable actions in the chat drawer, require explicit user confirmation to apply, and support a one-step undo for last applied assistant edit per editor. (3) Add PR-prep filename metadata: introduce user-editable fields for Component filename and Styles filename in AI controls, validate simple safe filename format, and persist/reload values scoped to selected repository so Phase 4 PR write flow can reuse them. Keep all AI/BYOT behavior behind the existing browser-only AI feature flag and preserve current token/repo persistence semantics. Do not add dependencies. Validate with npm run lint and targeted Playwright tests covering: mode-aware recommendation constraints, apply/undo editor actions, and repository-scoped filename persistence."
38+
- Remaining Phase 3 mini-spec (agent implementation prompt):
39+
- "Continue Issue #18 in @knighted/develop from the current baseline where PR filename/path groundwork and Open PR flow are already shipped. Implement the two remaining Phase 3 assistant deliverables. (1) Add mode-aware assistant guidance: when collecting AI context, include explicit policy hints derived from render mode and style mode, and ensure recommendations avoid incompatible patterns (for example, avoid React hook/state guidance in DOM mode unless user explicitly asks for React migration). (2) Add assistant-to-editor apply flow: support structured assistant responses that can propose edits for component and/or styles editors; render these as reviewable actions in the chat drawer, require explicit user confirmation to apply, and support a one-step undo for last applied assistant edit per editor. Keep all AI/BYOT behavior behind the existing browser-only AI feature flag and preserve current token/repo persistence semantics. Do not add dependencies. Validate with npm run lint and targeted Playwright tests covering mode-aware recommendation constraints and apply/undo editor actions."

playwright/app.spec.ts

Lines changed: 246 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,21 @@ type ChatRequestBody = {
1717
stream?: boolean
1818
}
1919

20+
type CreateRefRequestBody = {
21+
ref?: string
22+
sha?: string
23+
}
24+
25+
type PullRequestCreateBody = {
26+
head?: string
27+
base?: string
28+
}
29+
2030
const waitForAppReady = async (page: Page, path = appEntryPath) => {
2131
await page.goto(path)
2232
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
2333
await expect(page.locator('#cdn-loading')).toHaveAttribute('hidden', '')
34+
await expect.poll(() => page.locator('#status').textContent()).not.toBe('Idle')
2435
}
2536

2637
const waitForInitialRender = async (page: Page) => {
@@ -124,6 +135,18 @@ const ensureAiChatDrawerOpen = async (page: Page) => {
124135
await expect(page.locator('#ai-chat-drawer')).toBeVisible()
125136
}
126137

138+
const ensureOpenPrDrawerOpen = async (page: Page) => {
139+
const toggle = page.locator('#github-pr-toggle')
140+
await expect(toggle).toBeEnabled({ timeout: 60_000 })
141+
const isExpanded = await toggle.getAttribute('aria-expanded')
142+
143+
if (isExpanded !== 'true') {
144+
await toggle.click()
145+
}
146+
147+
await expect(page.locator('#github-pr-drawer')).toBeVisible()
148+
}
149+
127150
const connectByotWithSingleRepo = async (page: Page) => {
128151
await page.route('https://api.github.com/user/repos**', async route => {
129152
await route.fulfill({
@@ -144,9 +167,8 @@ const connectByotWithSingleRepo = async (page: Page) => {
144167

145168
await page.locator('#github-token-input').fill('github_pat_fake_chat_1234567890')
146169
await page.locator('#github-token-add').click()
147-
await expect(page.locator('#github-repo-select')).toHaveValue(
148-
'knightedcodemonkey/develop',
149-
)
170+
await expect(page.locator('#status')).toHaveText('Loaded 1 writable repositories')
171+
await expect(page.locator('#github-pr-toggle')).toBeVisible()
150172
}
151173

152174
const expectCollapseButtonState = async (
@@ -187,6 +209,8 @@ test('BYOT controls stay hidden when feature flag is disabled', async ({ page })
187209
await expect(byotControls).toBeHidden()
188210
await expect(page.locator('#ai-chat-toggle')).toBeHidden()
189211
await expect(page.locator('#ai-chat-drawer')).toBeHidden()
212+
await expect(page.locator('#github-pr-toggle')).toBeHidden()
213+
await expect(page.locator('#github-pr-drawer')).toBeHidden()
190214
})
191215

192216
test('BYOT controls render when feature flag is enabled by query param', async ({
@@ -199,6 +223,7 @@ test('BYOT controls render when feature flag is enabled by query param', async (
199223
await expect(page.locator('#github-token-input')).toBeVisible()
200224
await expect(page.locator('#github-token-add')).toBeVisible()
201225
await expect(page.locator('#github-ai-controls #ai-chat-toggle')).toBeHidden()
226+
await expect(page.locator('#github-ai-controls #github-pr-toggle')).toBeHidden()
202227
})
203228

204229
test('GitHub token info panel reflects missing and present token states', async ({
@@ -476,6 +501,8 @@ test('AI chat falls back to non-streaming response when streaming fails', async
476501
})
477502

478503
test('BYOT remembers selected repository across reloads', async ({ page }) => {
504+
test.setTimeout(90_000)
505+
479506
await page.route('https://api.github.com/user/repos**', async route => {
480507
await route.fulfill({
481508
status: 200,
@@ -506,7 +533,9 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
506533
await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
507534
await page.locator('#github-token-add').click()
508535

509-
const repoSelect = page.locator('#github-repo-select')
536+
await ensureOpenPrDrawerOpen(page)
537+
538+
const repoSelect = page.locator('#github-pr-repo-select')
510539
await expect(repoSelect).toBeEnabled()
511540
await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories')
512541

@@ -515,11 +544,224 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
515544

516545
await page.reload()
517546
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
547+
await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories', {
548+
timeout: 60_000,
549+
})
518550
await expect(page.locator('#github-token-add')).toBeHidden()
519551
await expect(page.locator('#github-token-delete')).toBeVisible()
552+
await ensureOpenPrDrawerOpen(page)
520553
await expect(repoSelect).toHaveValue('knightedcodemonkey/develop')
521554
})
522555

556+
test('Open PR drawer confirms and submits component/styles filepaths', async ({
557+
page,
558+
}) => {
559+
let createdRefBody: CreateRefRequestBody | null = null
560+
const upsertRequests: Array<{ path: string; body: Record<string, unknown> }> = []
561+
let pullRequestBody: PullRequestCreateBody | null = null
562+
563+
await page.route('https://api.github.com/user/repos**', async route => {
564+
await route.fulfill({
565+
status: 200,
566+
contentType: 'application/json',
567+
body: JSON.stringify([
568+
{
569+
id: 11,
570+
owner: { login: 'knightedcodemonkey' },
571+
name: 'develop',
572+
full_name: 'knightedcodemonkey/develop',
573+
default_branch: 'main',
574+
permissions: { push: true },
575+
},
576+
]),
577+
})
578+
})
579+
580+
await page.route(
581+
'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
582+
async route => {
583+
await route.fulfill({
584+
status: 200,
585+
contentType: 'application/json',
586+
body: JSON.stringify({
587+
ref: 'refs/heads/main',
588+
object: { type: 'commit', sha: 'abc123mainsha' },
589+
}),
590+
})
591+
},
592+
)
593+
594+
await page.route(
595+
'https://api.github.com/repos/knightedcodemonkey/develop/git/refs',
596+
async route => {
597+
createdRefBody = route.request().postDataJSON() as CreateRefRequestBody
598+
await route.fulfill({
599+
status: 201,
600+
contentType: 'application/json',
601+
body: JSON.stringify({ ref: 'refs/heads/develop/open-pr-test' }),
602+
})
603+
},
604+
)
605+
606+
await page.route(
607+
'https://api.github.com/repos/knightedcodemonkey/develop/contents/**',
608+
async route => {
609+
const request = route.request()
610+
const method = request.method()
611+
const url = request.url()
612+
const path = new URL(url).pathname.split('/contents/')[1] ?? ''
613+
614+
if (method === 'GET') {
615+
await route.fulfill({
616+
status: 404,
617+
contentType: 'application/json',
618+
body: JSON.stringify({ message: 'Not Found' }),
619+
})
620+
return
621+
}
622+
623+
const body = request.postDataJSON() as Record<string, unknown>
624+
upsertRequests.push({ path: decodeURIComponent(path), body })
625+
await route.fulfill({
626+
status: 201,
627+
contentType: 'application/json',
628+
body: JSON.stringify({ commit: { sha: 'commit-sha' } }),
629+
})
630+
},
631+
)
632+
633+
await page.route(
634+
'https://api.github.com/repos/knightedcodemonkey/develop/pulls',
635+
async route => {
636+
pullRequestBody = route.request().postDataJSON() as PullRequestCreateBody
637+
await route.fulfill({
638+
status: 201,
639+
contentType: 'application/json',
640+
body: JSON.stringify({
641+
number: 42,
642+
html_url: 'https://github.com/knightedcodemonkey/develop/pull/42',
643+
}),
644+
})
645+
},
646+
)
647+
648+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
649+
await connectByotWithSingleRepo(page)
650+
await ensureOpenPrDrawerOpen(page)
651+
652+
await page.locator('#github-pr-head-branch').fill('Develop/Open-Pr-Test')
653+
await page.locator('#github-pr-component-path').fill('examples/component/App.tsx')
654+
await page.locator('#github-pr-styles-path').fill('examples/styles/app.css')
655+
await page.locator('#github-pr-title').fill('Apply editor updates from develop')
656+
await page
657+
.locator('#github-pr-body')
658+
.fill('Generated from editor content in @knighted/develop.')
659+
660+
await page.locator('#github-pr-submit').click()
661+
662+
const dialog = page.locator('#clear-confirm-dialog')
663+
await expect(dialog).toHaveAttribute('open', '')
664+
await expect(page.locator('#clear-confirm-title')).toHaveText(
665+
'Open pull request with editor content?',
666+
)
667+
await expect(page.locator('#clear-confirm-copy')).toContainText(
668+
'Component file path: examples/component/App.tsx',
669+
)
670+
await expect(page.locator('#clear-confirm-copy')).toContainText(
671+
'Styles file path: examples/styles/app.css',
672+
)
673+
674+
await dialog.getByRole('button', { name: 'Open PR' }).click()
675+
676+
await expect(page.locator('#github-pr-status')).toContainText(
677+
'Pull request opened: https://github.com/knightedcodemonkey/develop/pull/42',
678+
)
679+
680+
const createdRefPayload = createdRefBody as CreateRefRequestBody | null
681+
const pullRequestPayload = pullRequestBody as PullRequestCreateBody | null
682+
683+
expect(createdRefPayload?.ref).toBe('refs/heads/Develop/Open-Pr-Test')
684+
expect(createdRefPayload?.sha).toBe('abc123mainsha')
685+
686+
expect(upsertRequests).toHaveLength(2)
687+
expect(upsertRequests[0]?.path).toBe('examples/component/App.tsx')
688+
expect(upsertRequests[1]?.path).toBe('examples/styles/app.css')
689+
expect(pullRequestPayload?.head).toBe('Develop/Open-Pr-Test')
690+
expect(pullRequestPayload?.base).toBe('main')
691+
692+
await ensureOpenPrDrawerOpen(page)
693+
await expect(page.locator('#github-pr-component-path')).toHaveValue(
694+
'examples/component/App.tsx',
695+
)
696+
await expect(page.locator('#github-pr-styles-path')).toHaveValue(
697+
'examples/styles/app.css',
698+
)
699+
await expect(page.locator('#github-pr-base-branch')).toHaveValue('main')
700+
701+
await expect(page.locator('#github-pr-head-branch')).toHaveValue(
702+
/^develop\/develop\/editor-sync-/,
703+
)
704+
await expect(page.locator('#github-pr-head-branch')).not.toHaveValue(
705+
'Develop/Open-Pr-Test',
706+
)
707+
await expect(page.locator('#github-pr-title')).toHaveValue(
708+
'Apply component and styles edits to knightedcodemonkey/develop',
709+
)
710+
await expect(page.locator('#github-pr-body')).toHaveValue(
711+
[
712+
'This PR was created from @knighted/develop editor content.',
713+
'',
714+
'- Component source -> examples/component/App.tsx',
715+
'- Styles source -> examples/styles/app.css',
716+
].join('\n'),
717+
)
718+
})
719+
720+
test('Open PR drawer validates unsafe filepaths', async ({ page }) => {
721+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
722+
await connectByotWithSingleRepo(page)
723+
await ensureOpenPrDrawerOpen(page)
724+
725+
await page.locator('#github-pr-component-path').fill('../outside/App.tsx')
726+
await page.locator('#github-pr-submit').click()
727+
728+
await expect(page.locator('#github-pr-status')).toContainText(
729+
'Component path: File path cannot include parent directory traversal.',
730+
)
731+
await expect(page.locator('#clear-confirm-dialog')).not.toHaveAttribute('open', '')
732+
})
733+
734+
test('Open PR drawer allows dotted file segments that are not traversal', async ({
735+
page,
736+
}) => {
737+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
738+
await connectByotWithSingleRepo(page)
739+
await ensureOpenPrDrawerOpen(page)
740+
741+
await page.locator('#github-pr-component-path').fill('docs/v1.0..v1.1/App.tsx')
742+
await page.locator('#github-pr-styles-path').fill('styles/foo..bar.css')
743+
await page.locator('#github-pr-submit').click()
744+
745+
await expect(page.locator('#clear-confirm-dialog')).toHaveAttribute('open', '')
746+
await expect(page.locator('#github-pr-status')).not.toContainText(
747+
'File path cannot include parent directory traversal.',
748+
)
749+
})
750+
751+
test('Open PR drawer rejects trailing slash file paths', async ({ page }) => {
752+
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
753+
await connectByotWithSingleRepo(page)
754+
await ensureOpenPrDrawerOpen(page)
755+
756+
await page.locator('#github-pr-component-path').fill('src/components/')
757+
await page.locator('#github-pr-submit').click()
758+
759+
await expect(page.locator('#github-pr-status')).toContainText(
760+
'Component path: File path must include a filename (no trailing slash).',
761+
)
762+
await expect(page.locator('#clear-confirm-dialog')).not.toHaveAttribute('open', '')
763+
})
764+
523765
test('renders default playground preview', async ({ page }) => {
524766
await waitForInitialRender(page)
525767

0 commit comments

Comments
 (0)