mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## 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>
161 lines
9.2 KiB
TypeScript
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();
|
|
});
|
|
});
|