Files
PaperClipAI/ui/storybook/support/projectRepositorySkills.ts
T
DottaandPaperclip 33d24e48ad fix(skills): simplify source rows, errors, and actions (#16310)
## Thinking Path

> - Paperclip manages agent skills from GitHub repositories.
> - Skills → Sources shows saved repositories and their sync status.
> - A saved source gained a second repository row when other projects
used the same repository and branch.
> - That row repeated the GitHub icon and branch, and it exposed project
names that did not help with source management.
> - The page also repeated the Installed navigation link and showed
refresh errors as separate lines of text.
> - Connection management also appeared under each source, where it
competed with source-specific actions.
> - This pull request shows each saved source once, groups error
details, and moves connection management into the menu beside Import
from GitHub.
> - The result is a clearer source list that keeps import and recovery
actions available.

## Linked Issues or Issue Description

**What happened?**

When a saved source shared its repository and branch with other
projects, Skills → Sources displayed a second GitHub header and the
names of those projects. The page also displayed an extra Installed
skills link and repeated Manage GitHub connection under source rows. A
failed refresh placed its timestamp and error on separate lines.

**Expected behavior**

Show one row for each saved source. Keep project names out of that row.
Keep the additional import action available in More. Put connection
management in the page actions menu beside Import from GitHub. Present
refresh errors as one readable notice with the time and review action.

**Steps to reproduce**

1. Open Skills → Sources in a company with one saved GitHub source and
another project that uses the same repository and branch.
2. Observe the repeated repository row and project names.
3. Open the Storybook journey `Existing source, more projects` to
inspect the same state. Open `Retained skills and retry` to inspect a
failed refresh.

**Paperclip version or commit**

The issue is present on base commit `739f9e40a4`.

**Deployment mode**

Built from source. The issue was also observed in a staging workspace.

## What Changed

- Remove project names, the duplicate repository row, and the redundant
Installed skills link from saved source rows.
- Put imports for additional projects in the source More menu. Show
brief import progress in the source metadata.
- Group source errors, attempt time, and the review link in a compact
notice. Add a Storybook journey and browser coverage for the
shared-repository state.
- Add a page actions menu beside Import from GitHub. Put Manage GitHub
connection there and remove the repeated inline links. Cover the menu
and keyboard focus in the Storybook browser suite.

## Verification

- `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, `node
scripts/check-maps.mjs`, and `node scripts/storybook-catalog.mjs
--check` passed locally. The Skills sources unit suite passed 22 tests.
- All 18 repository-skills Storybook browser tests passed, including the
shared-source import, connection menu, mobile, and failed-refresh
journeys.
- A local full `pnpm test:run` was stopped after a server runner-API
test timed out. An isolated diagnostic identified macOS embedded
PostgreSQL shared-memory exhaustion. The PR passed all 55 CI checks,
including server test shards and end-to-end tests.
- Open the Storybook journeys `Existing source, more projects`,
`Automatically imported skills`, and `Retained skills and retry` to
inspect the compact source row, page actions menu, and grouped error
notice.

## Risks

Low risk. The visible import action for projects that share a saved
source moves to More. Connection management moves to a page menu and
opens the Apps catalog. People must open those menus to find the
actions. The import API and access checks do not change.

> I checked `ROADMAP.md`. This source-list correction does not duplicate
planned core work.

## Model Used

OpenAI Codex, GPT-6 family. The task runtime did not expose an exact
serving model ID or context-window size. The model used tool calls, code
edits, 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>
2026-10-14 12:52:18 -05:00

230 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { QueryClient } from '@tanstack/react-query';
import type { ProjectSkillRepository, RoutineRunSummary, Issue } from '@paperclipai/shared';
import { buildSkillMentionHref } from '@paperclipai/shared';
import { skillSourcesApi } from '@/api/skillSources';
import { companySkillsApi } from '@/api/companySkills';
import { projectsApi } from '@/api/projects';
import { routinesApi } from '@/api/routines';
import { activityApi } from '@/api/activity';
import { heartbeatsApi } from '@/api/heartbeats';
import { emailApi } from '@/api/email';
import { ApiError } from '@/api/client';
import { issuesApi } from '@/api/issues';
import { queryKeys } from '@/lib/queryKeys';
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' | 'duplicate' | 'partial-adoption' | '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',
bindingId: null, sourceId: null, status: 'available', lastError: null,
};
export const ROUTINE_ID = 'routine-project-skills';
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', 'duplicate'].includes(scenario), false, { journey: true, repositories: 'multiple' });
const baseSources = { ...skillSourcesApi }, baseSkills = { ...companySkillsApi };
const originalActivity = { ...activityApi }, originalHeartbeats = { ...heartbeatsApi };
const originalEmailThread = emailApi.thread;
const originalProjects = { ...projectsApi }, originalRoutines = { ...routinesApi }, originalIssues = { ...issuesApi };
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; trackingRef?: string }>();
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, projectName: scenario === 'partial-adoption' ? 'Paperclip' : projectRepository.projectName,
projectVisibility: privateProject ? 'private' as const : 'open' as const };
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' }]
: scenario === 'partial-adoption' ? [
{ ...repository, workspaceId: 'cloud-workspace', projectId: 'paperclip-cloud', projectName: 'Paperclip Cloud', sourceId: null, bindingId: null, status: 'available' },
{ ...repository, workspaceId: 'sentry-workspace', projectId: 'sentry', projectName: 'Sentry', sourceId: null, bindingId: null, status: 'available' },
] : [];
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]!;
const invalidate = async () => {
await Promise.all([
client.invalidateQueries({ queryKey: queryKeys.skillSources.all(COMPANY_ID) }),
client.invalidateQueries({ queryKey: queryKeys.companySkills.list(COMPANY_ID) }),
client.invalidateQueries({ queryKey: ['project-skill-repositories', COMPANY_ID] }),
client.invalidateQueries({ queryKey: queryKeys.routines.detail(ROUTINE_ID) }),
]);
};
const delay = (ms: number) => new Promise<void>(resolve => timers.push(setTimeout(resolve, ms)));
const available = () => candidates.filter(candidate => !candidate.error && !candidate.path.startsWith('experimental/')).map(candidate => candidate.path);
function updateRepository(row: ProjectSkillRepository) {
if (row.workspaceId === projectRepository.workspaceId) repository = row;
else additionalRepositories = additionalRepositories.map(existing => existing.workspaceId === row.workspaceId ? row : existing);
}
async function adopt(row = repository) {
const bindingId = `binding-${row.workspaceId}`;
updateRepository({ ...row, status: 'pending', bindingId });
await invalidate(); await delay(600);
if (!live) return;
const existing = (await baseSources.list(COMPANY_ID)).find(source => source.repositoryUrl === row.repositoryUrl && source.trackingRef === row.trackingRef);
const source = existing ?? (await baseSources.create(COMPANY_ID, { repositoryUrl: row.repositoryUrl, trackingRef: row.trackingRef,
commitSha: COMMIT, connectionId: 'github-storybook', packageMode: 'repository', selectedPaths: available(), excludedFolders: ['experimental'] })).source;
updateRepository({ ...row, bindingId, sourceId: source.id, status: 'ready' });
await invalidate();
}
async function refresh(_companyId = COMPANY_ID, requestedSourceId?: string) {
await delay(450);
if (failed) { failed = false; throw new Error('GitHub could not be reached. The installed skills were kept. Try again.'); }
const sourceId = requestedSourceId ?? (await baseSources.list(COMPANY_ID))[0]!.id;
const result = await baseSources.refresh(COMPANY_ID, sourceId);
await baseSources.select(COMPANY_ID, sourceId, { revision: result.source.revision, selectedPaths: available(), excludedFolders: ['experimental'] });
updatedSources.add(sourceId);
return { ...result, source: (await skillSourcesApi.list(COMPANY_ID))[0]! };
}
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: 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 === '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,
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 }]
: scenario === 'partial-adoption' ? [repository, ...additionalRepositories].filter(row => row.sourceId === source.id && row.status === 'ready')
.map(row => ({ id: row.projectId, name: row.projectName, visibility: row.projectVisibility, active: true }))
: [{ id: project.id, name: project.name, visibility: repository.projectVisibility, active: true }],
entries: scenario === 'empty' ? [] : scenario === 'many' ? Array.from({ length: 80 }, (_, index) => ({
...source.entries[0]!, id: 'entry-' + index, name: 'Project workflow ' + (index + 1), skillId: 'skill-' + index,
})) : source.entries.map((entry, index) => index === 1 && scenario === 'removed' ? { ...entry, present: false }
: index === 1 && scenario === 'validation' ? { ...entry, error: 'SKILL.md contains an invalid package reference. The installed version was kept.' } : entry),
}));
skillSourcesApi.projectRepositories = async () => !projectCreated || project.workspaces.length === 0 ? [] : structuredClone([repository, ...additionalRepositories]);
skillSourcesApi.adoptProject = async (_companyId, workspaceId) => {
const row = [repository, ...additionalRepositories].find(row => row.workspaceId === workspaceId)!;
await adopt(row); return { bindingId: `binding-${workspaceId}` };
};
skillSourcesApi.refresh = refresh;
skillSourcesApi.branches = async () => ({ defaultBranch: scenario === 'missing-branch' ? 'master' : 'main', branches: [
{ name: scenario === 'missing-branch' ? 'master' : 'main', isDefault: true }, { name: 'release/2026.10', isDefault: false },
], truncated: false });
skillSourcesApi.settings = async (_companyId, id, input) => {
if (input.connectionId !== undefined && repairConflict) {
repairConflict = false; sourceRevision++;
throw new ApiError('The source was updated while choosing a connection.', 409, { code: 'skill_source_settings_changed' });
}
if (input.revision !== undefined && input.revision < sourceRevision) {
throw new ApiError('The source revision changed.', 409, { code: 'skill_source_settings_changed' });
}
sourcePreferences.set(id, { ...sourcePreferences.get(id),
...(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 || input.trackingRef !== undefined) failed = false;
return (await skillSourcesApi.list(COMPANY_ID)).find(source => source.id === id)!;
};
companySkillsApi.detail = async (companyId, id) => {
const skill = await baseSkills.detail(companyId, id);
const hasUpdate = updatedSources.has(String(skill.metadata?.skillSourceId));
return { ...skill, accessSourceId: String(skill.metadata?.skillSourceId), sourceRef: hasUpdate ? NEW_COMMIT : skill.sourceRef,
markdown: hasUpdate ? `${skill.markdown}\nCheck the project access before publishing results.\n` : skill.markdown,
currentVersionId: hasUpdate ? `${skill.currentVersionId}-updated` : skill.currentVersionId };
};
projectsApi.repositoryOptions = baseSources.repositories;
projectsApi.get = async () => structuredClone(project);
projectsApi.list = async () => !projectCreated ? [] : [structuredClone(project)];
projectsApi.setRepositories = async (_id, repositoryIds) => {
const options = await projectsApi.repositoryOptions(COMPANY_ID);
const existingIds = new Set(project.workspaces.map(workspace => workspace.metadata?.githubRepositoryId));
const template = workspaceTemplate;
const workspaces = repositoryIds.map((id, index) => {
const selected = options.repositories.find(option => option.id === id)!;
return { ...template, id: index === 0 ? projectRepository.workspaceId : `workspace-${id}`, name: selected.fullName,
repoUrl: selected.url, metadata: { ...template.metadata, githubRepositoryId: id }, isPrimary: index === 0 };
});
project = { ...project, workspaces, primaryWorkspace: workspaces[0] ?? null };
for (const workspace of workspaces) {
if (existingIds.has(workspace.metadata.githubRepositoryId)) continue;
const row: ProjectSkillRepository = { ...projectRepository, projectName: project.name, workspaceId: workspace.id,
repositoryUrl: workspace.repoUrl, status: 'pending', bindingId: `binding-${workspace.id}` };
if (workspace.id === repository.workspaceId) repository = row;
else additionalRepositories.push(row);
void adopt(row);
}
return structuredClone(project);
};
projectsApi.create = async (_companyId, input) => {
projectCreated = true;
project = { ...project, name: String(input.name), workspaces: [], primaryWorkspace: null };
repository = { ...repository, projectName: project.name };
return projectsApi.setRepositories(project.id, (input.repositoryIds ?? []) as string[]);
};
const skillId = sourceFixture().entries[1]!.skillId!;
let routine = { ...baseRoutine, id: ROUTINE_ID, title: 'Review project changes', projectId: project.id,
project: { id: project.id, name: project.name, description: null, status: 'in_progress' },
description: `Use [/code-review](${buildSkillMentionHref(skillId, 'code-review')}) to review the project changes and report your findings.`, triggers: [], recentRuns: [] as RoutineRunSummary[] };
let executionIssues: Issue[] = [storybookIssues[0]!].map(issue => ({ ...issue, id: 'project-review-before', projectId: project.id,
originKind: 'routine_execution' as const, originId: ROUTINE_ID, title: 'Review project changes', status: 'done' as const,
executionWorkspaceId: null, projectWorkspaceId: null, currentExecutionWorkspace: null, labels: [],
description: 'Completed using the installed code review checklist.' }));
routinesApi.get = async () => structuredClone(routine);
routinesApi.list = async () => [structuredClone({ ...routine, lastRun: routine.recentRuns[0] ?? null })];
routinesApi.listRuns = async () => structuredClone(routine.recentRuns);
routinesApi.activity = async () => [];
routinesApi.listRevisions = async () => [];
routinesApi.update = async (_id, input) => (routine = { ...routine, ...input });
routinesApi.run = async () => {
const now = new Date();
const executionId = `project-review-${routine.recentRuns.length + 1}`;
const resolvedSkill = await companySkillsApi.detail(COMPANY_ID, skillId);
const run: RoutineRunSummary = { id: `review-run-${routine.recentRuns.length}`, companyId: COMPANY_ID, routineId: ROUTINE_ID,
triggerId: null, source: 'manual', status: 'completed', idempotencyKey: null, triggerPayload: null, dispatchFingerprint: null,
linkedIssueId: executionId, coalescedIntoRunId: null, failureReason: null, triggeredAt: now, completedAt: now, createdAt: now, updatedAt: now,
trigger: null, linkedIssue: { id: executionId, identifier: `PAP-${124 + routine.recentRuns.length}`, title: 'Review project changes', status: 'done', priority: 'medium', updatedAt: now } };
routine = { ...routine, recentRuns: [run, ...routine.recentRuns] };
executionIssues = [{ ...executionIssues[0]!, id: executionId, identifier: run.linkedIssue!.identifier,
description: `Completed using code review version ${resolvedSkill.currentVersionId} at commit ${resolvedSkill.sourceRef}.`,
}, ...executionIssues];
return run;
};
const isExecution = (id: string) => executionIssues.some(issue => issue.id === id || issue.identifier === id);
emailApi.thread = async (companyId, id) => isExecution(id) ? null : originalEmailThread(companyId, id);
activityApi.forIssue = async id => isExecution(id) ? [] : originalActivity.forIssue(id);
activityApi.runsForIssue = async id => isExecution(id) ? [] : originalActivity.runsForIssue(id);
heartbeatsApi.liveRunsForIssue = async id => isExecution(id) ? [] : originalHeartbeats.liveRunsForIssue(id);
heartbeatsApi.activeRunForIssue = async id => isExecution(id) ? null : originalHeartbeats.activeRunForIssue(id);
heartbeatsApi.executionForIssue = async id => isExecution(id) ? null : originalHeartbeats.executionForIssue(id);
issuesApi.listComments = async (id, options) => isExecution(id) ? [] : originalIssues.listComments(id, options);
issuesApi.listInteractions = async id => isExecution(id) ? [] : originalIssues.listInteractions(id);
issuesApi.listAttachments = async id => isExecution(id) ? [] : originalIssues.listAttachments(id);
issuesApi.listApprovals = async id => isExecution(id) ? [] : originalIssues.listApprovals(id);
issuesApi.listWorkProducts = async (id, options) => isExecution(id) ? [] : originalIssues.listWorkProducts(id, options);
issuesApi.listFeedbackVotes = async id => isExecution(id) ? [] : originalIssues.listFeedbackVotes(id);
issuesApi.listTreeHolds = async (id, options) => isExecution(id) ? [] : originalIssues.listTreeHolds(id, options);
issuesApi.listAcceptedPlanDecompositions = async id => isExecution(id) ? [] : originalIssues.listAcceptedPlanDecompositions(id);
issuesApi.getQueuedComments = async id => isExecution(id) ? {
issueId: id, queueId: null, state: null, targetRunId: null, revision: 'empty', protocol: 'legacy', steeringDisposition: 'unsupported', entries: [],
} : originalIssues.getQueuedComments(id);
issuesApi.getTreeControlState = async id => isExecution(id) ? { activePauseHold: null } : originalIssues.getTreeControlState(id);
const originalList = originalIssues.list, originalCompact = originalIssues.listCompact;
issuesApi.get = async (id, options) => {
const issue = executionIssues.find(candidate => candidate.id === id || candidate.identifier === id);
return issue ? structuredClone(issue) : originalIssues.get(id, options);
};
issuesApi.list = async (companyId, filters) => filters?.originId === ROUTINE_ID ? structuredClone(executionIssues) : originalList(companyId, filters);
issuesApi.listCompact = async (companyId, filters, options) => filters?.originId === ROUTINE_ID ? structuredClone(executionIssues.map(issue => ({ ...issue, activeRecoveryAction: null, successfulRunHandoff: null }))) : originalCompact(companyId, filters, options);
if (scenario === 'automatic') timers.push(setTimeout(() => { if (live) void refresh().then(invalidate); }, 1800));
return () => {
live = false; timers.forEach(clearTimeout);
Object.assign(activityApi, originalActivity); Object.assign(heartbeatsApi, originalHeartbeats);
emailApi.thread = originalEmailThread;
Object.assign(projectsApi, originalProjects); Object.assign(routinesApi, originalRoutines); Object.assign(issuesApi, originalIssues);
cleanup();
};
}