mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix(skills): repair missing branches and show grouped import progress (#16256)
## Thinking Path > - Paperclip lets people manage AI agents and their reusable skills. > - GitHub skill sources must keep their installed content usable during updates. > - A source can track a branch that no longer exists. > - The UI treats that GitHub error as a connection problem, so saving a valid account does not resolve it. > - Project workspaces also show the same repository several times, and long imports give little feedback. > - This pull request adds explicit branch repair, groups repository imports, and shows import progress. > - People can repair the actual problem and see when their import is working. ## Linked Issues or Issue Description Refs #16066 and #14713. This fixes the Sources experience introduced by those changes. Related adapter-source PRs #14340 and #14341 cover runtime mounting, not this source-management flow. No overlapping source-management fix was found. **What happened?** An installed source tracked `main` while its GitHub repository used `master`. GitHub returned HTTP 422. The UI showed a general read error and asked for a new connection after a valid account was saved. The project repository list repeated the same repository/ref for each project. Import had no spinner or explanation of queued work. **Expected behavior** Identify an unavailable branch and offer an explicit repair. Retain the selected connection and installed versions. Show one repository/ref row with its projects. Give immediate feedback and show the queued and active import states. **Steps to reproduce** 1. Install a source with an immutable commit and a saved tracked branch that does not exist. 2. Refresh it, or try to save a valid GitHub connection. 3. Add the same repository/ref to two legacy project workspaces and open Sources. 4. Import that repository and observe the row until installation completes. ## What Changed - Distinguish a missing GitHub revision from access denial and temporary read errors. - Validate both repository identity and the tracked revision when saving a connection. - Offer Use default branch on independent sources and in connection repair. Keep `HEAD` as the default-branch alias after refresh. Preserve skill IDs, versions and selections. - Keep active project refs under project configuration. Reject source-level changes that would alter their tracking. Recheck the ref under the source lock when project attachment races with branch repair. - Group matching repository/ref workspaces. Adopt eligible workspaces and skip pending workspaces. Keep remaining project links inside the existing source card. Allow remaining imports while another workspace is pending. - Show a spinner, busy state and queued/importing text. Keep the saved authorized account selected in connection repair. - Update shared contracts, feature/access maps, database notes and production-component Storybook journeys. ## Verification - Red/green: three local regressions failed on the original code: missing-ref classification, duplicate repository rows, and branch repair instead of another connection prompt. The changed UI/reader tests pass after the fixes. - Live local app with a real authorized GitHub credential: reproduced HTTP 422 for the unavailable branch; used the UI to restore default-branch tracking; confirmed the same connection, skill ID and single immutable version remained. - Live local grouped Paperclip import: one source, two project associations and 52 installed skills. Sources survived reload. No staging data was changed. - All 16 project repository Storybook browser journeys pass on the final head, including keyboard focus recovery after branch repair. These include branch repair, grouped imports, connection selection, recovery, removal and responsive layouts. - Repository typecheck, production build, Storybook build, token gates and 28 map/catalog tests passed. Final UI typecheck and build also passed after grouping remaining project links. - Linux qualification: all 94 focused source, project import, GitHub reader and UI tests pass with supported Node 24. No database tests were skipped. Both review findings failed before their fixes and pass after them. - The full local `pnpm test:run` attempt was stopped after macOS database suites exhausted the host shared-memory limit. The complete Linux CI matrix is the full-suite gate. The complete Linux CI matrix passes for head `f886e3ac073ec1c6f051fd6429496234f5143694`. - Greptile rates head `f886e3ac073ec1c6f051fd6429496234f5143694` 5/5. Both findings are fixed and all review threads are resolved. - Storybook: `skills-project-repository-skills-02-journeys--missing-branch` and `--duplicate-projects`. ## Risks - Default-branch repair changes the tracked branch only after an explicit user action. Existing execution version pins stay intact. - A selected connection now needs access to the tracked revision, not only repository metadata. - Grouping is by repository and ref. Project access and provenance remain separate and are checked by the existing services. - Legacy source errors contain safe text. The repair-action classifier maps known errors and treats other failures as retryable. No credentials or provider response bodies are returned. - No schema migration, workflow change, lockfile change or staging deployment. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model identifier and context-window size are not exposed in this session. Tool-assisted implementation, code execution and browser verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a8b652b085
commit
c36281d639
21 files changed
+317
-51
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <section className="flex flex-col gap-2 py-3" aria-label={`Skills from ${repository.projectName}`}>
|
||||
const actionPending = pending || repository.status === 'pending';
|
||||
const importing = actionPending || backgroundPending;
|
||||
return <section className="flex flex-col gap-2 py-3" aria-label={`Skills from ${repository.projectName}`} aria-busy={Boolean(importing)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<GithubIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<a href={repository.repositoryUrl} target="_blank" rel="noreferrer" className="block truncate text-sm font-medium hover:underline">
|
||||
{showRepository && <a href={repository.repositoryUrl} target="_blank" rel="noreferrer" className="block truncate text-sm font-medium hover:underline">
|
||||
{repository.repositoryUrl.replace('https://github.com/', '')}
|
||||
</a>
|
||||
</a>}
|
||||
<p className="truncate text-xs text-muted-foreground">{repository.projectName} · {repository.trackingRef === 'HEAD' ? 'Default branch' : repository.trackingRef}{repository.projectVisibility === 'private' ? ' · Project access' : ''}</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" disabled={importing} onClick={onImport}>
|
||||
{importing ? 'Importing…' : repository.status === 'error' ? 'Retry import' : 'Import skills'}
|
||||
<Button size="sm" variant="outline" disabled={actionPending || disabled} onClick={onImport}>
|
||||
{importing && <LoaderCircle className="size-3.5 animate-spin motion-reduce:animate-none" />}
|
||||
{actionPending ? 'Importing…' : repository.status === 'error' ? 'Retry import' : backgroundPending ? 'Import remaining skills' : 'Import skills'}
|
||||
</Button>
|
||||
</div>
|
||||
{importing && <p role="status" className="ml-8 text-xs text-muted-foreground">{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.'}</p>}
|
||||
{repository.lastError && <p role="alert" className="text-sm text-destructive">{repository.lastError} <Link to="/apps" className="underline">Manage GitHub connection</Link></p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -13,10 +13,11 @@ export function SkillSourceConnectionRepair({ companyId, source, onClose, onSave
|
||||
companyId: string; source: SkillSource; onClose: () => void; onSaved: () => Promise<void>;
|
||||
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 <Dialog open onOpenChange={open => { if (!open && !save.isPending) onClose(); }}>
|
||||
<DialogContent className="flex max-h-(--sz-calc-18) flex-col overflow-hidden sm:max-w-md"
|
||||
onCloseAutoFocus={event => { if (onReturnFocus) { event.preventDefault(); onReturnFocus(); } }}>
|
||||
@@ -40,10 +43,11 @@ export function SkillSourceConnectionRepair({ companyId, source, onClose, onSave
|
||||
<Button asChild variant="link" className="justify-start p-0"><Link to="/apps">Manage GitHub connections</Link></Button>
|
||||
{save.error && <p role="alert" className="text-sm text-destructive">{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}</p>}
|
||||
{branchMissing && source.accessScope !== 'projects' && !source.projects?.some(project => project.active) && <Button variant="outline" disabled={!canSave || save.isPending} onClick={() => save.mutate(true)}>Use default branch</Button>}
|
||||
</div>
|
||||
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-border pt-4">
|
||||
<Button variant="ghost" disabled={save.isPending} onClick={onClose}>Cancel</Button>
|
||||
<Button disabled={!connectionId || save.isPending} onClick={() => save.mutate()}>{save.isPending ? 'Saving…' : 'Save connection'}</Button>
|
||||
<Button disabled={!canSave || save.isPending} onClick={() => save.mutate(false)}>{save.isPending ? 'Saving…' : 'Save connection'}</Button>
|
||||
</footer>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ProjectSkillRepository } from '@paperclipai/shared';
|
||||
|
||||
export function groupProjectSkillRepositories(repositories: ProjectSkillRepository[]) {
|
||||
const groups = new Map<string, ProjectSkillRepository[]>();
|
||||
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) };
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<typeof groupProjectSkillRepositories>[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 && <div className="flex flex-col items-start gap-3 py-8"><p className="text-sm text-muted-foreground">No repositories added yet. Import your skills to make them available in {selectedCompany?.name ?? 'this company'}.</p><Button variant="outline" onClick={() => navigate('/skills/sources/new')}>Import from GitHub</Button></div>}
|
||||
{projectRepositories.error && <p role="alert" className="text-sm text-destructive">{projectRepositories.error.message} <Button variant="ghost" size="sm" onClick={() => void projectRepositories.refetch()}>Try again</Button></p>}
|
||||
{adopt.error && <p role="alert" className="text-sm text-destructive">{adopt.error.message}</p>}
|
||||
{availableProjects.length > 0 && <div className="divide-y divide-border" aria-label="Project repositories">
|
||||
{availableProjects.map(repository => <ProjectSkillRepositoryRow key={repository.workspaceId} repository={repository}
|
||||
pending={adopt.isPending && adopt.variables === repository.workspaceId} onImport={() => adopt.mutate(repository.workspaceId)} />)}
|
||||
{uninstalledProjects.length > 0 && <div className="divide-y divide-border" aria-label="Project repositories">
|
||||
{uninstalledProjects.map(group => <ProjectSkillRepositoryRow key={group.key} repository={group.repository} backgroundPending={group.hasPending} disabled={adopt.isPending}
|
||||
pending={adopt.isPending && adopt.variables?.key === group.key} onImport={() => adopt.mutate(group)} />)}
|
||||
</div>}
|
||||
<div className="divide-y divide-border">
|
||||
{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() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{projectGroup && <ProjectSkillRepositoryRow repository={projectGroup.repository} backgroundPending={projectGroup.hasPending} showRepository={false} disabled={adopt.isPending}
|
||||
pending={adopt.isPending && adopt.variables?.key === projectGroup.key} onImport={() => adopt.mutate(projectGroup)} />}
|
||||
{installed.length > 0 && <ul className="ml-8 min-w-0" aria-label={`Installed skills from ${source.fullName}`}>
|
||||
{installed.map(entry => <li key={entry.id}>
|
||||
<Link to={skillRoute(entry.skillId!)} title={entry.path} className="group flex min-w-0 items-center gap-2 rounded-sm py-1 text-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
@@ -143,7 +154,11 @@ export function SkillSources() {
|
||||
<SkillImportWarnings warnings={skillImportWarnings(source.entries.filter(entry => entry.selection === 'selected'), source.repositoryPackage)} />
|
||||
{source.lastError && source.lastAttemptAt && <p className="text-xs text-muted-foreground">Last attempt {timeAgo(source.lastAttemptAt)}</p>}
|
||||
{source.lastError && <p role="alert" className="text-sm text-destructive">{source.lastError} <Link to={`/skills/sources/${source.id}`} className="underline">Review source</Link></p>}
|
||||
{source.lastError && <Button variant="outline" size="sm" className="self-start" onClick={() => setRepairSourceId(source.id)}>Choose connection</Button>}
|
||||
{source.lastError && (source.repairAction === 'connection' || (!source.repairAction && !source.connectionId)) && <Button variant="outline" size="sm" className="self-start" onClick={() => setRepairSourceId(source.id)}>Choose connection</Button>}
|
||||
{source.lastError && source.repairAction === 'branch' && (source.accessScope === 'projects' || source.projects?.some(project => project.active)
|
||||
? <div className="flex flex-wrap gap-3">{source.projects?.filter(project => project.active).map(project => <Link key={project.id} to={`/projects/${project.id}`} className="text-sm underline">Update {project.name} repository</Link>)}</div>
|
||||
: <Button variant="outline" size="sm" className="self-start" disabled={repairBranch.isPending} onClick={() => repairBranch.mutate(source)}>
|
||||
{repairBranch.isPending && repairBranch.variables.id === source.id ? 'Updating branch…' : 'Use default branch'}</Button>)}
|
||||
{source.connectionId && <Link to={`/apps/${source.connectionId}/permissions`} className="text-xs text-muted-foreground hover:text-foreground">Manage GitHub connection</Link>}
|
||||
</section>;
|
||||
})}
|
||||
|
||||
@@ -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 <div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
|
||||
<ProjectSkillRepositoryRow repository={{ ...projectRepository, status: imported || state === 'pending' ? 'pending' : state === 'error' ? 'error' : 'available',
|
||||
projectVisibility: state === 'private' ? 'private' : 'open', lastError: state === 'error' ? 'GitHub access is unavailable. Reconnect GitHub and retry.' : null }} onImport={() => setImported(true)} />
|
||||
<ProjectSkillRepositoryRow repository={{ ...projectRepository, status: imported || ['pending', 'importing'].includes(state) ? 'pending' : state === 'error' ? 'error' : 'available', importState: ['importing', 'partial'].includes(state) ? 'importing' : 'queued',
|
||||
projectVisibility: state === 'private' ? 'private' : 'open', lastError: state === 'error' ? 'GitHub access is unavailable. Reconnect GitHub and retry.' : null }} backgroundPending={state === 'partial'} onImport={() => setImported(true)} />
|
||||
<SkillSourceSyncControls automaticUpdates={automatic} refreshing={state === 'refreshing'} onAutomaticUpdates={setAutomatic} onRefresh={() => setImported(true)} />
|
||||
</div>;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
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' } };
|
||||
|
||||
@@ -42,6 +42,8 @@ export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
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' } };
|
||||
|
||||
@@ -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<typeof setTimeout>[] = [];
|
||||
let projectCreated = scenario !== 'new-project';
|
||||
const updatedSources = new Set<string>();
|
||||
const sourcePreferences = new Map<string, { automaticUpdates?: boolean; importNewSkills?: boolean; connectionId?: string | null }>();
|
||||
let live = true, automaticUpdates = scenario !== 'paused', failed = ['error', 'repair', 'repair-conflict'].includes(scenario);
|
||||
const sourcePreferences = new Map<string, { automaticUpdates?: boolean; importNewSkills?: boolean; connectionId?: string | null; trackingRef?: 'HEAD' }>();
|
||||
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) => {
|
||||
|
||||
Reference in new issue
Block a user