diff --git a/doc/DATABASE.md b/doc/DATABASE.md index f92ec1d1b45..d588e082360 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -788,6 +788,12 @@ source identity even if it currently resolves to that default branch. API-key agents without an authenticated run may attach repositories; their durable import jobs have no setup person and require adoption by an authorized person. +Independent legacy sources with a missing branch can explicitly switch to `HEAD` +through source settings. Validate the repository identity and default-branch read +before changing the ref; preserve installed skill IDs, versions and selection. Active +project bindings must change their ref through the project configuration. `HEAD` +remains the default-branch alias after refresh, including for independent sources. + ### Task workspace records (2026-10-09) Migration [0336_big_crystal.sql](../packages/db/src/migrations/0336_big_crystal.sql) makes `execution_workspaces.project_id` nullable and changes its project reference to `ON DELETE RESTRICT`: source-project policy cannot disappear while a workspace still depends on it. `issues.execution_workspace_id` remains the sole active task binding. Versioned selection/pending-selection JSON and a binding revision record intent and protect transitions; they are not additional active bindings. Accepting a new pending choice advances the revision without changing the active folder. Its receipt retains the original request revision, so current-choice retries are idempotent and superseded retries fail the revision check. diff --git a/maps/agent-access-map/skills.md b/maps/agent-access-map/skills.md index 5f2b862ecf6..15f2d881b80 100644 --- a/maps/agent-access-map/skills.md +++ b/maps/agent-access-map/skills.md @@ -146,7 +146,10 @@ admitted skill-test JWT scope. Project-source API additions: GET `/api/companies/{companyId}/skill-sources/project-repositories`, POST `/api/companies/{companyId}/skill-sources/adopt-project` with `workspaceId`, and PATCH `/api/companies/{companyId}/skill-sources/{sourceId}/settings` with expected revision -and update/connection preferences. Native API discovery uses the OpenAPI registration. +and update/connection preferences. `trackingRef: "HEAD"` explicitly restores an independent +source's default-branch tracking; active project bindings must be edited through their +project configuration. Responses identify branch, connection, or retry repair and distinguish +queued imports from imports under an active worker lease. Native API discovery uses the OpenAPI registration. Project-derived read/use permission follows current project authorization, with access through either originating project. File/version reads, source metadata, mutations, diff --git a/maps/feature-map/skills.md b/maps/feature-map/skills.md index 91a1d5d7c3a..77d3433a604 100644 --- a/maps/feature-map/skills.md +++ b/maps/feature-map/skills.md @@ -70,6 +70,7 @@ Manual: Enable the skill for one test agent and leave another without access. Ru New GitHub project repositories import skills automatically. Skills → Sources lists accessible legacy repositories with Import skills, shows source/project provenance, +groups matching repository/ref workspaces into one row, and shows queued/importing feedback, and provides Refresh and Automatic updates in its source menu. Verify future additions, retained exclusions, error recovery and reload persistence. Private-only sources must be absent for unrelated people/agents; access through either originating project is sufficient. @@ -79,6 +80,11 @@ must be verified separately; inspect resolved version IDs across an upstream upd ### Connection repair during an update +A missing tracked branch is a branch repair, not a credential failure. On an independent +source, choose Use default branch explicitly; installed identities, selections and version +pins remain. Project-tracked branches are changed in project settings. A saved connection +is validated against both the repository and tracked revision before repair succeeds. + Open Choose connection while a source is being refreshed. Select an authorized account and save. If the source changes, the dialog must retain that selection, load the current revision, and allow a second save without reopening. The [project repository browser suite](../../tests/storybook-visual/project-repository-skills.spec.ts) covers this recovery with simulated upstream data. ### Storybook verification diff --git a/packages/shared/src/types/skill-source.ts b/packages/shared/src/types/skill-source.ts index 87b64ff95c4..7714ff33ac8 100644 --- a/packages/shared/src/types/skill-source.ts +++ b/packages/shared/src/types/skill-source.ts @@ -78,6 +78,7 @@ export interface SkillSource { lastSuccessAt: Date | null; lastScanCommit: string | null; lastError: string | null; + repairAction?: 'connection' | 'branch' | 'retry' | null; entries: SkillSourceEntry[]; } export interface SkillSourceSettingsRequest { @@ -85,6 +86,8 @@ export interface SkillSourceSettingsRequest { automaticUpdates?: boolean; importNewSkills?: boolean; connectionId?: string | null; + /** Explicitly resume following the default branch; existing version pins remain intact. */ + trackingRef?: 'HEAD'; } export interface ProjectSkillRepository { projectId: string; @@ -96,6 +99,7 @@ export interface ProjectSkillRepository { bindingId: string | null; sourceId: string | null; status: 'available' | 'pending' | 'ready' | 'error'; + importState?: 'queued' | 'importing'; lastError: string | null; } export interface SkillSourceDiscoveryRequest { diff --git a/packages/shared/src/validators/skill-source.ts b/packages/shared/src/validators/skill-source.ts index bd441270fcb..a1355aae3f0 100644 --- a/packages/shared/src/validators/skill-source.ts +++ b/packages/shared/src/validators/skill-source.ts @@ -30,6 +30,7 @@ export const skillSourceSettingsSchema = z.object({ automaticUpdates: z.boolean().optional(), importNewSkills: z.boolean().optional(), connectionId: z.string().uuid().nullable().optional(), + trackingRef: z.literal('HEAD').optional(), }); export const projectSkillSourceAdoptSchema = z.object({ workspaceId: z.string().uuid(), diff --git a/server/src/__tests__/project-skill-sources.test.ts b/server/src/__tests__/project-skill-sources.test.ts index fcaaf791a0b..9f0c2fa60ef 100644 --- a/server/src/__tests__/project-skill-sources.test.ts +++ b/server/src/__tests__/project-skill-sources.test.ts @@ -43,6 +43,40 @@ describe.skipIf(!support.supported)('automatic project skill sources', () => { process.env.PAPERCLIP_HOME = home; await db.insert(companies).values({ id: companyId, name: 'Project skills', issuePrefix: 'PSK' }); }, 30000); + it('rechecks the locked source ref when branch repair races with project attachment', async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: 'Branch repair race', issuePrefix: 'RCE' }); + const content = { 'race/SKILL.md': md('race') }; + const sourceContext = { ...context, read: () => githubFixture(content) }; + const service = skillSourceService(db); + const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'configured-branch', + commitSha: 'a'.repeat(40), selectedPaths: ['race/SKILL.md'] }, sourceContext); + const [project] = await db.insert(projects).values({ companyId, name: 'Configured branch' }).returning(); + const [row] = await db.insert(projectWorkspaces).values({ companyId, projectId: project!.id, name: 'Repository', + repoUrl: created.source.repositoryUrl, defaultRef: 'configured-branch' }).returning(); + await enqueueProjectSkillSource(db, row!, { refreshUserId: null, refreshAgentId: null, refreshLocalTrusted: true, connectionId: null, refreshGrantId: null }); + let importChecks = 0; + const worker = skillSourceSyncService(db, { context: async () => ({ ...sourceContext, authorize: async action => { + // The second authorization occurs after matching the existing source, + // before the worker locks it for attachment. + if (action === 'skills.import' && ++importChecks === 2) await service.settings(companyId, created.source.id, + { revision: created.source.revision, trackingRef: 'HEAD' }, sourceContext); + } }) }); + try { + await worker.tick(); + const [blocked] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row!.id)); + expect(blocked!.sourceId).toBeNull(); + expect(blocked!.status).toBe('error'); + expect(blocked!.lastError).toContain('branch changed'); + await worker.tick(new Date(Date.now() + 6 * 60_000)); + const [attached] = await db.select().from(projectSkillSources).where(eq(projectSkillSources.workspaceId, row!.id)); + expect(attached!.sourceId).not.toBe(created.source.id); + expect((await service.detail(companyId, attached!.sourceId!)).trackingRef).toBe('configured-branch'); + } finally { + await worker.stop(); + await db.update(companies).set({ status: 'archived' }).where(eq(companies.id, companyId)); + } + }); it('requires explicit repair before an agent-created repository can search a person’s GitHub grants', async () => { const row = await workspace('agent-repair', 'open'); const binding = await enqueueProjectSkillSource(db, row); diff --git a/server/src/__tests__/skill-source-github-access.test.ts b/server/src/__tests__/skill-source-github-access.test.ts index 2292aed8013..8d3d4ae3780 100644 --- a/server/src/__tests__/skill-source-github-access.test.ts +++ b/server/src/__tests__/skill-source-github-access.test.ts @@ -14,6 +14,14 @@ const db = {} as Db; beforeEach(() => { mocks.grantIds.mockResolvedValue(['grant']); mocks.connectionIds.mockResolvedValue([]); }); afterEach(() => { vi.unstubAllGlobals(); vi.resetAllMocks(); }); describe('GitHub source authorization', () => { + it('identifies a missing tracked branch instead of suggesting another connection', async () => { + mocks.headers.mockResolvedValue({ Authorization: 'Bearer allowed' }); + const fetch = vi.fn().mockResolvedValue(new Response('', { status: 422 })); + vi.stubGlobal('fetch', fetch); + await expect(skillSourceGitHubReader(db, 'missing-ref-company', actor({ type: 'board', userId: 'alice' }), 'connection')('/repos/acme/private/commits/main')) + .rejects.toMatchObject({ details: { code: 'skill_source_ref_unavailable' }, message: expect.stringContaining('branch or revision') }); + expect(fetch).toHaveBeenCalledTimes(1); + }); it('uses one lease across metadata, snapshot and auditing, then permits another complete scan', async () => { const fixture = githubFixture({ 'SKILL.md': '---\nname: example\ndescription: Useful instructions\n---\nRead the document.' }); const fetch = vi.fn(async (url: string) => Response.json(await fixture(new URL(url).pathname))); diff --git a/server/src/__tests__/skill-sources.test.ts b/server/src/__tests__/skill-sources.test.ts index f8789d86787..026ce8519cb 100644 --- a/server/src/__tests__/skill-sources.test.ts +++ b/server/src/__tests__/skill-sources.test.ts @@ -37,6 +37,30 @@ describe.skipIf(!support.supported)('skill source persistence', () => { if (originalHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = originalHome; await testDb?.cleanup(); if (home) await fs.rm(home, { recursive: true, force: true }); }); + it('explicitly repairs a missing branch while retaining connection, selection and installed versions', async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: 'Branch repair', issuePrefix: 'BRP' }); + const service = skillSourceService(db); + const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'missing-branch', commitSha: sha, selectedPaths: ['deep/one/SKILL.md'] }, context); + const reader = githubFixture(initial); + const read = vi.fn(reader); + Object.assign(read, { openSnapshot: reader.openSnapshot }); + const failedRead = Object.assign(async (request: string) => { + if (request.endsWith('/commits/missing-branch')) throw unprocessable('The tracked GitHub branch or revision is unavailable.', { code: 'skill_source_ref_unavailable' }); + return reader(request); + }, { openSnapshot: reader.openSnapshot }); + await expect(service.settings(companyId, created.source.id, { revision: created.source.revision, connectionId: randomUUID() }, { ...context, read: () => failedRead })) + .rejects.toMatchObject({ details: { code: 'skill_source_ref_unavailable' } }); + expect((await service.detail(companyId, created.source.id)).connectionId).toBe(created.source.connectionId); + const repaired = await service.settings(companyId, created.source.id, { revision: created.source.revision, trackingRef: 'HEAD' }, { ...context, read: () => read }); + expect(repaired.trackingRef).toBe('HEAD'); + expect(repaired.entries).toEqual(created.source.entries); + expect(read).toHaveBeenCalledWith('/repos/acme/skills'); + expect(read).toHaveBeenCalledWith('/repos/acme/skills/commits/main'); + const refreshed = await service.refresh(companyId, repaired.id, context); + expect(refreshed.source.trackingRef).toBe('HEAD'); + expect(refreshed.source.entries[0]?.skillId).toBe(created.source.entries[0]?.skillId); + }); it('checks a new commit with an unchanged tree without downloading or replacing versions', async () => { const originalFiles = files, originalCommit = commit; try { @@ -175,7 +199,7 @@ describe.skipIf(!support.supported)('skill source persistence', () => { await expect(skillSourceService(db).create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'rollback', commitSha: sha, selectedPaths: ['SKILL.md'] }, { ...context, audit: async () => { throw new Error('audit unavailable'); } })).rejects.toThrow('audit unavailable'); expect(await db.select().from(companySkills).where(eq(companySkills.companyId, companyId))).toHaveLength(before.length); expect((await skillSourceService(db).list(companyId)).some(source => source.trackingRef === 'rollback')).toBe(false); - expect((await db.select().from(companySkillVersions)).every(version => before.some(skill => skill.id === version.companySkillId))).toBe(true); + expect((await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, companyId))).every(version => before.some(skill => skill.id === version.companySkillId))).toBe(true); }); it('retains excluded folders, reviews additions, restores removed paths and preserves pinned versions and copies', async () => { const service = skillSourceService(db), skills = companySkillService(db); @@ -331,7 +355,7 @@ describe.skipIf(!support.supported)('skill source persistence', () => { return read(request); }, { openSnapshot: read.openSnapshot }) }); expect(refreshed.imported[0]).toMatchObject({ id: legacy!.id, sourceRef: commit }); - expect((await service.sourceForSkill(legacyCompany, legacy!.id))?.trackingRef).toBe('main'); + expect((await service.sourceForSkill(legacyCompany, legacy!.id))?.trackingRef).toBe('HEAD'); expect(await service.list(legacyCompany)).toHaveLength(2); }); diff --git a/server/src/services/skill-source-github-access.ts b/server/src/services/skill-source-github-access.ts index 1388245b7ea..89a65064bda 100644 --- a/server/src/services/skill-source-github-access.ts +++ b/server/src/services/skill-source-github-access.ts @@ -99,7 +99,9 @@ export function skillSourceGitHubReader(db: Db, companyId: string, actor: Reques }); if (!response.ok) { const limited = response.status === 429 || (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0'); - const message = limited ? 'GitHub request limit reached. Try again after the limit resets or use another GitHub connection.' + const missingRef = response.status === 422 && /^\/repos\/[^/]+\/[^/]+\/commits\/[^/]+$/.test(apiPath); + const message = missingRef ? 'The tracked GitHub branch or revision is unavailable. Use the repository’s default branch or update its configured ref.' + : limited ? 'GitHub request limit reached. Try again after the limit resets or use another GitHub connection.' : response.status === 404 ? 'Repository, branch, or file is unavailable. Check the URL and GitHub repository access.' : [401, 403].includes(response.status) ? 'GitHub denied repository access. Reconnect GitHub or choose an authorized connection.' : 'GitHub could not complete the read. Try again.'; @@ -108,7 +110,7 @@ export function skillSourceGitHubReader(db: Db, companyId: string, actor: Reques const retryAfterMs = retryAfter && /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1_000 : retryAfter ? Date.parse(retryAfter) - Date.now() : resetAt - Date.now(); - throw unprocessable(message, { code: limited ? 'skill_source_github_rate_limited' : 'skill_source_github_read_failed', status: response.status, + throw unprocessable(message, { code: missingRef ? 'skill_source_ref_unavailable' : limited ? 'skill_source_github_rate_limited' : 'skill_source_github_read_failed', status: response.status, ...(limited && Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? { retryAfterMs } : {}) }); } return response.json(); diff --git a/server/src/services/skill-source-problems.ts b/server/src/services/skill-source-problems.ts new file mode 100644 index 00000000000..7acb375a5f2 --- /dev/null +++ b/server/src/services/skill-source-problems.ts @@ -0,0 +1,7 @@ +/** Legacy sources retain safe error text rather than provider response bodies. */ +export function skillSourceRepairAction(error: string | null): 'connection' | 'branch' | 'retry' | null { + if (!error) return null; + if (error.startsWith('The tracked GitHub branch or revision is unavailable.')) return 'branch'; + if (/^(Choose a GitHub connection|Reconnect|GitHub denied repository access|GitHub connection is unavailable|Choose an active GitHub authorization|Private GitHub skills require|This run cannot use|The person who connected|The agent who connected|Project access changed)/.test(error)) return 'connection'; + return 'retry'; +} diff --git a/server/src/services/skill-source-sync.ts b/server/src/services/skill-source-sync.ts index 3cb094deaeb..3144a0de458 100644 --- a/server/src/services/skill-source-sync.ts +++ b/server/src/services/skill-source-sync.ts @@ -42,7 +42,8 @@ export async function projectSkillRepositories(db: Db, companyId: string, actor: const binding = bindings.find(item => (item.active || (!item.sourceId && item.lastError !== null)) && item.workspaceId === workspace.id && item.repositoryUrl === repo.repositoryUrl && item.trackingRef === repo.trackingRef); result.push({ projectId: project.id, projectName: project.name, projectVisibility: project.visibility as 'open' | 'private', workspaceId: workspace.id, ...repo, bindingId: binding?.id ?? null, sourceId: binding?.sourceId && !removed.has(binding.sourceId) ? binding.sourceId : null, - status: binding?.sourceId && removed.has(binding.sourceId) ? 'available' : binding?.status ?? 'available', lastError: binding?.lastError ?? null }); + status: binding?.sourceId && removed.has(binding.sourceId) ? 'available' : binding?.status ?? 'available', lastError: binding?.lastError ?? null, + ...(binding?.status === 'pending' ? { importState: binding.leaseToken && binding.leaseExpiresAt && binding.leaseExpiresAt > new Date() ? 'importing' as const : 'queued' as const } : {}) }); } return result; } @@ -223,6 +224,10 @@ export function skillSourceSyncService(db: Db, options: { context?: (setup: Setu const [source] = await tx.select().from(companySkillSources).where(and( eq(companySkillSources.companyId, binding.companyId), eq(companySkillSources.id, sourceId))).for('update'); if (!source || source.removedAt) throw conflict('Source was removed during import. Retry to start independent tracking.'); + if (source.trackingRef !== binding.trackingRef || source.repositoryUrl.toLowerCase() !== scan.repositoryUrl.toLowerCase() + || (source.repositoryId && source.repositoryId !== scan.repositoryId)) { + throw conflict('Source repository or branch changed during import. Retry from Skills.'); + } const linked = await tx.update(projectSkillSources).set({ sourceId, status: 'ready', lastError: null, failureCount: 0, leaseToken: null, leaseExpiresAt: null }) .where(and(eq(projectSkillSources.id, binding.id), eq(projectSkillSources.companyId, binding.companyId), eq(projectSkillSources.active, true), eq(projectSkillSources.leaseToken, token))).returning({ id: projectSkillSources.id }); diff --git a/server/src/services/skill-sources.ts b/server/src/services/skill-sources.ts index bd8637bdbd5..92a1143b2cd 100644 --- a/server/src/services/skill-sources.ts +++ b/server/src/services/skill-sources.ts @@ -1,4 +1,5 @@ import { reserveAutomaticSkillWork, settleAutomaticSkillWork } from './skill-source-refresh-budget.js'; +import { skillSourceRepairAction } from './skill-source-problems.js'; import { createHash, randomUUID } from 'node:crypto'; import { skillSourceRetryAt } from './skill-source-retry.js'; import path from 'node:path'; @@ -51,7 +52,7 @@ export function skillSourceService(db: Db) { const refreshing = leaseToken && leaseExpiresAt && leaseExpiresAt > new Date(); const trackingActive = source.accessScope === 'company' || associated.some(project => project.active); const selected = await database.select().from(entries).where(and(eq(entries.companyId, companyId), eq(entries.sourceId, id))).orderBy(entries.path); - return { ...source, trackingActive, entries: selected, projects: [...visibleProjects.values()].map(project => ({ ...project, visibility: project.visibility as 'open' | 'private' })), + return { ...source, repairAction: skillSourceRepairAction(source.lastError), trackingActive, entries: selected, projects: [...visibleProjects.values()].map(project => ({ ...project, visibility: project.visibility as 'open' | 'private' })), syncState: !source.enabled || !source.automaticUpdates || !trackingActive ? 'paused' : refreshing ? 'refreshing' : source.lastError ? 'error' : source.lastSuccessAt ? 'ready' : 'pending' }; } async function list(companyId: string) { @@ -178,18 +179,10 @@ export function skillSourceService(db: Db) { if (current) await tx.update(companySkills).set({ metadata: { ...current.metadata, skillSourceId: source.id, skillSourcePath: old.path, skillSourceState: 'removed' } }).where(and(eq(companySkills.companyId, source.companyId), eq(companySkills.id, old.skillId))); } } - // Two old imports can represent distinct installed skills under HEAD and an - // explicit branch. Keep HEAD as Git's default-branch alias when resolving it - // would collide; merging their entries could discard skill IDs or selections. - const [existingBranch] = source.trackingRef === 'HEAD' ? await tx.select({ id: sources.id }).from(sources).where(and( - eq(sources.companyId, source.companyId), eq(sources.trackingRef, scan.trackingRef), - or(eq(sources.repositoryId, scan.repositoryId), eq(sources.repositoryUrl, scan.repositoryUrl)), - )) : []; - const [defaultBinding] = source.trackingRef === 'HEAD' ? await tx.select({ id: projectSkillSources.id }).from(projectSkillSources) - .where(and(eq(projectSkillSources.companyId, source.companyId), eq(projectSkillSources.sourceId, source.id), eq(projectSkillSources.trackingRef, 'HEAD'))).limit(1) : []; + // HEAD remains the default-branch alias across refreshes and branch renames. await tx.update(sources).set({ repositoryId: scan.repositoryId, repositoryUrl: scan.repositoryUrl, fullName: scan.fullName, packageMode: mode, repositoryPackage: scan.repositoryPackage ?? null, - trackingRef: existingBranch || defaultBinding || (source.accessScope === 'projects' && source.trackingRef === 'HEAD') ? 'HEAD' : scan.trackingRef, excludedFolders, lastSuccessAt: new Date(), lastScanCommit: scan.commitSha, lastScanTreeSha: scan.treeSha ?? null, lastError: null, + trackingRef: source.trackingRef === 'HEAD' ? 'HEAD' : scan.trackingRef, excludedFolders, lastSuccessAt: new Date(), lastScanCommit: scan.commitSha, lastScanTreeSha: scan.treeSha ?? null, lastError: null, revision: source.revision + 1, leaseToken: null, leaseExpiresAt: null, failureCount: 0, nextSyncAt: new Date(Date.now() + 5 * 60_000) }).where(scope(source.companyId, source.id)); await context.audit(tx, source.id, 'company.skill_source_refreshed', { commit: scan.commitSha, importedCount: imported.length, updatedCount: updated.length, unchanged, warningCount: warnings.length }); return { imported, updated, unchanged, warnings: [...new Set(warnings)] }; @@ -307,21 +300,41 @@ export function skillSourceService(db: Db) { await context.authorize('skills.edit', { sourceType: 'git', sourceLocator: source.repositoryUrl }); // Validate a replacement connection before binding unattended work to it. let reader: GitHubRead | undefined; - if (input.connectionId !== undefined) { - reader = context.read(input.connectionId); - await reader(`/repos/${source.fullName}`); + if (input.trackingRef !== undefined) { + const [binding] = await db.select({ id: projectSkillSources.id }).from(projectSkillSources) + .where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.sourceId, id), eq(projectSkillSources.active, true))).limit(1); + if (source.accessScope === 'projects' || binding) throw unprocessable('Update the repository branch in its project settings.'); + } + if (input.connectionId !== undefined || input.trackingRef !== undefined) { + reader = context.read(input.connectionId !== undefined ? input.connectionId : source.connectionId); + const repository = await reader(`/repos/${source.fullName}`) as { id: number; default_branch: string }; + if (source.repositoryId && String(repository.id) !== source.repositoryId) throw conflict('The repository at this URL has changed identity. Add it as a new source.'); + const ref = (input.trackingRef ?? source.trackingRef) === 'HEAD' ? repository.default_branch : source.trackingRef; + await reader(`/repos/${source.fullName}/commits/${encodeURIComponent(ref)}`); } return db.transaction(async tx => { + if (input.trackingRef !== undefined) { + // Attachment/removal lock the source before changing its project bindings. + await tx.select({ id: sources.id }).from(sources).where(scope(companyId, id)).for('update'); + const [binding] = await tx.select({ id: projectSkillSources.id }).from(projectSkillSources) + .where(and(eq(projectSkillSources.companyId, companyId), eq(projectSkillSources.sourceId, id), eq(projectSkillSources.active, true))).limit(1); + if (binding) throw unprocessable('Update the repository branch in its project settings.'); + const [duplicate] = await tx.select({ id: sources.id }).from(sources).where(and( + eq(sources.companyId, companyId), eq(sources.repositoryUrl, source.repositoryUrl), eq(sources.trackingRef, input.trackingRef), isNull(sources.removedAt), sql`${sources.id} <> ${id}`)); + if (duplicate) throw conflict('This repository’s default branch is already in Sources. Use that source; installed skills were kept.'); + } const [updated] = await tx.update(sources).set({ ...(input.automaticUpdates !== undefined ? { automaticUpdates: input.automaticUpdates } : {}), ...(input.importNewSkills !== undefined ? { importNewSkills: input.importNewSkills } : {}), ...(input.connectionId !== undefined ? { connectionId: reader?.connectionId ?? input.connectionId, refreshUserId: context.setup?.userId, refreshAgentId: context.setup?.agentId ?? null, refreshLocalTrusted: context.setup?.localTrusted ?? false, refreshGrantId: reader?.grantId ?? null, lastError: null } : {}), + ...(input.trackingRef !== undefined ? { trackingRef: input.trackingRef, lastError: null, failureCount: 0 } : {}), revision: sql`${sources.revision} + 1`, nextSyncAt: new Date(), }).where(and(scope(companyId, id), eq(sources.revision, input.revision), or(isNull(sources.leaseToken), lt(sources.leaseExpiresAt, new Date())))).returning(); if (!updated) throw conflict('This source is refreshing or changed. Reload before saving.'); - await context.audit(tx, id, 'company.skill_source_settings_updated', { automaticUpdates: updated.automaticUpdates, importNewSkills: updated.importNewSkills, connectionChanged: input.connectionId !== undefined }); + await context.audit(tx, id, 'company.skill_source_settings_updated', { automaticUpdates: updated.automaticUpdates, importNewSkills: updated.importNewSkills, connectionChanged: input.connectionId !== undefined, + ...(input.trackingRef !== undefined ? { previousRef: source.trackingRef, trackingRef: updated.trackingRef } : {}) }); return detail(companyId, id, tx); }); } diff --git a/tests/storybook-visual/project-repository-skills.spec.ts b/tests/storybook-visual/project-repository-skills.spec.ts index 0d02800e406..9e14a3826b1 100644 --- a/tests/storybook-visual/project-repository-skills.spec.ts +++ b/tests/storybook-visual/project-repository-skills.spec.ts @@ -2,6 +2,26 @@ import { expect, test } from '@playwright/test'; const story = (name: string) => `/iframe.html?id=skills-project-repository-skills-02-journeys--${name}&viewMode=story&globals=theme:dark`; +test('groups matching project repositories and keeps progress visible through adoption', async ({ page }) => { + await page.goto(story('duplicate-projects')); + await expect(page.getByRole('button', { name: 'Import skills', exact: true })).toHaveCount(1); + await expect(page.getByText('Acme platform, Sentry · main', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Import skills', exact: true }).click(); + await expect(page.getByRole('status')).toContainText('Starting import'); + await expect(page.getByRole('region', { name: 'acme/team-skills', exact: true }).getByText('Code review', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Import skills', exact: true })).toHaveCount(0); +}); + +test('repairs a missing branch without prompting for another connection', async ({ page }) => { + await page.goto(story('missing-branch')); + await expect(page.getByRole('button', { name: 'Choose connection', exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'Use default branch', exact: true }).click(); + await expect(page.getByRole('alert')).toHaveCount(0); + await expect(page.getByText(/Default branch · Updated/)).toBeVisible(); + await expect(page.getByRole('region', { name: 'acme/team-skills', exact: true }).getByText('Code review', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true })).toBeFocused(); +}); + test('creating a project imports repository skills without another setup flow', async ({ page }) => { await page.goto(story('create-project')); await page.getByRole('button', { name: 'Add Project', exact: true }).first().click(); @@ -141,7 +161,8 @@ test('connection repair keeps the installed skills and selections', async ({ pag const source = page.getByRole('region', { name: 'acme/team-skills', exact: true }); await source.getByRole('button', { name: 'Choose connection', exact: true }).click(); const dialog = page.getByRole('dialog'); - await expect(dialog.getByRole('button', { name: 'Save connection', exact: true })).toBeDisabled(); + await expect(dialog.getByRole('radio', { name: 'My GitHub account', exact: true })).toBeChecked(); + await expect(dialog.getByRole('button', { name: 'Save connection', exact: true })).toBeEnabled(); await dialog.getByRole('radio', { name: 'My GitHub account', exact: true }).click(); await dialog.getByRole('button', { name: 'Save connection', exact: true }).click(); await expect(dialog).toHaveCount(0); diff --git a/ui/src/components/skill-studio/ProjectSkillRepositoryRow.tsx b/ui/src/components/skill-studio/ProjectSkillRepositoryRow.tsx index 52d426b233d..e7dca5a04e7 100644 --- a/ui/src/components/skill-studio/ProjectSkillRepositoryRow.tsx +++ b/ui/src/components/skill-studio/ProjectSkillRepositoryRow.tsx @@ -2,26 +2,34 @@ import type { ProjectSkillRepository } from '@paperclipai/shared'; import { GithubIcon } from '@/components/icons/github-icon'; import { Button } from '@/components/ui/button'; import { Link } from '@/lib/router'; +import { LoaderCircle } from 'lucide-react'; -export function ProjectSkillRepositoryRow({ repository, pending, onImport }: { +export function ProjectSkillRepositoryRow({ repository, pending, backgroundPending, disabled, showRepository = true, onImport }: { repository: ProjectSkillRepository; pending?: boolean; + backgroundPending?: boolean; + disabled?: boolean; + showRepository?: boolean; onImport: () => void; }) { - const importing = pending || repository.status === 'pending'; - return
+ const actionPending = pending || repository.status === 'pending'; + const importing = actionPending || backgroundPending; + return
- + {showRepository && {repository.repositoryUrl.replace('https://github.com/', '')} - + }

{repository.projectName} · {repository.trackingRef === 'HEAD' ? 'Default branch' : repository.trackingRef}{repository.projectVisibility === 'private' ? ' · Project access' : ''}

-
+ {importing &&

{pending ? 'Starting import…' : repository.importState === 'importing' + ? 'Checking repository for skills… This can take a few minutes for large repositories.' : 'Import queued. Skills will appear here when ready.'}

} {repository.lastError &&

{repository.lastError} Manage GitHub connection

}
; } diff --git a/ui/src/components/skill-studio/SkillSourceConnectionRepair.tsx b/ui/src/components/skill-studio/SkillSourceConnectionRepair.tsx index 9de630b617d..c5163bf87c6 100644 --- a/ui/src/components/skill-studio/SkillSourceConnectionRepair.tsx +++ b/ui/src/components/skill-studio/SkillSourceConnectionRepair.tsx @@ -13,10 +13,11 @@ export function SkillSourceConnectionRepair({ companyId, source, onClose, onSave companyId: string; source: SkillSource; onClose: () => void; onSaved: () => Promise; onReturnFocus?: () => void; }) { - const [connectionId, setConnectionId] = useState(''); + const [connectionId, setConnectionId] = useState(source.connectionId ?? ''); const client = useQueryClient(); const query = useQuery({ queryKey: queryKeys.skillSources.repositories(companyId), queryFn: () => skillSourcesApi.repositories(companyId), refetchOnMount: 'always', retry: false }); - const save = useMutation({ mutationFn: () => skillSourcesApi.settings(companyId, source.id, { revision: source.revision, connectionId }), + const save = useMutation({ mutationFn: (useDefaultBranch: boolean) => skillSourcesApi.settings(companyId, source.id, { revision: source.revision, connectionId, + ...(useDefaultBranch ? { trackingRef: 'HEAD' as const } : {}) }), onError: async error => { if (error instanceof ApiError && error.status === 409) { await client.invalidateQueries({ queryKey: queryKeys.skillSources.all(companyId) }); @@ -24,6 +25,8 @@ export function SkillSourceConnectionRepair({ companyId, source, onClose, onSave }, onSuccess: async () => { await onSaved(); onClose(); } }); const connections = query.data?.connections ?? []; + const branchMissing = save.error instanceof ApiError && (save.error.body as { code?: string } | null)?.code === 'skill_source_ref_unavailable'; + const canSave = connections.some(connection => connection.id === connectionId); return { if (!open && !save.isPending) onClose(); }}> { if (onReturnFocus) { event.preventDefault(); onReturnFocus(); } }}> @@ -40,10 +43,11 @@ export function SkillSourceConnectionRepair({ companyId, source, onClose, onSave {save.error &&

{save.error instanceof ApiError && save.error.status === 409 ? 'This source was updated. Your connection is still selected. Save again to apply it.' : save.error.message}

} + {branchMissing && source.accessScope !== 'projects' && !source.projects?.some(project => project.active) && }
- +
; diff --git a/ui/src/lib/project-skill-repositories.ts b/ui/src/lib/project-skill-repositories.ts new file mode 100644 index 00000000000..bf799e10dc2 --- /dev/null +++ b/ui/src/lib/project-skill-repositories.ts @@ -0,0 +1,23 @@ +import type { ProjectSkillRepository } from '@paperclipai/shared'; + +export function groupProjectSkillRepositories(repositories: ProjectSkillRepository[]) { + const groups = new Map(); + for (const repository of repositories) { + const key = JSON.stringify([repository.repositoryUrl.toLowerCase(), repository.trackingRef]); + const group = groups.get(key) ?? []; + group.push(repository); + groups.set(key, group); + } + return [...groups].map(([key, workspaces]) => { + const pending = workspaces.find(repository => repository.status === 'pending'); + const failed = workspaces.find(repository => repository.status === 'error'); + const importableWorkspaces = workspaces.filter(repository => repository.status === 'available' || repository.status === 'error'); + const repository: ProjectSkillRepository = { ...workspaces[0]!, + projectName: [...new Set(workspaces.map(repository => repository.projectName))].join(', '), + status: importableWorkspaces.length ? failed?.status ?? 'available' : pending?.status ?? workspaces[0]!.status, + lastError: failed?.lastError ?? workspaces[0]!.lastError, + importState: workspaces.some(repository => repository.importState === 'importing') ? 'importing' : 'queued', + }; + return { key, repository, workspaces, importableWorkspaces, hasPending: Boolean(pending) }; + }); +} diff --git a/ui/src/pages/SkillSources.test.tsx b/ui/src/pages/SkillSources.test.tsx index ac8f5b81e72..0acef241b55 100644 --- a/ui/src/pages/SkillSources.test.tsx +++ b/ui/src/pages/SkillSources.test.tsx @@ -62,6 +62,82 @@ afterEach(async () => { }); describe('GitHub skill source import', () => { + it('groups the same repository and ref across projects and shows import progress until it is installed', async () => { + context.sourceId = ''; + const repository = { projectId: 'one', projectName: 'Paperclip', projectVisibility: 'open' as const, + workspaceId: 'workspace-one', repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'HEAD', + bindingId: null, sourceId: null, status: 'available' as const, lastError: null }; + vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([repository, { ...repository, projectId: 'two', projectName: 'Sentry', workspaceId: 'workspace-two' }]); + let finish!: (value: { bindingId: string }) => void; + vi.mocked(skillSourcesApi.adoptProject).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })) + .mockResolvedValue({ bindingId: 'binding-two' }); + await mount(); + expect(document.querySelectorAll('[aria-label="Project repositories"] section')).toHaveLength(1); + expect(document.body.textContent).toContain('Paperclip, Sentry'); + await act(async () => button('Import skills').click()); await flush(); + expect(document.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(document.querySelector('[role="status"]')?.textContent).toContain('Starting import'); + vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([ + { ...repository, status: 'pending' }, { ...repository, projectId: 'two', projectName: 'Sentry', workspaceId: 'workspace-two', status: 'pending' }, + ]); + await act(async () => finish({ bindingId: 'binding-one' })); await flush(); + expect(skillSourcesApi.adoptProject).toHaveBeenCalledWith('company-1', 'workspace-two'); + expect(document.querySelector('[role="status"]')?.textContent).toContain('queued'); + expect(button('Importing…').disabled).toBe(true); + }); + + it('offers branch repair rather than connection repair for an unavailable tracked ref', async () => { + context.sourceId = ''; + const source = { ...sourceFixture(), trackingRef: 'main', lastError: 'The tracked GitHub branch or revision is unavailable. Use the repository’s default branch or update its configured ref.' }; + vi.mocked(skillSourcesApi.list).mockResolvedValue([{ ...source, repairAction: 'branch' }]); + vi.mocked(skillSourcesApi.settings).mockResolvedValue({ ...source, trackingRef: 'HEAD', lastError: null }); + await mount(); + expect(button('Choose connection')).toBeUndefined(); + await act(async () => button('Use default branch').click()); await flush(); + expect(skillSourcesApi.settings).toHaveBeenCalledWith('company-1', source.id, { revision: source.revision, trackingRef: 'HEAD' }); + }); + + it('keeps pending project links inside the installed source instead of duplicating its repository', async () => { + context.sourceId = ''; + vi.mocked(skillSourcesApi.list).mockResolvedValue([{ ...sourceFixture(), trackingRef: 'HEAD' }]); + vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([{ projectId: 'sentry', projectName: 'Sentry', projectVisibility: 'open', + workspaceId: 'second-workspace', repositoryUrl: sourceFixture().repositoryUrl, trackingRef: 'HEAD', bindingId: 'binding', sourceId: null, status: 'pending', lastError: null }]); + await mount(); + expect(document.querySelectorAll(`a[href="${sourceFixture().repositoryUrl}"]`)).toHaveLength(1); + expect(document.querySelector('[aria-label="Project repositories"]')).toBeNull(); + expect(button('Importing…').disabled).toBe(true); + expect(document.querySelector('[role="status"]')?.textContent).toContain('queued'); + }); + + it('allows remaining project imports while another matching workspace is pending', async () => { + context.sourceId = ''; + const repository = { projectId: 'one', projectName: 'Paperclip', projectVisibility: 'open' as const, workspaceId: 'already-pending', + repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'HEAD', bindingId: 'binding', sourceId: null, status: 'pending' as const, lastError: null }; + vi.mocked(skillSourcesApi.projectRepositories).mockResolvedValue([repository, + { ...repository, projectId: 'two', projectName: 'Sentry', workspaceId: 'available-workspace', status: 'available', bindingId: null }]); + vi.mocked(skillSourcesApi.adoptProject).mockResolvedValue({ bindingId: 'second-binding' }); + await mount(); + expect(document.querySelector('[role="status"]')?.textContent).toContain('queued'); + expect(button('Import remaining skills')?.disabled).toBe(false); + await act(async () => button('Import remaining skills').click()); await flush(); + expect(skillSourcesApi.adoptProject).toHaveBeenCalledTimes(1); + expect(skillSourcesApi.adoptProject).toHaveBeenCalledWith('company-1', 'available-workspace'); + }); + + it('retains the selected connection and repairs a missing branch directly from connection setup', async () => { + context.sourceId = ''; + const source = { ...sourceFixture(), connectionId: 'personal', lastError: 'Choose a GitHub connection to enable automatic updates.', repairAction: 'connection' as const }; + vi.mocked(skillSourcesApi.list).mockResolvedValue([source]); + vi.mocked(skillSourcesApi.settings).mockRejectedValueOnce(new ApiError('The tracked GitHub branch or revision is unavailable.', 422, { code: 'skill_source_ref_unavailable' })) + .mockResolvedValue({ ...source, trackingRef: 'HEAD', lastError: null }); + await mount(); + await act(async () => button('Choose connection').click()); await flush(); + await act(async () => button('Save connection').click()); await flush(); + expect(skillSourcesApi.settings).toHaveBeenLastCalledWith('company-1', source.id, { revision: source.revision, connectionId: 'personal' }); + await act(async () => button('Use default branch').click()); await flush(); + expect(skillSourcesApi.settings).toHaveBeenLastCalledWith('company-1', source.id, { revision: source.revision, connectionId: 'personal', trackingRef: 'HEAD' }); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + }); it('shows measured Git download progress before skill discovery and retains the automatically chosen connection', async () => { let finish!: (value: typeof discovery & { connectionId: string }) => void; vi.mocked(skillSourcesApi.discoverStream).mockImplementation((_company, _input, update) => { diff --git a/ui/src/pages/SkillSources.tsx b/ui/src/pages/SkillSources.tsx index 05c164e9a40..4f6d0045b61 100644 --- a/ui/src/pages/SkillSources.tsx +++ b/ui/src/pages/SkillSources.tsx @@ -26,6 +26,7 @@ import { SkillImportWarnings, skillImportWarnings } from './skills/SkillImportWa import { timeAgo } from '@/lib/timeAgo'; import { skillRoute } from '@/lib/company-skill-routes'; import { consumeSkillSourceReturn, rememberSkillSourceReturn } from '@/lib/skill-source-connect-return'; +import { groupProjectSkillRepositories } from '@/lib/project-skill-repositories'; const sourceKey = (companyId: string) => queryKeys.skillSources.all(companyId); export function SkillSources() { @@ -49,11 +50,18 @@ export function SkillSources() { const refresh = useMutation({ mutationFn: (id: string) => skillSourcesApi.refresh(companyId, id), onSuccess: async result => { setResults(prev => ({ ...prev, [result.source.id]: null })); await invalidate(); }, onError: (error, id) => { setResults(prev => ({ ...prev, [id]: { message: error.message, failedAt: Date.now(), operation: 'refresh' } })); void invalidate(); } }); - const adopt = useMutation({ mutationFn: (workspaceId: string) => skillSourcesApi.adoptProject(companyId, workspaceId), onSuccess: invalidate }); + const adopt = useMutation({ mutationFn: async (group: ReturnType[number]) => { + for (const workspace of group.importableWorkspaces) await skillSourcesApi.adoptProject(companyId, workspace.workspaceId); + }, onSuccess: invalidate, onError: () => { void invalidate(); } }); const settings = useMutation({ mutationFn: ({ source, enabled }: { source: SkillSource; enabled: boolean }) => skillSourcesApi.settings(companyId, source.id, { revision: source.revision, automaticUpdates: enabled }), onSuccess: async (_result, variables) => { setResults(prev => ({ ...prev, [variables.source.id]: null })); await invalidate(); }, onError: (error, variables) => setResults(prev => ({ ...prev, [variables.source.id]: { message: error.message, failedAt: Date.now(), operation: 'settings' } })) }); - const availableProjects = Array.isArray(projectRepositories.data) ? projectRepositories.data.filter(repo => !repo.sourceId) : []; + const repairBranch = useMutation({ mutationFn: (source: SkillSource) => skillSourcesApi.settings(companyId, source.id, { revision: source.revision, trackingRef: 'HEAD' }), + onSuccess: async (_result, source) => { setResults(prev => ({ ...prev, [source.id]: null })); await invalidate(); sourceActionButtons.current[source.id]?.focus(); }, + onError: (error, source) => { setResults(prev => ({ ...prev, [source.id]: { message: error.message, failedAt: Date.now(), operation: 'settings' } })); void invalidate(); } }); + const availableProjects = groupProjectSkillRepositories(Array.isArray(projectRepositories.data) ? projectRepositories.data.filter(repo => !repo.sourceId) : []); + const groupSource = (group: typeof availableProjects[number]) => query.data?.find(source => source.repositoryUrl.toLowerCase() === group.repository.repositoryUrl.toLowerCase() && source.trackingRef === group.repository.trackingRef); + const uninstalledProjects = availableProjects.filter(group => !groupSource(group)); const disconnect = useMutation({ mutationFn: ({ source, skills }: { source: SkillSource; skills: SkillSourceDisconnectRequest['skills'] }) => skillSourcesApi.disconnect(companyId, source.id, { revision: source.revision, skills }), @@ -85,15 +93,16 @@ export function SkillSources() { {query.data?.length === 0 && availableProjects.length === 0 &&

No repositories added yet. Import your skills to make them available in {selectedCompany?.name ?? 'this company'}.

} {projectRepositories.error &&

{projectRepositories.error.message}

} {adopt.error &&

{adopt.error.message}

} - {availableProjects.length > 0 &&
- {availableProjects.map(repository => adopt.mutate(repository.workspaceId)} />)} + {uninstalledProjects.length > 0 &&
+ {uninstalledProjects.map(group => adopt.mutate(group)} />)}
}
{query.data?.map(source => { const installed = source.entries.filter(entry => entry.skillId); const newCount = source.entries.filter(entry => entry.selection === 'new' && entry.present).length; const result = results[source.id]; + const projectGroup = availableProjects.find(group => groupSource(group)?.id === source.id); const localError = result && result.message !== source.lastError && (result.operation === 'settings' || !source.lastSuccessAt || new Date(source.lastSuccessAt).getTime() < result.failedAt) ? result.message : null; @@ -126,6 +135,8 @@ export function SkillSources() {
+ {projectGroup && adopt.mutate(projectGroup)} />} {installed.length > 0 &&
    {installed.map(entry =>
  • @@ -143,7 +154,11 @@ export function SkillSources() { entry.selection === 'selected'), source.repositoryPackage)} /> {source.lastError && source.lastAttemptAt &&

    Last attempt {timeAgo(source.lastAttemptAt)}

    } {source.lastError &&

    {source.lastError} Review source

    } - {source.lastError && } + {source.lastError && (source.repairAction === 'connection' || (!source.repairAction && !source.connectionId)) && } + {source.lastError && source.repairAction === 'branch' && (source.accessScope === 'projects' || source.projects?.some(project => project.active) + ?
    {source.projects?.filter(project => project.active).map(project => Update {project.name} repository)}
    + : )} {source.connectionId && Manage GitHub connection}
; })} diff --git a/ui/storybook/stories/skills/project-repository-skills/components.stories.tsx b/ui/storybook/stories/skills/project-repository-skills/components.stories.tsx index baf62b43be0..a523f59504c 100644 --- a/ui/storybook/stories/skills/project-repository-skills/components.stories.tsx +++ b/ui/storybook/stories/skills/project-repository-skills/components.stories.tsx @@ -4,11 +4,11 @@ import { ProjectSkillRepositoryRow } from '@/components/skill-studio/ProjectSkil import { SkillSourceSyncControls } from '@/components/skill-studio/SkillSourceSyncControls'; import { projectRepository } from '../../../support/projectRepositorySkills'; -function Components({ state = 'available' }: { state?: 'available' | 'pending' | 'error' | 'private' | 'paused' | 'refreshing' }) { +function Components({ state = 'available' }: { state?: 'available' | 'pending' | 'importing' | 'partial' | 'error' | 'private' | 'paused' | 'refreshing' }) { const [imported, setImported] = useState(false), [automatic, setAutomatic] = useState(state !== 'paused'); return
- setImported(true)} /> + setImported(true)} /> setImported(true)} />
; } @@ -17,6 +17,8 @@ export default meta; type Story = StoryObj; export const Available: Story = {}; export const Pending: Story = { args: { state: 'pending' } }; +export const Importing: Story = { args: { state: 'importing' } }; +export const PartialImport: Story = { args: { state: 'partial' } }; export const Error: Story = { args: { state: 'error' } }; export const Private: Story = { args: { state: 'private' } }; export const Paused: Story = { args: { state: 'paused' } }; diff --git a/ui/storybook/stories/skills/project-repository-skills/journeys.stories.tsx b/ui/storybook/stories/skills/project-repository-skills/journeys.stories.tsx index 55a47cdd47d..22262b2cabc 100644 --- a/ui/storybook/stories/skills/project-repository-skills/journeys.stories.tsx +++ b/ui/storybook/stories/skills/project-repository-skills/journeys.stories.tsx @@ -42,6 +42,8 @@ export default meta; type Story = StoryObj; export const CreateProject: Story = { name: '01 · Create a project', args: { scenario: 'new-project' } }; export const LegacyAdoption: Story = { name: '02 · Import from an older project', args: { scenario: 'legacy' } }; +export const DuplicateProjects: Story = { name: 'Shared repository import across projects', args: { scenario: 'duplicate' } }; +export const MissingBranch: Story = { name: 'Repair an unavailable branch', args: { scenario: 'missing-branch' } }; export const Synced: Story = { name: '03 · Automatically imported skills' }; export const AutomaticUpdate: Story = { name: '04 · Background update and a new skill', args: { scenario: 'automatic' } }; export const Refreshing: Story = { name: '05 · Refresh in progress', args: { scenario: 'refreshing' } }; diff --git a/ui/storybook/support/projectRepositorySkills.ts b/ui/storybook/support/projectRepositorySkills.ts index 858945c4c74..16f213f5a40 100644 --- a/ui/storybook/support/projectRepositorySkills.ts +++ b/ui/storybook/support/projectRepositorySkills.ts @@ -15,7 +15,7 @@ import { baseRoutine } from '../fixtures/routineWebhooks'; import { storybookProjects, storybookIssues } from '../fixtures/paperclipData'; import { COMPANY_ID, SOURCE_ID, COMMIT, installFixtures, candidates, sourceFixture } from '../fixtures/githubSkillSources'; -export type Scenario = 'new-project' | 'attach-repository' | 'legacy' | 'ready' | 'refreshing' | 'error' | 'empty' | 'private' | 'shared' | 'shared-private' | 'long' | 'many' | 'paused' | 'removed' | 'validation' | 'repair' | 'repair-conflict' | 'automatic' | 'latest'; +export type Scenario = 'new-project' | 'attach-repository' | 'legacy' | 'duplicate' | 'missing-branch' | 'ready' | 'refreshing' | 'error' | 'empty' | 'private' | 'shared' | 'shared-private' | 'long' | 'many' | 'paused' | 'removed' | 'validation' | 'repair' | 'repair-conflict' | 'automatic' | 'latest'; export const projectRepository: ProjectSkillRepository = { projectId: 'project-skills', projectName: 'Acme platform', projectVisibility: 'open', workspaceId: 'workspace-skills', repositoryUrl: 'https://github.com/acme/team-skills', trackingRef: 'main', @@ -26,7 +26,7 @@ const NEW_COMMIT = 'b'.repeat(40); /** All mutations are scoped in-memory API fixtures. No provider calls or server writes. */ export function installProjectSkillFixtures(scenario: Scenario, client: QueryClient) { - const cleanup = installFixtures(['new-project', 'legacy'].includes(scenario), false, { journey: true, repositories: 'multiple' }); + const cleanup = installFixtures(['new-project', 'legacy', 'duplicate'].includes(scenario), false, { journey: true, repositories: 'multiple' }); const baseSources = { ...skillSourcesApi }, baseSkills = { ...companySkillsApi }; const originalActivity = { ...activityApi }, originalHeartbeats = { ...heartbeatsApi }; const originalEmailThread = emailApi.thread; @@ -34,13 +34,13 @@ export function installProjectSkillFixtures(scenario: Scenario, client: QueryCli const timers: ReturnType[] = []; let projectCreated = scenario !== 'new-project'; const updatedSources = new Set(); - const sourcePreferences = new Map(); - let live = true, automaticUpdates = scenario !== 'paused', failed = ['error', 'repair', 'repair-conflict'].includes(scenario); + const sourcePreferences = new Map(); + let live = true, automaticUpdates = scenario !== 'paused', failed = ['error', 'repair', 'repair-conflict', 'missing-branch'].includes(scenario); let sourceRevision = sourceFixture().revision, repairConflict = scenario === 'repair-conflict'; const privateProject = scenario === 'private'; let repository = { ...projectRepository, projectVisibility: privateProject ? 'private' as const : 'open' as const }; - if (!['new-project', 'legacy'].includes(scenario)) repository = { ...repository, sourceId: SOURCE_ID, bindingId: 'binding-skills', status: 'ready' }; - let additionalRepositories: ProjectSkillRepository[] = []; + if (!['new-project', 'legacy', 'duplicate'].includes(scenario)) repository = { ...repository, sourceId: SOURCE_ID, bindingId: 'binding-skills', status: 'ready' }; + let additionalRepositories: ProjectSkillRepository[] = scenario === 'duplicate' ? [{ ...repository, workspaceId: 'second-workspace', projectId: 'second-project', projectName: 'Sentry' }] : []; let project = { ...storybookProjects[0]!, id: repository.projectId, urlKey: repository.projectId, name: repository.projectName, companyId: COMPANY_ID, workspaces: storybookProjects[0]!.workspaces.slice(0, 1).map(workspace => ({ ...workspace, id: repository.workspaceId, projectId: repository.projectId, repoUrl: repository.repositoryUrl, repoRef: 'main', defaultRef: 'main', name: 'acme/team-skills', metadata: { ...workspace.metadata, githubRepositoryId: '123456' } })) }; const workspaceTemplate = project.workspaces[0]!; @@ -80,13 +80,14 @@ export function installProjectSkillFixtures(scenario: Scenario, client: QueryCli } skillSourcesApi.list = async companyId => (await baseSources.list(companyId)).map(source => ({ ...source, revision: Math.max(source.revision, sourceRevision), - automaticUpdates, importNewSkills: true, ...sourcePreferences.get(source.id), accessScope: 'projects', + automaticUpdates, importNewSkills: true, ...sourcePreferences.get(source.id), accessScope: scenario === 'missing-branch' ? 'company' : 'projects', syncState: scenario === 'refreshing' ? 'refreshing' : failed ? 'error' : automaticUpdates ? 'ready' : 'paused', fullName: scenario === 'long' ? 'acme/platform-services-production-web-accessibility-improvements' : source.fullName, lastScanCommit: updatedSources.has(source.id) ? NEW_COMMIT : source.lastScanCommit, - lastError: failed ? scenario.startsWith('repair') ? 'The person who connected this source no longer has company access. Choose another GitHub connection.' + lastError: failed ? scenario === 'missing-branch' ? 'The tracked GitHub branch or revision is unavailable. Use the repository’s default branch or update its configured ref.' : scenario.startsWith('repair') ? 'The person who connected this source no longer has company access. Choose another GitHub connection.' : 'GitHub could not be reached. The installed skills were kept.' : null, - projects: ['shared', 'shared-private'].includes(scenario) ? [{ id: project.id, name: 'Private research', visibility: 'private', active: true }, + repairAction: !failed ? null : scenario === 'missing-branch' ? 'branch' : scenario.startsWith('repair') ? 'connection' : 'retry', + projects: scenario === 'missing-branch' ? [] : ['shared', 'shared-private'].includes(scenario) ? [{ id: project.id, name: 'Private research', visibility: 'private', active: true }, { id: 'project-open', name: 'Engineering', visibility: scenario === 'shared' ? 'open' : 'private', active: true }] : [{ id: project.id, name: project.name, visibility: repository.projectVisibility, active: true }], entries: scenario === 'empty' ? [] : scenario === 'many' ? Array.from({ length: 80 }, (_, index) => ({ @@ -112,8 +113,9 @@ export function installProjectSkillFixtures(scenario: Scenario, client: QueryCli ...(input.automaticUpdates === undefined ? {} : { automaticUpdates: input.automaticUpdates }), ...(input.importNewSkills === undefined ? {} : { importNewSkills: input.importNewSkills }), ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + ...(input.trackingRef === undefined ? {} : { trackingRef: input.trackingRef }), }); - if (input.connectionId !== undefined) failed = false; + if (input.connectionId !== undefined || input.trackingRef !== undefined) failed = false; return (await skillSourcesApi.list(COMPANY_ID)).find(source => source.id === id)!; }; companySkillsApi.detail = async (companyId, id) => {