feat(skills): select and validate tracked GitHub branches (#16288)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Skills can track a branch in an authorized GitHub repository.
> - Sources previously offered only a default-branch repair action.
> - People need to select a listed branch or enter a branch that GitHub
has not listed.
> - The server must validate the selected ref with the caller's
authorized connection.
> - This pull request connects the approved branch picker to production
APIs.
> - Installed skills remain usable while the source changes its tracked
ref.

## Linked Issues or Issue Description

**Subsystem affected**

Skills UI, shared API contracts, GitHub source services, and agent API
discovery.

**Problem or motivation**

The default-branch repair action does not let people choose another
branch. The settings service also validates the old ref when a caller
supplies a new ref other than HEAD. A missing tracked branch can prevent
connection repair even when the selected account has repository access.

**Proposed solution**

Load a bounded branch list with the authorized connection. Mark the
default branch. Search listed branches or accept a typed ref. Validate
the selected ref before saving. Keep the chosen value after a load
error, invalid ref, or concurrent update. Save the selected connection
and branch together during connection repair.

**Alternatives considered**

A default-branch button does not support a named branch. A text field
alone does not help people find the repository default or available
branches.

**Roadmap alignment**

This improves the existing Skills source manager. It does not add a new
skill import system.

**Additional context**

Refs #16066 and #16256. This is the production implementation of the
approved branch-selection Storybook journeys.

## What Changed

- Add an authorized branch-list endpoint and shared response/query
types. Register it in agent API discovery.
- Validate the requested ref, including the returned commit SHA.
Preserve source leases, revision checks, project ownership, skill
identities, selections, and installed versions.
- Add the searchable branch picker to Sources and connection repair.
Allow typed refs during branch discovery and after discovery fails.
- Keep save errors in the dialog. Retain the selected branch through a
concurrent source update.
- Use the production components and API client in Storybook. Add
keyboard, touch, loading, retry, invalid-ref, connection-repair, and
concurrent-update browser journeys.
- Update product documentation and the feature/access maps.

## Verification

- Red/green: two new database regressions fail against the previous
service and pass against the implementation.
- Linux database/service suite: 85 tests passed. This includes source
visibility, human/agent authorization, selected connections, immutable
versions, and invalid refs.
- Rebased branch and review fixes: 94 focused UI, route,
branch-validation, and fixture tests passed. Two conflict-message
regressions and the stale-retry fixture regression fail before the fix
and pass after it.
- Storybook build passed. All 26 focused browser journeys passed.
- Repository typecheck and build passed. Final UI typecheck passed after
the review fixes.
- The full local Vitest run was interrupted when the checkout and its
dependencies disappeared. It reported missing working-directory/Postgres
files and worker startup errors. Its two timed-out tests pass after the
checkout was restored. The complete CI suite passed on the final head,
`cf5d4c8f0a`: [CI
run](https://github.com/paperclipai/paperclip/actions/runs/38571068332).
One unchanged workspace timestamp test required a single rerun. Its
release-minus-one-millisecond value can equal the original confirmation
time. The rerun passed.
- Live acceptance passed on implementation head `5dd935bf14` in an
isolated macOS arm64 instance with Node 24.21.0 and an authorized
private GitHub repository. The UI loaded the actual default branch,
saved branch and selected connection together, and preserved the
installed skill/version IDs after reload. An invalid typed ref retained
the draft and showed the provider error. A valid typed HEAD ref then
saved successfully. The owned runtime and database were stopped after
the test. Focused unit, Linux database, and browser suites passed again
after the error-handling review fixes.
- Greptile reviewed final head `cf5d4c8f0a` at 5/5. Both review threads
are resolved; the fresh review has no remaining findings.
- Token gates and map/catalog checks passed.
- Review entry points: `Skills / Project repository skills / 03 Branch
selection components` and `04 Branch selection journeys`. Run `pnpm
storybook` to inspect them.
- Browser command: `pnpm exec playwright test --config
tests/storybook-visual/playwright.config.ts
tests/storybook-visual/skill-source-branch-selection.spec.ts
tests/storybook-visual/project-repository-skills.spec.ts`.
- Manual check: open an independent GitHub source, choose a new branch,
select the default or type a ref, save, and reload. Try an invalid ref
and confirm the selection remains in the dialog. For a project-bound
source, change its ref in project settings.

## Risks

- GitHub discovery is bounded to 100 listed branches. The default branch
is always included. Typed refs remain available.
- A source can change while the dialog is open. The API rejects a stale
revision and the UI keeps the draft for retry.
- A ref change schedules the existing refresh pipeline. Installed
versions remain available until a successful refresh imports new
content.
- No database migration or credential storage change is required.
Existing HEAD requests remain supported.

## Model Used

OpenAI Codex, GPT-6 family. The serving model's exact identifier and
context-window size are not exposed in this session. Used reasoning,
repository tools, code execution, and browser tools.

## 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:
DottaandPaperclip authored and GitHub committed 2026-10-14 09:55:43 -05:00
1 parent d4892d4761
commit df4ee06e2f
29 files changed
+722 -42

No files matched your search

@@ -5,7 +5,7 @@ import { hoistModuleGraph } from "./helpers/hoist-module-graph.js";
const mockCaptureRunIdentity = vi.hoisted(() => vi.fn());
const mockSkillSourceService = vi.hoisted(() => ({ sourceForSkill: vi.fn(), importFromUrl: vi.fn(), discover: vi.fn() }));
const mockSkillSourceService = vi.hoisted(() => ({ sourceForSkill: vi.fn(), importFromUrl: vi.fn(), discover: vi.fn(), branches: vi.fn() }));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
@@ -1344,6 +1344,17 @@ describe("company skill mutation permissions", () => {
}
});
it('lists authorized source branches with a validated connection override', async () => {
const app = createApp({ type: 'board', userId: 'member', companyIds: ['company-1'], source: 'session' });
const connectionId = '00000000-0000-4000-8000-000000000042';
const data = { defaultBranch: 'master', branches: [{ name: 'master', isDefault: true }], truncated: false };
mockSkillSourceService.branches.mockResolvedValue(data);
const path = '/api/companies/company-1/skill-sources/source-id/branches';
expect((await request(app).get(path).query({ connectionId }).expect(200)).body).toEqual(data);
expect(mockSkillSourceService.branches).toHaveBeenCalledWith('company-1', 'source-id', { connectionId }, expect.objectContaining({ read: expect.any(Function) }), expect.any(AbortSignal));
await request(app).get(path).query({ connectionId: 'invalid' }).expect(400);
});
it("rejects cross-company source reads and mutations for board users and agents", async () => {
for (const actor of [
{ type: "board", userId: "member", companyIds: ["company-1"], isInstanceAdmin: true, source: "session" },
@@ -1352,7 +1363,7 @@ describe("company skill mutation permissions", () => {
const app = createApp(actor);
const base = "/api/companies/company-2/skill-sources";
const responses = await Promise.all([
request(app).get(base), request(app).get(`${base}/repositories`), request(app).get(`${base}/source-id`),
request(app).get(base), request(app).get(`${base}/repositories`), request(app).get(`${base}/source-id`), request(app).get(`${base}/source-id/branches`),
request(app).post(`${base}/discover`).send({ repositoryUrl: "https://github.com/acme/skills" }),
request(app).post(`${base}/discover`).set('Accept', 'application/x-ndjson').send({ repositoryUrl: "https://github.com/acme/skills" }),
request(app).post(`${base}/preview`).send({ repositoryUrl: "https://github.com/acme/skills", commitSha: "a".repeat(40), skillPath: "SKILL.md", filePath: "SKILL.md" }),
@@ -466,6 +466,9 @@ describe.skipIf(!support.supported)('automatic project skill sources', () => {
await expect(companySkillService(db).createLocalSkill(companyId, { name: 'Unsafe publication', forkedFromSkillId: skillId, sharingScope: 'public_link' })).rejects.toThrow(/public link/);
});
await withSkillActor(actor('bob'), async () => {
const branchRead = vi.fn(context.read(null));
await expect(skillSourceService(db).branches(companyId, source.id, {}, { ...context, read: () => branchRead })).rejects.toThrow(/not found/);
expect(branchRead).not.toHaveBeenCalled();
await expect(companySkillService(db).importFromSource(companyId, privateSnapshotPath)).rejects.toThrow(/not found/);
expect(await companySkillService(db).getById(companyId, skillId)).toBeNull();
expect(await companySkillService(db).readFile(companyId, skillId, 'SKILL.md')).toBeNull();
@@ -487,6 +490,9 @@ describe.skipIf(!support.supported)('automatic project skill sources', () => {
});
for (const denied of [agentActor(reader!.id, 'bob'), agentActor(outsider!.id, 'alice'), { ...agentActor(reader!.id, 'alice'), companyId: randomUUID() }]) {
await withSkillActor(denied, async () => {
const branchRead = vi.fn(context.read(null));
await expect(skillSourceService(db).branches(companyId, source.id, {}, { ...context, read: () => branchRead })).rejects.toThrow(/not found/);
expect(branchRead).not.toHaveBeenCalled();
expect(await companySkillService(db).readFile(companyId, skillId, 'SKILL.md')).toBeNull();
expect((await companySkillService(db).listRuntimeSkillEntries(companyId)).some(entry => entry.key.includes('private'))).toBe(false);
});
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest';
import { skillSourceSettingsSchema } from '@paperclipai/shared';
import { listSkillSourceBranches } from '../services/skill-source-branches.js';
const source = { fullName: 'acme/skills', repositoryId: '42' };
function reader(branches: unknown, defaultBranch = 'master', id = 42) {
return vi.fn(async (request: string) => request === '/repos/acme/skills' ? { id, default_branch: defaultBranch } : branches);
}
describe('skill source branch discovery', () => {
it('includes the default even when it is outside the bounded first page', async () => {
const read = reader(Array.from({ length: 100 }, (_, index) => ({ name: `feature/${index}` })));
const result = await listSkillSourceBranches(source, read);
expect(result.branches).toHaveLength(101);
expect(result.branches[0]).toEqual({ name: 'master', isDefault: true });
expect(result.truncated).toBe(true);
expect(read).toHaveBeenLastCalledWith('/repos/acme/skills/branches?per_page=100&page=1', undefined);
});
it('deduplicates branch names and preserves case and slashes', async () => {
expect(await listSkillSourceBranches(source, reader([{ name: 'master' }, { name: 'feature/New' }, { name: 'feature/New' }]))).toEqual({
defaultBranch: 'master', branches: [{ name: 'master', isDefault: true }, { name: 'feature/New', isDefault: false }], truncated: false,
});
});
it('stops before listing when the repository identity changed', async () => {
const read = reader([], 'master', 99);
await expect(listSkillSourceBranches(source, read)).rejects.toMatchObject({ status: 409 });
expect(read).toHaveBeenCalledTimes(1);
});
it.each([{ value: null }, { value: {} }, { value: [{ name: 42 }] }, { value: [{ name: 'bad\0branch' }] }])('rejects malformed provider lists without returning their contents', async ({ value }) => {
await expect(listSkillSourceBranches(source, reader(value))).rejects.toMatchObject({ status: 422, message: 'GitHub returned an invalid branch list. Try again.' });
});
it('accepts a typed ref and the existing HEAD alias but rejects empty and control-character values', () => {
expect(skillSourceSettingsSchema.parse({ revision: 2, trackingRef: ' feature/New ' }).trackingRef).toBe('feature/New');
expect(skillSourceSettingsSchema.parse({ revision: 2, trackingRef: 'HEAD' }).trackingRef).toBe('HEAD');
for (const trackingRef of ['', ' ', 'bad\0ref', 'bad\nref']) expect(skillSourceSettingsSchema.safeParse({ revision: 2, trackingRef }).success).toBe(false);
});
});
@@ -61,6 +61,56 @@ describe.skipIf(!support.supported)('skill source persistence', () => {
expect(refreshed.source.trackingRef).toBe('HEAD');
expect(refreshed.source.entries[0]?.skillId).toBe(created.source.entries[0]?.skillId);
});
it('validates the selected branch and retains installed content until a successful refresh', async () => {
const isolatedCompany = randomUUID();
await db.insert(companies).values({ id: isolatedCompany, name: 'Selected branch', issuePrefix: 'SBR' });
const service = skillSourceService(db);
const created = await service.create(isolatedCompany, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'main', commitSha: sha, selectedPaths: ['deep/one/SKILL.md'], excludedFolders: ['elsewhere'] }, context);
const versions = await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, isolatedCompany));
const read = vi.fn(githubFixture(initial));
const repaired = await service.settings(isolatedCompany, created.source.id, { revision: created.source.revision, trackingRef: 'feature/New' }, { ...context, read: () => read });
expect(read).toHaveBeenCalledWith('/repos/acme/skills/commits/feature%2FNew');
expect(repaired.trackingRef).toBe('feature/New');
expect(repaired.entries).toEqual(created.source.entries);
expect(repaired.excludedFolders).toEqual(['elsewhere']);
expect(repaired.lastScanCommit).toBe(created.source.lastScanCommit);
await expect(service.settings(isolatedCompany, created.source.id, { revision: created.source.revision, automaticUpdates: false }, context))
.rejects.toMatchObject({ status: 409, details: { code: 'skill_source_settings_changed' } });
expect(await db.select().from(companySkillVersions).where(eq(companySkillVersions.companyId, isolatedCompany))).toEqual(versions);
const changed = githubFixture({ 'deep/one/SKILL.md': md('new branch version') }, {}, 'f'.repeat(40));
await service.refresh(isolatedCompany, repaired.id, { ...context, read: () => changed });
const skillId = repaired.entries.find(entry => entry.skillId)!.skillId!;
const current = await companySkillService(db).getById(isolatedCompany, skillId);
expect(current!.sourceRef).toBe('f'.repeat(40));
expect(current!.currentVersionId).not.toBe(versions[0]!.id);
expect(await companySkillService(db).getVersion(isolatedCompany, skillId, versions[0]!.id)).not.toBeNull();
});
it('rejects an unavailable selected branch without changing the source or recording a successful audit', async () => {
const isolatedCompany = randomUUID();
await db.insert(companies).values({ id: isolatedCompany, name: 'Unavailable branch', issuePrefix: 'UBR' });
const service = skillSourceService(db);
const created = await service.create(isolatedCompany, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'main', commitSha: sha, selectedPaths: ['deep/one/SKILL.md'] }, context);
const original = githubFixture(initial), audit = vi.fn();
const read = async (request: string) => {
if (request.endsWith('/commits/feature%2Fmissing')) throw unprocessable('Unavailable branch.', { code: 'skill_source_ref_unavailable' });
if (request.endsWith('/commits/..')) return { id: 42, default_branch: 'main' };
return original(request);
};
for (const trackingRef of ['feature/missing', '..']) await expect(service.settings(isolatedCompany, created.source.id, { revision: created.source.revision, trackingRef }, { ...context, audit, read: () => read })).rejects.toMatchObject({ details: { code: 'skill_source_ref_unavailable' } });
expect(await service.detail(isolatedCompany, created.source.id)).toEqual(created.source);
expect(audit).not.toHaveBeenCalled();
});
it('requires source edit authorization before reading branches and uses only the requested connection', async () => {
const service = skillSourceService(db);
const created = await service.create(companyId, { repositoryUrl: 'https://github.com/acme/skills', trackingRef: 'branch-list', commitSha: sha, selectedPaths: [] }, context);
const read = vi.fn(async (request: string) => request === '/repos/acme/skills' ? { id: 42, default_branch: 'main' } : [{ name: 'main' }]);
const connection = randomUUID(), choose = vi.fn(() => read);
const result = await service.branches(companyId, created.source.id, { connectionId: connection }, { ...context, read: choose });
expect(result.defaultBranch).toBe('main'); expect(choose).toHaveBeenCalledWith(connection);
read.mockClear();
await expect(service.branches(companyId, created.source.id, {}, { ...context, read: choose, authorize: async () => { throw unprocessable('Editing denied.'); } })).rejects.toThrow('Editing denied');
expect(read).not.toHaveBeenCalled();
});
it('checks a new commit with an unchanged tree without downloading or replacing versions', async () => {
const originalFiles = files, originalCommit = commit;
try {
+10 -1
View File
@@ -4,7 +4,7 @@ import { skillAccessRequestContext, skillAccessService } from "../services/skill
import { skillSourceService, type SkillSourceContext } from "../services/skill-sources.js";
import { skillSourceGitHubReader } from "../services/skill-source-github-access.js";
import { toolAccessService } from "../services/tool-access.js";
import { skillSourceCreateSchema, skillSourceDiscoverySchema, skillSourcePreviewSchema, skillSourceSelectionSchema, skillSourceSettingsSchema, projectSkillSourceAdoptSchema, skillSourceDisconnectSchema } from "@paperclipai/shared";
import { skillSourceCreateSchema, skillSourceDiscoverySchema, skillSourcePreviewSchema, skillSourceSelectionSchema, skillSourceSettingsSchema, skillSourceBranchesQuerySchema, projectSkillSourceAdoptSchema, skillSourceDisconnectSchema } from "@paperclipai/shared";
import type { ActivityPublication } from "../services/activity-log.js";
import { once } from "node:events";
import type { SkillSourceDiscoveryEvent } from "@paperclipai/shared";
@@ -347,6 +347,15 @@ export function companySkillRoutes(db: Db) {
const companyId = String(req.params.companyId);
res.json(await sourceOperation(req, companyId, context => sourceSvc.settings(companyId, String(req.params.sourceId), req.body, context)));
});
router.get("/companies/:companyId/skill-sources/:sourceId/branches", async (req, res) => {
const companyId = String(req.params.companyId);
const input = skillSourceBranchesQuerySchema.parse({ connectionId: firstQueryString(req.query.connectionId) });
const controller = new AbortController();
const stop = () => controller.abort();
res.once("close", stop);
try { res.json(await sourceOperation(req, companyId, context => sourceSvc.branches(companyId, String(req.params.sourceId), input, context, controller.signal))); }
finally { res.off("close", stop); }
});
router.get("/companies/:companyId/skill-sources/repositories", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
+4 -1
View File
@@ -174,6 +174,7 @@ import {
skillSourceCreateSchema,
skillSourceSelectionSchema,
skillSourceSettingsSchema,
skillSourceBranchesQuerySchema,
projectSkillSourceAdoptSchema,
companySkillCreateSchema,
companySkillFileDeleteSchema,
@@ -12452,6 +12453,7 @@ for (const [method, path, summary] of [
["get", "/api/companies/{companyId}/skill-sources/project-repositories", "List accessible project repositories and skill import status"],
["post", "/api/companies/{companyId}/skill-sources/adopt-project", "Import all skills from an accessible project repository"],
["patch", "/api/companies/{companyId}/skill-sources/{sourceId}/settings", "Set automatic skill updates and authorized refresh connection"],
["get", "/api/companies/{companyId}/skill-sources/{sourceId}/branches", "List authorized repository branches, including the default branch"],
["get", "/api/companies/{companyId}/skill-sources/repositories", "Browse authorized GitHub repositories for skills"],
["get", "/api/companies/{companyId}/skill-sources/{sourceId}", "Get a skill source and entries"],
["post", "/api/companies/{companyId}/skill-sources/discover", "Discover and validate repository skills"],
@@ -12462,7 +12464,8 @@ for (const [method, path, summary] of [
["delete", "/api/companies/{companyId}/skill-sources/{sourceId}", "Disconnect a skill source and retain installed skills"],
] as const) registerCurrentRoute({
method, path, tags: ["skills"], summary,
...(method === "patch" && path.endsWith("/settings") ? { body: skillSourceSettingsSchema }
...(method === "get" && path.endsWith("/branches") ? { query: skillSourceBranchesQuerySchema }
: method === "patch" && path.endsWith("/settings") ? { body: skillSourceSettingsSchema }
: method === "post" && path.endsWith("/adopt-project") ? { body: projectSkillSourceAdoptSchema }
: method === "patch" ? { body: skillSourceSelectionSchema }
: method === "post" && path.endsWith("/discover") ? { body: skillSourceDiscoverySchema }
@@ -0,0 +1,19 @@
import type { SkillSource, SkillSourceBranches } from '@paperclipai/shared';
import { conflict, unprocessable } from '../errors.js';
import type { GitHubRead } from './github-skill-source.js';
const LIMIT = 100;
function branchName(value: unknown): value is string {
return typeof value === 'string' && value.length > 0 && value.length <= 255 && !/[\x00-\x1f\x7f]/.test(value);
}
/** One bounded GitHub list request. The repository default is always included. */
export async function listSkillSourceBranches(source: Pick<SkillSource, 'fullName' | 'repositoryId'>, read: GitHubRead, signal?: AbortSignal): Promise<SkillSourceBranches> {
const repository = await read(`/repos/${source.fullName}`, signal) as { id: number; default_branch: unknown };
if (source.repositoryId && String(repository.id) !== source.repositoryId) throw conflict('The repository at this URL has changed identity. Add it as a new source.');
if (!branchName(repository.default_branch)) throw unprocessable('GitHub returned an invalid default branch. Try again.');
const result = await read(`/repos/${source.fullName}/branches?per_page=${LIMIT}&page=1`, signal);
if (!Array.isArray(result) || result.some(branch => !branch || !branchName(branch.name))) throw unprocessable('GitHub returned an invalid branch list. Try again.');
const names = new Set<string>([repository.default_branch, ...result.slice(0, LIMIT).map(branch => branch.name as string)]);
return { defaultBranch: repository.default_branch, branches: [...names].map(name => ({ name, isDefault: name === repository.default_branch })), truncated: result.length >= LIMIT };
}
+17 -6
View File
@@ -1,5 +1,6 @@
import { reserveAutomaticSkillWork, settleAutomaticSkillWork } from './skill-source-refresh-budget.js';
import { skillSourceRepairAction } from './skill-source-problems.js';
import { listSkillSourceBranches } from './skill-source-branches.js';
import { createHash, randomUUID } from 'node:crypto';
import { skillSourceRetryAt } from './skill-source-retry.js';
import path from 'node:path';
@@ -7,7 +8,7 @@ import { promises as fs } from 'node:fs';
import { folderService } from './folders.js';
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
import { companySkillSources as sources, companySkillSourceEntries as entries, companySkills, companySkillRepositorySnapshots, projectSkillSources, projects, type Db, authUsers } from '@paperclipai/db';
import type { CompanySkill, SkillSource, SkillSourceCreateRequest, SkillSourceDiscoveryRequest, SkillSourceSelectionRequest, SkillSourceRefreshResult, SkillSourcePreviewRequest, SkillSourceSettingsRequest, SkillSourceDisconnectRequest, SkillSourceDisconnectResult } from '@paperclipai/shared';
import type { CompanySkill, SkillSource, SkillSourceCreateRequest, SkillSourceDiscoveryRequest, SkillSourceSelectionRequest, SkillSourceRefreshResult, SkillSourcePreviewRequest, SkillSourceSettingsRequest, SkillSourceDisconnectRequest, SkillSourceDisconnectResult, SkillSourceBranchesQuery } from '@paperclipai/shared';
import { normalizeAgentUrlKey } from '@paperclipai/shared';
import { conflict, notFound, unprocessable } from '../errors.js';
import { companySkillService, parseSkillImportSourceInput } from './company-skills.js';
@@ -295,7 +296,14 @@ export function skillSourceService(db: Db) {
throw error;
}
}
async function branches(companyId: string, id: string, input: SkillSourceBranchesQuery, context: SkillSourceContext, signal?: AbortSignal) {
await skillAccessService(db).assertSource(companyId, id);
const source = await row(companyId, id);
await context.authorize('skills.edit', { sourceType: 'git', sourceLocator: source.repositoryUrl });
return listSkillSourceBranches(source, context.read(input.connectionId ?? source.connectionId), signal);
}
async function settings(companyId: string, id: string, input: SkillSourceSettingsRequest, context: SkillSourceContext) {
await skillAccessService(db).assertSource(companyId, id);
const source = await row(companyId, id);
await context.authorize('skills.edit', { sourceType: 'git', sourceLocator: source.repositoryUrl });
// Validate a replacement connection before binding unattended work to it.
@@ -309,8 +317,11 @@ export function skillSourceService(db: Db) {
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)}`);
const requestedRef = input.trackingRef ?? source.trackingRef;
const ref = requestedRef === 'HEAD' ? repository.default_branch : requestedRef;
if (typeof ref !== 'string' || !ref) throw unprocessable('The selected branch or revision is unavailable.', { code: 'skill_source_ref_unavailable' });
const commit = await reader(`/repos/${source.fullName}/commits/${encodeURIComponent(ref)}`) as { sha?: unknown } | null;
if (!commit || typeof commit.sha !== 'string' || !/^[a-f0-9]{40}$/i.test(commit.sha)) throw unprocessable('The selected branch or revision is unavailable.', { code: 'skill_source_ref_unavailable' });
}
return db.transaction(async tx => {
if (input.trackingRef !== undefined) {
@@ -321,7 +332,7 @@ export function skillSourceService(db: Db) {
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.');
if (duplicate) throw conflict('This repository and branch are already in Sources. Use that source; installed skills were kept.');
}
const [updated] = await tx.update(sources).set({
...(input.automaticUpdates !== undefined ? { automaticUpdates: input.automaticUpdates } : {}),
@@ -332,7 +343,7 @@ export function skillSourceService(db: Db) {
...(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.');
if (!updated) throw conflict('This source is refreshing or changed. Reload before saving.', { code: 'skill_source_settings_changed' });
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);
@@ -435,5 +446,5 @@ export function skillSourceService(db: Db) {
const imported = await Promise.all(result.source.entries.filter(entry => selectedPaths.includes(entry.path) && entry.skillId && !entry.error).map(entry => skills.getById(companyId, entry.skillId!)));
return { imported: imported.filter((skill): skill is CompanySkill => Boolean(skill)), warnings: result.warnings };
}
return { list, detail, sourceForSkill, discover, preview, create, refresh, settings, disconnect, importFromUrl };
return { list, detail, sourceForSkill, discover, preview, create, refresh, branches, settings, disconnect, importFromUrl };
}