feat: add new custom repos and packager submissions UI#12
Conversation
adds support for new custom repositories/packages endpoints
Submissions - List with multi-select, bulk approve/reject/queue/cancel, saved filter views (localStorage, with built-in "Pending reviews" and "Failed today") Maintainers (admin only) - List grouped by username with first-grant-since and auto-queue badge - Detail page per username with revocable per-package policies and recent-review activity
There was a problem hiding this comment.
Pull request overview
Adds a new "Custom" section to the dashboard surfacing custom repos, custom packages, package submissions, and (admin-only) maintainer policies. Backed by two new API clients (CustomClient, MaintainersClient) wired into CachyBuilderClient, plus matching server actions in src/app/actions/custom.ts. Introduces a new PACKAGER user scope, several new audit-log event names, and shared UI primitives (PageHeader, EmptyState, FilterToolbar, StatusDot, Timeline, Tabs, SavedViews, MetadataRow) along with brand/status color tokens and new Button/Badge variants.
Changes:
- New
CustomClientandMaintainersClientwith admin-gated approve/reject/queue and maintainer grant/revoke, plusPACKAGER-or-admin gated submission creation. - New
/dashboard/custom/{repos,packages,submissions,maintainers}routes (with detail pages, bulk actions, saved views, suggestions, and tabs/sidebar entries). - New shared UI primitives, status color tokens,
Buttonxs/brandandBadgestatus variants, and new typings/Zod schemas for the submissions/maintainers domain.
Reviewed changes
Copilot reviewed 46 out of 46 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib/typings.ts | New schemas/enums for custom repos, packages, submissions, maintainers; adds isActionError helper and new audit event names. |
| src/lib/saved-views.ts | New useSavedViews hook backed by localStorage. |
| src/lib/api/index.ts | Wires CustomClient and MaintainersClient into CachyBuilderClient. |
| src/lib/api/custom.ts | New endpoints for submissions, custom repos/packages, and submission lifecycle actions. |
| src/lib/api/maintainers.ts | New admin-only maintainer add/list/revoke endpoints. |
| src/app/actions/custom.ts | Server actions wrapping the new API clients. |
| src/app/actions/{users,stats,audit-logs}.ts | Pure formatting changes (line breaks). |
| src/app/dashboard/custom/* | New section with layout, tabs, list/detail pages for repos, packages, submissions, and maintainers, plus saved-views/bulk-action flows. |
| src/components/custom/* | Dialogs for submit/review/add-maintainer and submission-status helpers. |
| src/components/ui/* | New page-header, empty-state, filter-toolbar, status-dot, timeline, tabs, saved-views, metadata-row; new Button and Badge variants. |
| src/components/app-sidebar.tsx | Adds collapsible "Custom" group (admin-only "Maintainers"). |
| src/app/globals.css | Adds brand/status CSS color tokens (light + dark). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const response = await this.base | ||
| ._fetcher<RevokeMaintainerResponse>({ | ||
| clientHeaders, | ||
| endpoint: `admin/maintainers/${id}`, | ||
| init: {method: 'DELETE'}, | ||
| }) | ||
| .catch(() => ({revoked: false})); | ||
| return parseOrThrow( | ||
| RevokeMaintainerResponseSchema, | ||
| response, | ||
| 'revoke maintainer response' | ||
| ); | ||
| } |
| const handleQueue = useCallback(async () => { | ||
| if (!submission) return; | ||
| setBusy(true); | ||
| const t = toast.loading('Queueing build…'); | ||
| try { | ||
| const r = await queueSubmission(submission.id); | ||
| if (isActionError(r)) { | ||
| toast.error(r.error, {id: t}); | ||
| } else { | ||
| toast.success('Build queued.', {id: t}); | ||
| refresh(); | ||
| } | ||
| } finally { | ||
| setBusy(false); | ||
| } | ||
| }, [submission, refresh]); | ||
|
|
||
| const handleCancel = useCallback(async () => { | ||
| if (!submission) return; | ||
| setBusy(true); | ||
| const t = toast.loading('Cancelling…'); | ||
| try { | ||
| const r = await cancelSubmission(submission.id); | ||
| if (isActionError(r)) { | ||
| toast.error(r.error, {id: t}); | ||
| } else { | ||
| toast.success('Cancelled.', {id: t}); | ||
| refresh(); | ||
| } | ||
| } finally { | ||
| setBusy(false); | ||
| } | ||
| }, [submission, refresh]); |
| export async function getCustomPackages() { | ||
| const {cachyBuilderClient, session} = await getSession(); | ||
| if (!session.isLoggedIn) { | ||
| return redirect('/'); | ||
| } | ||
| try { | ||
| return await cachyBuilderClient.custom.getCustomPackages( | ||
| 1, | ||
| 200, | ||
| await headers() | ||
| ); | ||
| } catch (error) { | ||
| return { | ||
| error: `Failed to get custom packages: ${error instanceof Error ? error.message : 'Unknown error'}`, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| export async function getCustomRepos() { | ||
| const {cachyBuilderClient, session} = await getSession(); | ||
| if (!session.isLoggedIn) { | ||
| return redirect('/'); | ||
| } | ||
| try { | ||
| return await cachyBuilderClient.custom.getCustomRepos( | ||
| 1, | ||
| 200, | ||
| await headers() | ||
| ); | ||
| } catch (error) { | ||
| return { | ||
| error: `Failed to get custom repos: ${error instanceof Error ? error.message : 'Unknown error'}`, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| export async function getMaintainers(currentPage = 1, pageSize = 200) { | ||
| const {cachyBuilderClient, session} = await getSession(); | ||
| if (!session.isLoggedIn) { | ||
| return redirect('/'); | ||
| } | ||
| try { | ||
| return await cachyBuilderClient.maintainers.getMaintainers( | ||
| currentPage, | ||
| pageSize, | ||
| await headers() | ||
| ); | ||
| } catch (error) { | ||
| return { | ||
| error: `Failed to get maintainers: ${error instanceof Error ? error.message : 'Unknown error'}`, | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| export async function getPackageSubmissions(status?: string) { | ||
| const {cachyBuilderClient, session} = await getSession(); | ||
| if (!session.isLoggedIn) { | ||
| return redirect('/'); | ||
| } | ||
| try { | ||
| return await cachyBuilderClient.custom.getPackageSubmissions( | ||
| status, | ||
| 1, | ||
| 200, | ||
| await headers() | ||
| ); | ||
| } catch (error) { | ||
| return { | ||
| error: `Failed to get package submissions: ${error instanceof Error ? error.message : 'Unknown error'}`, | ||
| }; | ||
| } | ||
| } |
| const statusTone: Record<string, StatusTone> = { | ||
| BUILDING: 'building', | ||
| DONE: 'success', | ||
| FAILED: 'danger', | ||
| QUEUED: 'warning', | ||
| SKIPPED: 'muted', | ||
| }; | ||
|
|
||
| const statusVariant: Record< | ||
| string, | ||
| 'building' | 'danger' | 'muted' | 'success' | 'warning' | ||
| > = { | ||
| BUILDING: 'building', | ||
| DONE: 'success', | ||
| FAILED: 'danger', | ||
| QUEUED: 'warning', | ||
| SKIPPED: 'muted', | ||
| }; |
| export function badgeVariantFor(status: string): SubmissionBadgeVariant { | ||
| switch (status) { | ||
| case SubmissionStatus.APPROVED: | ||
| return 'info'; | ||
| case SubmissionStatus.BUILD_DONE: | ||
| return 'success'; | ||
| case SubmissionStatus.BUILD_FAILED: | ||
| return 'danger'; | ||
| case SubmissionStatus.BUILD_QUEUED: | ||
| return 'building'; | ||
| case SubmissionStatus.PENDING_REVIEW: | ||
| return 'warning'; | ||
| case SubmissionStatus.REJECTED: | ||
| return 'destructive'; | ||
| default: | ||
| return 'muted'; | ||
| } |
| export function labelFor(status: string): string { | ||
| return status | ||
| .replace(/_/g, ' ') | ||
| .toLowerCase() | ||
| .replace(/^./, c => c.toUpperCase()) | ||
| .replace(/build /, 'Build '); | ||
| } |
| const canApproveReject = | ||
| isAdmin && status === SubmissionStatus.PENDING_REVIEW; | ||
| const canQueue = isAdmin && status === SubmissionStatus.APPROVED; | ||
| const canCancel = | ||
| status === SubmissionStatus.APPROVED || | ||
| status === SubmissionStatus.BUILD_QUEUED; |
| <div className="relative"> | ||
| <Input | ||
| id="maintainer-pkgbase" | ||
| onChange={e => { | ||
| setPkgbase(e.target.value); | ||
| setSuggestionsOpen(true); | ||
| }} | ||
| onFocus={() => { | ||
| if (filteredSuggestions.length > 0) setSuggestionsOpen(true); | ||
| }} | ||
| placeholder="e.g. my-package" | ||
| ref={pkgbaseInputRef} | ||
| required | ||
| value={pkgbase} | ||
| /> | ||
| {suggestionsOpen && filteredSuggestions.length > 0 && ( | ||
| <div className="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md"> | ||
| {filteredSuggestions.map(suggestion => ( | ||
| <button | ||
| className="w-full px-3 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground" | ||
| key={suggestion} | ||
| onClick={() => { | ||
| setPkgbase(suggestion); | ||
| setSuggestionsOpen(false); | ||
| pkgbaseInputRef.current?.focus(); | ||
| }} | ||
| onMouseDown={e => e.preventDefault()} | ||
| type="button" | ||
| > | ||
| {suggestion} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> |
| <SidebarMenu> | ||
| {customSubItems | ||
| .filter(sub => sub.tab !== 'maintainers' || isAdmin) | ||
| .map(sub => ( | ||
| <SidebarMenuSubItem key={sub.tab}> | ||
| <SidebarMenuSubButton asChild> | ||
| <Link href={`/dashboard/custom/${sub.tab}`}> | ||
| <sub.icon /> | ||
| <span>{sub.name}</span> | ||
| </Link> | ||
| </SidebarMenuSubButton> | ||
| </SidebarMenuSubItem> | ||
| ))} | ||
| </SidebarMenu> |
|
From a quick look nothing jumps out to me. Maybe a bit of repetition, but not sure if it's needed to dry that up. Will be testing a bit locally later to see how it all connects. |
Adds support for new functionality introduced in the API
admins can add some maintainers as trusted which maintain their packages without required review from admins
ones the submission is passed, approved and queued. the builder will queue the build, and push to the repo if configured