Files
PaperClipAI/tests/storybook-visual/skill-source-branch-selection.spec.ts
T
DottaandPaperclip df4ee06e2f 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>
2026-10-14 09:55:43 -05:00

161 lines
9.2 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
const story = (name = 'choose-branch', theme = 'dark') => `/iframe.html?id=skills-project-repository-skills-04-branch-selection-journeys--${name}&viewMode=story&globals=theme:${theme}`;
async function openRepair(page: Page) {
await page.getByRole('button', { name: 'Choose a new branch', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Choose a new branch', exact: true });
await expect(dialog).toBeVisible();
return dialog;
}
async function search(page: Page, name: string) {
const picker = page.getByRole('combobox', { name: 'Branch', exact: true });
if (await picker.getAttribute('aria-expanded') !== 'true') await picker.click();
await page.getByPlaceholder('Search or enter a branch name…', { exact: true }).fill(name);
}
test('marks the default branch and saves a searched listed branch without replacing skills', async ({ page }) => {
await page.goto(story());
const source = page.getByRole('region', { name: 'acme/team-skills', exact: true });
const skill = source.getByRole('link', { name: /^Code review / });
const skillHref = await skill.getAttribute('href');
const dialog = await openRepair(page);
await expect(dialog.getByRole('button', { name: 'Change branch', exact: true })).toBeDisabled();
await search(page, '');
await expect(page.getByRole('option', { name: 'master Default', exact: true })).toBeVisible();
await search(page, 'release');
await page.getByRole('option', { name: 'release/2026.10', exact: true }).click();
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
await expect(dialog.getByRole('button', { name: 'Changing branch…', exact: true })).toBeDisabled();
await expect(dialog).toHaveCount(0);
await expect(source.getByText(/release\/2026\.10 · Updated/)).toBeVisible();
await expect(skill).toHaveAttribute('href', skillHref!);
await expect(page.getByRole('button', { name: 'More actions for acme/team-skills', exact: true })).toBeFocused();
});
test('allows an unlisted branch while discovery is still loading', async ({ page }) => {
await page.goto(story('slow-branch-list'));
const dialog = await openRepair(page);
await expect(dialog.getByRole('status')).toContainText('Loading branches');
await search(page, 'feature/not-in-the-list');
await page.getByRole('option', { name: 'Use “feature/not-in-the-list”', exact: true }).click();
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('feature/not-in-the-list');
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText(/feature\/not-in-the-list · Updated/)).toBeVisible();
});
test('retains manual entry when discovery fails', async ({ page }) => {
await page.goto(story('branch-list-unavailable'));
const dialog = await openRepair(page);
await expect(dialog.getByRole('alert')).toContainText('You can still enter a branch name');
await search(page, 'private/release');
await page.getByRole('option', { name: 'Use “private/release”', exact: true }).click();
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText(/private\/release · Updated/)).toBeVisible();
});
test('retries discovery and loads the default branch', async ({ page }) => {
await page.goto(story('branch-list-unavailable'));
const dialog = await openRepair(page);
await expect(dialog.getByRole('alert')).toBeVisible();
await dialog.getByRole('button', { name: 'Try again', exact: true }).click();
await expect(dialog.getByRole('alert')).toHaveCount(0);
await search(page, 'master');
await page.getByRole('option', { name: 'master Default', exact: true }).click();
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
await expect(page.getByText(/master · Updated/)).toBeVisible();
});
test('keeps an invalid selection and permits correction after a save error', async ({ page }) => {
await page.goto(story('validation-failure'));
const dialog = await openRepair(page);
await search(page, 'feature/missing');
await page.getByRole('option', { name: 'Use “feature/missing”', exact: true }).click();
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
await expect(dialog.getByRole('alert')).toContainText('GitHub could not find that branch or revision');
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('feature/missing');
await expect(page.getByText(/main · Updated/)).toBeVisible();
await search(page, 'master');
await page.getByRole('option', { name: 'master Default', exact: true }).click();
await expect(dialog.getByRole('alert')).toHaveCount(0);
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText(/master · Updated/)).toBeVisible();
});
test('keyboard input accepts an unlisted branch and Escape closes only the picker', async ({ page }) => {
await page.goto(story('empty-branch-list'));
const dialog = await openRepair(page);
await search(page, 'feature/keyboard');
const input = page.getByPlaceholder('Search or enter a branch name…', { exact: true });
await input.press('Escape');
await expect(dialog).toBeVisible();
await search(page, 'feature/keyboard');
await input.press('ArrowDown');
await input.press('Enter');
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('feature/keyboard');
await dialog.getByRole('button', { name: 'Change branch', exact: true }).press('Enter');
await expect(dialog).toHaveCount(0);
await expect(page.getByText(/feature\/keyboard · Updated/)).toBeVisible();
});
test('cancel preserves the tracked branch and clears the next draft', async ({ page }) => {
await page.goto(story());
const dialog = await openRepair(page);
await search(page, 'master');
await page.getByRole('option', { name: 'master Default', exact: true }).click();
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText(/main · Updated/)).toBeVisible();
await openRepair(page);
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('Choose a branch');
await expect(dialog.getByRole('button', { name: 'Change branch', exact: true })).toBeDisabled();
});
test('retains a selection across a concurrent source update and retries with the new revision', async ({ page }) => {
await page.goto(story('save-conflict'));
const dialog = await openRepair(page);
await search(page, 'master');
await page.getByRole('option', { name: 'master Default', exact: true }).click();
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
await expect(dialog.getByRole('alert')).toContainText('Your branch is still selected');
await expect(dialog.getByRole('combobox', { name: 'Branch', exact: true })).toContainText('master');
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByText(/master · Updated/)).toBeVisible();
});
test('uses the selected connection when repairing an unavailable branch', async ({ page }) => {
await page.goto(story('connection-repair'));
await page.getByRole('button', { name: 'Choose connection', exact: true }).click();
await page.getByRole('button', { name: 'Save connection', exact: true }).click();
await page.getByRole('button', { name: 'Choose a new branch', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Choose a new branch', exact: true });
await search(page, 'master');
await page.getByRole('option', { name: 'master Default', exact: true }).click();
await dialog.getByRole('button', { name: 'Change branch', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole('dialog', { name: 'Choose GitHub connection', exact: true })).toHaveCount(0);
await expect(page.getByText(/master · Updated/)).toBeVisible();
});
test.describe('mobile', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true });
test('selects a long typed branch using touch without horizontal overflow', async ({ page }) => {
await page.goto(story('choose-branch', 'light'));
await page.getByRole('button', { name: 'Choose a new branch', exact: true }).tap();
const dialog = page.getByRole('dialog', { name: 'Choose a new branch', exact: true });
const branch = 'feature/platform-services-new-skill-refresh-and-accessibility';
await search(page, branch);
await page.getByRole('option', { name: `Use “${branch}”`, exact: true }).tap();
await expect(dialog.getByRole('button', { name: 'Change branch', exact: true })).toBeInViewport();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await dialog.getByRole('button', { name: 'Change branch', exact: true }).tap();
await expect(dialog).toHaveCount(0);
await expect(page.getByText(new RegExp(`${branch} · Updated`))).toBeVisible();
});
});