diff --git a/.prettierignore b/.prettierignore index eb74930f..0f14cd10 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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/ diff --git a/services/assistant-ui/Dockerfile b/services/assistant-ui/Dockerfile index efc06b36..3bf7acaa 100644 --- a/services/assistant-ui/Dockerfile +++ b/services/assistant-ui/Dockerfile @@ -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 diff --git a/services/assistant-ui/src/features/repositories/__tests__/GitHubAppReference.test.ts b/services/assistant-ui/src/features/repositories/__tests__/GitHubAppReference.test.ts new file mode 100644 index 00000000..88550e39 --- /dev/null +++ b/services/assistant-ui/src/features/repositories/__tests__/GitHubAppReference.test.ts @@ -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, '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', + ) + }) +}) diff --git a/services/assistant-ui/src/features/repositories/__tests__/RepositoriesView.test.ts b/services/assistant-ui/src/features/repositories/__tests__/RepositoriesView.test.ts new file mode 100644 index 00000000..72bd59b0 --- /dev/null +++ b/services/assistant-ui/src/features/repositories/__tests__/RepositoriesView.test.ts @@ -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>() + +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: '
' } }, + ], +}) + +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', + ) + }) +}) diff --git a/services/assistant-ui/src/features/repositories/__tests__/RepositoryView.test.ts b/services/assistant-ui/src/features/repositories/__tests__/RepositoryView.test.ts index 860130a4..8cf26d60 100644 --- a/services/assistant-ui/src/features/repositories/__tests__/RepositoryView.test.ts +++ b/services/assistant-ui/src/features/repositories/__tests__/RepositoryView.test.ts @@ -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({ diff --git a/services/assistant-ui/src/features/repositories/__tests__/githubAppLinks.test.ts b/services/assistant-ui/src/features/repositories/__tests__/githubAppLinks.test.ts new file mode 100644 index 00000000..1661c631 --- /dev/null +++ b/services/assistant-ui/src/features/repositories/__tests__/githubAppLinks.test.ts @@ -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) + }) +}) diff --git a/services/assistant-ui/src/features/repositories/components/GitHubAppPanel.vue b/services/assistant-ui/src/features/repositories/components/GitHubAppPanel.vue new file mode 100644 index 00000000..1a8a877b --- /dev/null +++ b/services/assistant-ui/src/features/repositories/components/GitHubAppPanel.vue @@ -0,0 +1,109 @@ + + + diff --git a/services/assistant-ui/src/features/repositories/components/GitHubAppReference.vue b/services/assistant-ui/src/features/repositories/components/GitHubAppReference.vue new file mode 100644 index 00000000..14122737 --- /dev/null +++ b/services/assistant-ui/src/features/repositories/components/GitHubAppReference.vue @@ -0,0 +1,91 @@ + + + diff --git a/services/assistant-ui/src/features/repositories/services/githubAppLinks.ts b/services/assistant-ui/src/features/repositories/services/githubAppLinks.ts new file mode 100644 index 00000000..137b85b9 --- /dev/null +++ b/services/assistant-ui/src/features/repositories/services/githubAppLinks.ts @@ -0,0 +1,117 @@ +export interface GitHubRepositorySlug { + readonly owner: string + readonly repo: string +} + +export interface GitHubAppRequestedPermission { + readonly key: string + readonly access: string + readonly label: string +} + +export interface GitHubAppLinksApi { + readonly requestedPermissions: ReadonlyArray + readonly isValidGitHubAppSlug: (slug: string) => boolean + readonly resolveGitHubAppSlug: (override?: string | null) => string + readonly isValidGitHubOwner: (owner: string) => boolean + readonly parseGitHubRepositoryUrl: (repoUrl: string) => GitHubRepositorySlug | null + readonly parseGitHubRepositoryOwner: (repoUrl: string) => string | null + readonly buildGitHubAppInstallationUrl: (owner: string, appSlug?: string | null) => string + readonly buildGitHubAppInstallationUrlForRepo: (repoUrl: string, appSlug?: string | null) => string | null +} + +export const DEFAULT_GITHUB_APP_SLUG = 'personal-stack-agents' +export const GITHUB_APP_SLUG_ENV_KEY = 'VITE_GITHUB_APP_SLUG' + +const GITHUB_BASE_URL = 'https://github.com' +const GITHUB_APP_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/ +const GITHUB_OWNER_PATTERN = /^(?!.*--)[A-Z0-9](?:[A-Z0-9-]{0,37}[A-Z0-9])?$/i +const SCP_LIKE_REPO_URL = /^[^@]+@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?\/?$/ +const URL_LIKE_REPO_URL = /^[a-zA-Z]+:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/ + +function permission(key: string, access: string, label: string): GitHubAppRequestedPermission { + return Object.freeze({ key, access, label }) +} + +export const GITHUB_APP_REQUESTED_PERMISSIONS: ReadonlyArray = Object.freeze([ + permission('contents', 'write', 'Contents'), + permission('pull_requests', 'write', 'Pull requests'), + permission('actions', 'write', 'Actions'), +]) + +export function isValidGitHubAppSlug(slug: string): boolean { + return GITHUB_APP_SLUG_PATTERN.test(slug.trim()) +} + +export function resolveGitHubAppSlug(override?: string | null): string { + const candidate = firstConfiguredSlug(override, import.meta.env[GITHUB_APP_SLUG_ENV_KEY], DEFAULT_GITHUB_APP_SLUG) + if (!isValidGitHubAppSlug(candidate)) { + throw new Error(`Invalid GitHub App slug: ${candidate}`) + } + return candidate +} + +export function isValidGitHubOwner(owner: string): boolean { + return GITHUB_OWNER_PATTERN.test(owner.trim()) +} + +export function parseGitHubRepositoryUrl(repoUrl: string): GitHubRepositorySlug | null { + const trimmed = repoUrl.trim() + const match = SCP_LIKE_REPO_URL.exec(trimmed) ?? URL_LIKE_REPO_URL.exec(trimmed) + if (match === null) { + return null + } + const owner = match[1]?.trim() + const repo = match[2]?.trim() + if (owner === undefined || repo === undefined || owner === '' || repo === '' || !isValidGitHubOwner(owner)) { + return null + } + return Object.freeze({ owner, repo }) +} + +export function parseGitHubRepositoryOwner(repoUrl: string): string | null { + return parseGitHubRepositoryUrl(repoUrl)?.owner ?? null +} + +export function buildGitHubAppInstallationUrl(owner: string, appSlug?: string | null): string { + const trimmedOwner = owner.trim() + if (!isValidGitHubOwner(trimmedOwner)) { + throw new Error(`Invalid GitHub owner: ${owner}`) + } + const encodedSlug = encodeURIComponent(resolveGitHubAppSlug(appSlug)) + const url = new URL(`${GITHUB_BASE_URL}/apps/${encodedSlug}/installations/new`) + url.searchParams.set('state', trimmedOwner) + return url.toString() +} + +export function buildGitHubAppInstallationUrlForRepo(repoUrl: string, appSlug?: string | null): string | null { + const owner = parseGitHubRepositoryOwner(repoUrl) + if (owner === null) { + return null + } + return buildGitHubAppInstallationUrl(owner, appSlug) +} + +function firstConfiguredSlug( + override: string | null | undefined, + env: string | boolean | undefined, + fallback: string, +): string { + const overrideSlug = override?.trim() + if (overrideSlug !== undefined && overrideSlug !== '') { + return overrideSlug + } + const envSlug = typeof env === 'string' ? env.trim() : '' + return envSlug === '' ? fallback : envSlug +} + +export const githubAppLinks: GitHubAppLinksApi = Object.freeze({ + requestedPermissions: GITHUB_APP_REQUESTED_PERMISSIONS, + isValidGitHubAppSlug, + resolveGitHubAppSlug, + isValidGitHubOwner, + parseGitHubRepositoryUrl, + parseGitHubRepositoryOwner, + buildGitHubAppInstallationUrl, + buildGitHubAppInstallationUrlForRepo, +}) diff --git a/services/assistant-ui/src/features/repositories/views/RepositoriesView.vue b/services/assistant-ui/src/features/repositories/views/RepositoriesView.vue index 8575bc1d..3923e3d4 100644 --- a/services/assistant-ui/src/features/repositories/views/RepositoriesView.vue +++ b/services/assistant-ui/src/features/repositories/views/RepositoriesView.vue @@ -4,6 +4,7 @@ import { Card, Modal, useToast } from '@personal-stack/vue-common' import { onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import CreateRepositoryForm from '../components/CreateRepositoryForm.vue' +import GitHubAppReference from '../components/GitHubAppReference.vue' import { useRepositoriesStore } from '../stores/repositories' const store = useRepositoriesStore() @@ -52,6 +53,8 @@ async function onCreateSuccess(created: Repository): Promise { + +

{{ store.error }}

Loading…
diff --git a/services/assistant-ui/src/features/repositories/views/RepositoryView.vue b/services/assistant-ui/src/features/repositories/views/RepositoryView.vue index f3efd861..d40417ec 100644 --- a/services/assistant-ui/src/features/repositories/views/RepositoryView.vue +++ b/services/assistant-ui/src/features/repositories/views/RepositoryView.vue @@ -5,6 +5,7 @@ import { computed, onMounted, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import AccessStatusBadge from '../components/AccessStatusBadge.vue' import AttachKeyWizard from '../components/AttachKeyWizard.vue' +import GitHubAppPanel from '../components/GitHubAppPanel.vue' import { useRepositoriesStore } from '../stores/repositories' const route = useRoute() @@ -129,6 +130,8 @@ async function onDestroy(): Promise {
+ +

Access