Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions playwright/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ type PullRequestCreateBody = {
base?: string
}

type BranchesByRepo = Record<string, string[]>

const waitForAppReady = async (page: Page, path = appEntryPath) => {
await page.goto(path)
await expect(page.getByRole('heading', { name: '@knighted/develop' })).toBeVisible()
Expand Down Expand Up @@ -147,6 +149,28 @@ const ensureOpenPrDrawerOpen = async (page: Page) => {
await expect(page.locator('#github-pr-drawer')).toBeVisible()
}

const mockRepositoryBranches = async (
page: Page,
branchesByRepo: BranchesByRepo = {},
) => {
await page.route('https://api.github.com/repos/**/branches**', async route => {
const url = new URL(route.request().url())
const match = url.pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/branches$/)
const repositoryKey = match ? `${match[1]}/${match[2]}` : ''

const branchNames =
branchesByRepo[repositoryKey] && branchesByRepo[repositoryKey].length > 0
? branchesByRepo[repositoryKey]
: ['main']

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(branchNames.map(name => ({ name }))),
})
})
}

const connectByotWithSingleRepo = async (page: Page) => {
await page.route('https://api.github.com/user/repos**', async route => {
await route.fulfill({
Expand All @@ -165,6 +189,10 @@ const connectByotWithSingleRepo = async (page: Page) => {
})
})

await mockRepositoryBranches(page, {
'knightedcodemonkey/develop': ['main', 'release'],
})

await page.locator('#github-token-input').fill('github_pat_fake_chat_1234567890')
await page.locator('#github-token-add').click()
await expect(page.locator('#status')).toHaveText('Loaded 1 writable repositories')
Expand Down Expand Up @@ -528,6 +556,11 @@ test('BYOT remembers selected repository across reloads', async ({ page }) => {
})
})

await mockRepositoryBranches(page, {
'knightedcodemonkey/develop': ['main', 'release'],
'knightedcodemonkey/css': ['main', 'release/1.x'],
})

await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)

await page.locator('#github-token-input').fill('github_pat_fake_1234567890')
Expand Down Expand Up @@ -577,6 +610,10 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({
})
})

await mockRepositoryBranches(page, {
'knightedcodemonkey/develop': ['main', 'release'],
})

await page.route(
'https://api.github.com/repos/knightedcodemonkey/develop/git/ref/**',
async route => {
Expand Down Expand Up @@ -717,6 +754,82 @@ test('Open PR drawer confirms and submits component/styles filepaths', async ({
)
})

test('Open PR drawer base dropdown updates from mocked repo branches', async ({
page,
}) => {
const branchRequestUrls: string[] = []

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: 'stable',
permissions: { push: true },
},
]),
})
})

await page.route('https://api.github.com/repos/**/branches**', async route => {
const url = route.request().url()
branchRequestUrls.push(url)

const branchNames = url.includes('/repos/knightedcodemonkey/css/branches')
? ['stable', 'release/1.x']
: ['main', 'develop-next']

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(branchNames.map(name => ({ name }))),
})
})

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()
await expect(page.locator('#status')).toHaveText('Loaded 2 writable repositories')

await ensureOpenPrDrawerOpen(page)

const repoSelect = page.locator('#github-pr-repo-select')
const baseSelect = page.locator('#github-pr-base-branch')

await repoSelect.selectOption('knightedcodemonkey/develop')
await expect(baseSelect).toHaveValue('main')
await expect(baseSelect.locator('option')).toHaveText(['main', 'develop-next'])

await repoSelect.selectOption('knightedcodemonkey/css')
await expect(baseSelect).toHaveValue('stable')
await expect(baseSelect.locator('option')).toHaveText(['stable', 'release/1.x'])

expect(
branchRequestUrls.some(url =>
url.includes('https://api.github.com/repos/knightedcodemonkey/develop/branches'),
),
).toBe(true)
expect(
branchRequestUrls.some(url =>
url.includes('https://api.github.com/repos/knightedcodemonkey/css/branches'),
),
).toBe(true)
})

test('Open PR drawer validates unsafe filepaths', async ({ page }) => {
await waitForAppReady(page, `${appEntryPath}?feature-ai=true`)
await connectByotWithSingleRepo(page)
Expand Down
12 changes: 6 additions & 6 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -659,13 +659,13 @@ <h2>Open Pull Request</h2>
for="github-pr-base-branch"
>
<span>Base</span>
<input
<select
id="github-pr-base-branch"
type="text"
autocomplete="off"
spellcheck="false"
placeholder="main"
/>
aria-label="Pull request base branch"
disabled
>
<option value="main" selected>main</option>
</select>
</label>

<label
Expand Down
66 changes: 66 additions & 0 deletions src/modules/github-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ const normalizeRepo = repo => {

const hasWritePermission = permissions => Boolean(permissions && permissions.push)

const normalizeBranchName = branch => {
if (!branch || typeof branch !== 'object') {
return null
}

return typeof branch.name === 'string' && branch.name.trim() ? branch.name : null
}

const buildRequestHeaders = token => ({
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
Expand Down Expand Up @@ -363,6 +371,19 @@ const listReposPage = async ({ token, url, signal }) => {
}
}

const listBranchesPage = async ({ token, url, signal }) => {
const { data, nextPageUrl } = await fetchJson({ token, url, signal })

if (!Array.isArray(data)) {
throw new Error('Unexpected response while loading repository branches from GitHub.')
}

return {
branches: data.map(normalizeBranchName).filter(Boolean),
nextPageUrl,
}
}

export const listWritableRepositories = async ({ token, signal }) => {
if (typeof token !== 'string' || token.trim().length === 0) {
throw new Error('A GitHub token is required to load repositories.')
Expand Down Expand Up @@ -394,6 +415,51 @@ export const listWritableRepositories = async ({ token, signal }) => {
return writableRepos
}

export const listRepositoryBranches = async ({ token, owner, repo, signal }) => {
if (typeof token !== 'string' || token.trim().length === 0) {
throw new Error('A GitHub token is required to load branches.')
}

const normalizedOwner = typeof owner === 'string' ? owner.trim() : ''
const normalizedRepo = typeof repo === 'string' ? repo.trim() : ''

if (!normalizedOwner || !normalizedRepo) {
throw new Error('A valid repository owner/name is required to load branches.')
}

const branches = []
const dedupe = new Set()
const collectBranchesByPage = async ({ url, remainingPageBudget }) => {
if (!url || remainingPageBudget <= 0) {
return
}

const page = await listBranchesPage({ token, url, signal })

for (const name of page.branches) {
if (dedupe.has(name)) {
continue
}

dedupe.add(name)
branches.push(name)
}

await collectBranchesByPage({
url: page.nextPageUrl,
remainingPageBudget: remainingPageBudget - 1,
})
}

await collectBranchesByPage({
url: `${githubApiBaseUrl}/repos/${normalizedOwner}/${normalizedRepo}/branches?per_page=100`,
remainingPageBudget: 5,
})

branches.sort((left, right) => left.localeCompare(right))
return branches
}

export const streamGitHubChatCompletion = async ({
token,
messages,
Expand Down
Loading
Loading