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
9 changes: 9 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,12 @@ playwright-report
services/assistant-api/openapi.json
**/src/api/generated.ts
.gradle

# Agent-kit content: prompts, templates, rendered skills, the manifest, and
# design notes are authored/generated text (and prompt wording), not JS sources.
# Keep prettier off them so it neither churns prompt text nor fights the
# agent-kit renderer / manifest sha pins.
.agents/skills/
.claude/skills/
platform/agents/
docs/private/
2 changes: 2 additions & 0 deletions services/assistant-ui/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ COPY libs/vue-common/ libs/vue-common/
COPY services/assistant-ui/ services/assistant-ui/
ARG VITE_AUTH_URL=https://auth.jorisjonkers.dev
ARG VITE_FARO_URL=https://faro.jorisjonkers.dev/collect
ARG VITE_GITHUB_APP_SLUG=personal-stack-agents
RUN VITE_AUTH_URL=${VITE_AUTH_URL} \
VITE_FARO_URL=${VITE_FARO_URL} \
VITE_GITHUB_APP_SLUG=${VITE_GITHUB_APP_SLUG} \
pnpm --filter @personal-stack/assistant-ui build

FROM nginx:alpine
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { DOMWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import GitHubAppReference from '../components/GitHubAppReference.vue'
import { GITHUB_APP_SLUG_ENV_KEY } from '../services/githubAppLinks'

function expectSafeNewTab(link: Omit<DOMWrapper<Element>, 'exists'>, href: string): void {
expect(link.attributes('href')).toBe(href)
expect(link.attributes('target')).toBe('_blank')
expect(link.attributes('rel')).toBe('noopener noreferrer')
}

describe('gitHubAppReference', () => {
afterEach(() => {
vi.unstubAllEnvs()
})

it('renders canonical GitHub App links with safe new-tab attributes', () => {
vi.stubEnv(GITHUB_APP_SLUG_ENV_KEY, 'custom-agents-app')
const wrapper = mount(GitHubAppReference)

expectSafeNewTab(wrapper.get('[data-testid="github-app-public-link"]'), 'https://github.com/apps/custom-agents-app')
expectSafeNewTab(
wrapper.get('[data-testid="github-app-install-link"]'),
'https://github.com/apps/custom-agents-app/installations/new',
)
expectSafeNewTab(
wrapper.get('[data-testid="github-app-owner-permissions-link"]'),
'https://github.com/settings/apps/custom-agents-app/permissions',
)
expectSafeNewTab(
wrapper.get('[data-testid="github-app-owner-installations-link"]'),
'https://github.com/settings/apps/custom-agents-app/installations',
)
})

it('summarizes requested permissions and installation approval', () => {
const wrapper = mount(GitHubAppReference)

expect(wrapper.get('[data-testid="github-app-permission-contents"]').text()).toBe('Contents: write')
expect(wrapper.get('[data-testid="github-app-permission-pull_requests"]').text()).toBe('Pull requests: write')
expect(wrapper.get('[data-testid="github-app-permission-actions"]').text()).toBe('Actions: write')
expect(wrapper.get('[data-testid="github-app-permission-approval-note"]').text()).toContain(
'Permission changes must be approved on each existing installation',
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Repository } from '../types'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import RepositoriesView from '../views/RepositoriesView.vue'

const listRepositories = vi.fn<() => Promise<Repository[]>>()

vi.mock('../services/repositoriesService', () => ({
listRepositories: () => listRepositories(),
getRepository: vi.fn(),
createRepository: vi.fn(),
attachDeployKey: vi.fn(),
deleteRepository: vi.fn(),
verifyRepositoryAccess: vi.fn(),
}))

const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/repositories', component: RepositoriesView },
{ path: '/repositories/:id', component: { template: '<div />' } },
],
})

describe('repositoriesView', () => {
beforeEach(async () => {
setActivePinia(createPinia())
listRepositories.mockReset()
await router.push('/repositories')
await router.isReady()
})

it('places the GitHub App reference below the header and above loading state', async () => {
listRepositories.mockReturnValue(new Promise(() => {}))

const wrapper = mount(RepositoriesView, { global: { plugins: [router] } })
await wrapper.vm.$nextTick()

const header = wrapper.get('header')
const reference = wrapper.get('[data-testid="github-app-reference"]')
const loading = wrapper.findAll('div').find((node) => node.text().startsWith('Loading'))
if (loading === undefined) throw new Error('missing loading state')

expect(header.element.compareDocumentPosition(reference.element)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(reference.element.compareDocumentPosition(loading.element)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(reference.get('[data-testid="github-app-public-link"]').attributes('href')).toBe(
'https://github.com/apps/personal-stack-agents',
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,32 @@ describe('repositoryView access status', () => {
expect(wrapper.find('[data-testid="repository-protection-unknown"]').exists()).toBe(true)
})

it('renders GitHub App owner guidance and canonical external links', async () => {
getRepository.mockResolvedValue(detail({ repository: fakeRepo({ repoUrl: 'git@github.com:ExtraToast/demo.git' }) }))
const wrapper = await mountView()

expect(wrapper.find('[data-testid="github-app-owner"]').text()).toBe('ExtraToast')
expect(wrapper.find('[data-testid="github-app-permissions"]').text()).toMatch(/Contents:\s+write/)
expect(wrapper.find('[data-testid="github-app-permissions"]').text()).toMatch(/Pull requests:\s+write/)
expect(wrapper.find('[data-testid="github-app-permissions"]').text()).toMatch(/Actions:\s+write/)
expect(wrapper.find('[data-testid="github-app-approval-note"]').text()).toContain('approval on each installation')

const expectedLinks = {
'github-app-install-link': 'https://github.com/apps/personal-stack-agents/installations/new?state=ExtraToast',
'github-app-user-installations-link': 'https://github.com/settings/installations',
'github-app-organization-installations-link':
'https://github.com/organizations/ExtraToast/settings/installations',
'github-app-permissions-link': 'https://github.com/settings/apps/personal-stack-agents/permissions',
}

for (const [testId, href] of Object.entries(expectedLinks)) {
const link = wrapper.find(`[data-testid="${testId}"]`)
expect(link.attributes('href')).toBe(href)
expect(link.attributes('target')).toBe('_blank')
expect(link.attributes('rel')).toBe('noopener noreferrer')
}
})

it('verify-access button calls the verify endpoint and stores the result', async () => {
getRepository.mockResolvedValue(detail())
verifyRepositoryAccess.mockResolvedValue({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildGitHubAppInstallationUrl,
buildGitHubAppInstallationUrlForRepo,
DEFAULT_GITHUB_APP_SLUG,
GITHUB_APP_REQUESTED_PERMISSIONS,
GITHUB_APP_SLUG_ENV_KEY,
githubAppLinks,
isValidGitHubAppSlug,
isValidGitHubOwner,
parseGitHubRepositoryOwner,
parseGitHubRepositoryUrl,
resolveGitHubAppSlug,
} from '../services/githubAppLinks'

describe('githubAppLinks', () => {
afterEach(() => {
vi.unstubAllEnvs()
})

it.each([
['git@github.com:ExtraToast/personal-stack.git', 'ExtraToast', 'personal-stack'],
['git@github.com:ExtraToast/personal-stack', 'ExtraToast', 'personal-stack'],
['ssh://git@github.com/ExtraToast/personal-stack.git', 'ExtraToast', 'personal-stack'],
['https://github.com/ExtraToast/personal-stack.git', 'ExtraToast', 'personal-stack'],
['https://github.com/ExtraToast/personal-stack', 'ExtraToast', 'personal-stack'],
['https://github.com/ExtraToast/personal-stack/', 'ExtraToast', 'personal-stack'],
['https://x-access-token:tok@github.com/ExtraToast/repo.git', 'ExtraToast', 'repo'],
])('parses owner and repo from %s', (repoUrl, owner, repo) => {
expect(parseGitHubRepositoryUrl(repoUrl)).toEqual({ owner, repo })
expect(parseGitHubRepositoryOwner(repoUrl)).toBe(owner)
})

it.each([
'not-a-url',
'git@github.com:onlyowner',
'https://github.com/onlyowner',
'https://github.com/owner/repo/issues',
'',
])('returns null for invalid repo URL format %s', (repoUrl) => {
expect(parseGitHubRepositoryUrl(repoUrl)).toBeNull()
expect(parseGitHubRepositoryOwner(repoUrl)).toBeNull()
})

it.each([
{ owner: 'ExtraToast', expected: true },
{ owner: 'octo-org-1', expected: true },
{ owner: 'a', expected: true },
{ owner: '', expected: false },
{ owner: '-octo', expected: false },
{ owner: 'octo-', expected: false },
{ owner: 'octo--org', expected: false },
{ owner: 'octo/org', expected: false },
{ owner: 'octo org', expected: false },
{ owner: 'abcdefghijklmnopqrstuvwxyz0123456789abcd', expected: false },
])('validates owner $owner as $expected', ({ owner, expected }) => {
expect(isValidGitHubOwner(owner)).toBe(expected)
})

it('rejects repo URLs with invalid owners', () => {
expect(parseGitHubRepositoryUrl('git@github.com:-octo/repo.git')).toBeNull()
expect(parseGitHubRepositoryUrl('https://github.com/octo--org/repo')).toBeNull()
})

it.each([
{ slug: 'personal-stack-agents', expected: true },
{ slug: 'custom-app-1', expected: true },
{ slug: 'a', expected: true },
{ slug: '', expected: false },
{ slug: 'Custom-App', expected: false },
{ slug: 'custom_app', expected: false },
{ slug: '-custom-app', expected: false },
{ slug: 'custom-app-', expected: false },
])('validates app slug $slug as $expected', ({ slug, expected }) => {
expect(isValidGitHubAppSlug(slug)).toBe(expected)
})

it('falls back to the default slug when no env or override is configured', () => {
expect(resolveGitHubAppSlug()).toBe(DEFAULT_GITHUB_APP_SLUG)
})

it('uses the env slug when configured', () => {
vi.stubEnv(GITHUB_APP_SLUG_ENV_KEY, 'custom-agents-app')

expect(resolveGitHubAppSlug()).toBe('custom-agents-app')
})

it('prefers an explicit slug override over the env slug', () => {
vi.stubEnv(GITHUB_APP_SLUG_ENV_KEY, 'env-agents-app')

expect(resolveGitHubAppSlug('override-agents-app')).toBe('override-agents-app')
})

it('rejects an invalid configured slug', () => {
vi.stubEnv(GITHUB_APP_SLUG_ENV_KEY, 'Bad Slug')

expect(() => resolveGitHubAppSlug()).toThrow('Invalid GitHub App slug: Bad Slug')
expect(() => resolveGitHubAppSlug('also_bad')).toThrow('Invalid GitHub App slug: also_bad')
})

it('builds the canonical GitHub App installation URL with owner state', () => {
expect(buildGitHubAppInstallationUrl('ExtraToast')).toBe(
'https://github.com/apps/personal-stack-agents/installations/new?state=ExtraToast',
)
expect(buildGitHubAppInstallationUrlForRepo('git@github.com:ExtraToast/personal-stack.git')).toBe(
'https://github.com/apps/personal-stack-agents/installations/new?state=ExtraToast',
)
})

it('safely encodes the app slug path segment', () => {
expect(buildGitHubAppInstallationUrl('ExtraToast', 'custom-app')).toBe(
'https://github.com/apps/custom-app/installations/new?state=ExtraToast',
)
expect(() => buildGitHubAppInstallationUrl('ExtraToast', 'custom/app')).toThrow(
'Invalid GitHub App slug: custom/app',
)
})

it('rejects invalid owners before building URLs', () => {
expect(() => buildGitHubAppInstallationUrl('octo/org')).toThrow('Invalid GitHub owner: octo/org')
expect(buildGitHubAppInstallationUrlForRepo('not-a-url')).toBeNull()
})

it('mirrors the backend requested permissions without administration', () => {
expect(GITHUB_APP_REQUESTED_PERMISSIONS).toEqual([
{ key: 'contents', access: 'write', label: 'Contents' },
{ key: 'pull_requests', access: 'write', label: 'Pull requests' },
{ key: 'actions', access: 'write', label: 'Actions' },
])
expect(GITHUB_APP_REQUESTED_PERMISSIONS.map((permission) => permission.key)).not.toContain('administration')
expect(Object.isFrozen(GITHUB_APP_REQUESTED_PERMISSIONS)).toBe(true)
expect(Object.isFrozen(githubAppLinks)).toBe(true)
})
})
Loading
Loading