Skip to content

feat: add new custom repos and packager submissions UI#12

Open
vnepogodin wants to merge 2 commits into
mainfrom
feat/submissions
Open

feat: add new custom repos and packager submissions UI#12
vnepogodin wants to merge 2 commits into
mainfrom
feat/submissions

Conversation

@vnepogodin
Copy link
Copy Markdown
Member

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

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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CustomClient and MaintainersClient with admin-gated approve/reject/queue and maintainer grant/revoke, plus PACKAGER-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, Button xs/brand and Badge status 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.

Comment on lines +79 to +91
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'
);
}
Comment on lines +72 to +104
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]);
Comment thread src/app/actions/custom.ts
Comment on lines +61 to +132
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'}`,
};
}
}
Comment on lines +29 to +46
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',
};
Comment on lines +64 to +80
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';
}
Comment on lines +20 to +26
export function labelFor(status: string): string {
return status
.replace(/_/g, ' ')
.toLowerCase()
.replace(/^./, c => c.toUpperCase())
.replace(/build /, 'Build ');
}
Comment on lines +37 to +42
const canApproveReject =
isAdmin && status === SubmissionStatus.PENDING_REVIEW;
const canQueue = isAdmin && status === SubmissionStatus.APPROVED;
const canCancel =
status === SubmissionStatus.APPROVED ||
status === SubmissionStatus.BUILD_QUEUED;
Comment on lines +183 to +217
<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>
Comment on lines +132 to +145
<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>
@azdanov
Copy link
Copy Markdown
Member

azdanov commented May 31, 2026

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants