Vectrum
Sign In

Let repository admins transfer a repository to an organization

A repository's General settings get a Transfer section: pick an account
you may create repositories in (an organization you're an admin or owner
of, or your own) and the repository moves there under the same name. Its
issues, pull requests, wiki, releases, images, forks and collaborators
come along, since they're kept by the repository's id; only its directory
moves. The new owner stops being a collaborator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent dd295d0commit836f571

Showing 5 changed files with 93 additions and 4 deletions.

+44−2apps/web/app/[owner]/[repo]/settings/forms.tsxView File
@@ -1,10 +1,11 @@
11 "use client";
22
33 import { useActionState, useEffect, useState } from "react";
4- import { Checkbox } from "@unlocalhosted/metalui";
4+ import Link from "next/link";
5+ import { Checkbox, Select } from "@unlocalhosted/metalui";
56 import type { FormState } from "@/app/actions/types";
67 import { DeleteZone } from "@/components/delete-zone";
7- import { BookIcon, GlobeIcon, LockIcon, PencilIcon, UndoIcon, UploadIcon } from "@/components/icons";
8+ import { ArrowRightIcon, BookIcon, GlobeIcon, LockIcon, PencilIcon, UndoIcon, UploadIcon } from "@/components/icons";
89 import { ChoiceCards, TextField } from "@/components/settings-fields";
910 import { PlateBody, PlateFooter } from "@/components/settings-plate";
1011 import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui";
@@ -94,6 +95,47 @@ export function RenameForm({ action, name }: { action: Action; name: string }) {
9495 );
9596 }
9697
98+ /** Moving the repository to another account: one of `owners`, the accounts the viewer may create repositories in. */
99+ export function TransferForm({ action, owners, name }: { action: Action; owners: string[]; name: string }) {
100+ const [state, formAction, pending] = useActionState(action, undefined);
101+ if (owners.length === 0) {
102+ return (
103+ <PlateBody>
104+ <p className="type-body text-ink2">
105+ Move this repository to an organization you’re an admin or owner of. You don’t have one yet:{" "}
106+ <Link href="/organizations/new" className="text-ink underline">
107+ create an organization
108+ </Link>{" "}
109+ first.
110+ </p>
111+ </PlateBody>
112+ );
113+ }
114+ return (
115+ <form action={formAction}>
116+ <PlateBody>
117+ <p className="type-body text-ink2">
118+ Its issues, pull requests, wiki, releases, packages and collaborators move with it, and the new owner’s access rules apply. Clone
119+ URLs and links change to the new owner’s name.
120+ </p>
121+ <div className="flex max-w-xl flex-wrap items-end gap-2">
122+ <div className="flex flex-col gap-1.5">
123+ <span className="type-ui text-ink">New owner</span>
124+ <Select name="owner" aria-label="New owner" defaultValue={owners[0]} options={owners.map((o) => ({ value: o, label: o }))} className="min-w-36" />
125+ </div>
126+ <span aria-hidden className="flex h-8 items-center type-content text-ink3">
127+ / {name}
128+ </span>
129+ <Button icon={<ArrowRightIcon />} disabled={pending}>
130+ {pending ? "Transferring…" : "Transfer"}
131+ </Button>
132+ </div>
133+ {state?.error && <Alert>{state.error}</Alert>}
134+ </PlateBody>
135+ </form>
136+ );
137+ }
138+
97139 export function GeneralForm({
98140 action,
99141 description,
+8−1apps/web/app/[owner]/[repo]/settings/page.tsxView File
@@ -3,20 +3,24 @@ import {
33 deleteRepoAction,
44 removeRepoImageAction,
55 renameRepoAction,
6+ transferRepoAction,
67 updateFeaturesAction,
78 updateRepoAction,
89 updateRepoImageAction,
910 } from "@/app/actions/repo";
1011 import { PlateSection } from "@/components/settings-plate";
1112 import { PageTitle } from "@/components/ui";
13+ import { repoOwnersFor } from "@/lib/orgs";
1214 import { getRepo } from "@/lib/repo";
13- import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm } from "./forms";
15+ import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm, TransferForm } from "./forms";
1416
1517 export const metadata: Metadata = { title: "Settings" };
1618
1719 export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[repo]/settings">) {
1820 const { owner, repo: repoParam } = await params;
1921 const repo = await getRepo(owner, repoParam);
22+ // Where it can move: accounts the viewer may create repositories in, other than its own.
23+ const owners = repo.viewer ? (await repoOwnersFor(repo.viewer)).map((o) => o.username).filter((o) => o !== repo.owner) : [];
2024 return (
2125 <div>
2226 <PageTitle title="General" />
@@ -41,6 +45,9 @@ export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[
4145 <PlateSection title="Features">
4246 <FeaturesForm action={updateFeaturesAction.bind(null, repo.owner, repo.name)} wiki={repo.wikiEnabled} />
4347 </PlateSection>
48+ <PlateSection title="Transfer">
49+ <TransferForm action={transferRepoAction.bind(null, repo.owner, repo.name)} owners={owners} name={repo.name} />
50+ </PlateSection>
4451 <PlateSection title="Danger zone">
4552 <DangerZone action={deleteRepoAction.bind(null, repo.owner, repo.name)} fullName={`${repo.owner}/${repo.name}`} />
4653 </PlateSection>
+17−0apps/web/app/actions/repo.tsView File
@@ -12,6 +12,7 @@ import {
1212 RepoError,
1313 setRepoImage,
1414 setWikiEnabled,
15+ transferRepo,
1516 updateRepo,
1617 type Visibility,
1718 } from "@/lib/repos";
@@ -103,6 +104,22 @@ export async function renameRepoAction(owner: string, name: string, _prev: FormS
103104 redirect(path);
104105 }
105106
107+ /** Moves the repository to an account the viewer may create repositories in: theirs, or an organization they're an admin of. */
108+ export async function transferRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
109+ let path = "";
110+ const result = await attempt(async () => {
111+ const { repo, viewer } = await repoForAction(owner, name, "admin");
112+ const ownerName = String(formData.get("owner") ?? "");
113+ const newOwner = ownerName.toLowerCase() === viewer.username.toLowerCase() ? viewer : await getOrg(ownerName);
114+ if (!newOwner || !(await mayCreateRepos(newOwner, viewer))) throw new ActionError(`You can't move repositories to ${ownerName}.`);
115+ const moved = await transferRepo(repo, newOwner);
116+ revalidatePath("/", "layout");
117+ path = `/${moved.owner}/${moved.name}/settings`;
118+ });
119+ if (result?.error) return result;
120+ redirect(path);
121+ }
122+
106123 export async function deleteRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) {
107124 const result = await attempt(async () => {
108125 const { repo } = await repoForAction(owner, name, "admin");
+23−0apps/web/lib/repos.tsView File
@@ -342,6 +342,29 @@ export async function renameRepo(repo: RepoRecord, newName: string) {
342342 return (await findRepoById(repo.id))!;
343343 }
344344
345+ /**
346+ * Moves a repository to another account (an organization, or a person's) under the same name. Its
347+ * issues, pull requests, wiki, releases, images, forks and collaborators come along: all of them
348+ * are kept by the repository's id. The new owner stops being a collaborator: they own it now.
349+ */
350+ export async function transferRepo(repo: RepoRecord, newOwner: User) {
351+ if (newOwner.id === repo.ownerId) throw new RepoError(`${repo.owner}/${repo.name} already belongs to ${newOwner.username}.`);
352+ if (await findRepo(newOwner.username, repo.name)) throw new RepoError(alreadyHas(newOwner, repo.name));
353+ const newDir = repoPath(newOwner.username, repo.name);
354+ if (await pathExists(newDir)) throw new RepoError(`A repository directory for ${newOwner.username}/${repo.name} already exists on disk.`);
355+ await mkdir(path.dirname(newDir), { recursive: true });
356+ await rename(repo.dir, newDir);
357+ try {
358+ await (await repos()).update(repo.id, { owner: newOwner.id });
359+ } catch (e) {
360+ await rename(newDir, repo.dir);
361+ if (isUniqueViolation(e)) throw new RepoError(alreadyHas(newOwner, repo.name));
362+ throw e;
363+ }
364+ await removeCollaborator(repo.id, newOwner.id);
365+ return (await findRepoById(repo.id))!;
366+ }
367+
345368 /** Deletes the repository and its wiki; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */
346369 export async function deleteRepo(repo: RepoRecord) {
347370 // Pull requests from it to other repositories can't change anymore. Loaded when needed: lib/pulls.ts needs this module.
+1−1docs/features.mdView File
@@ -2,7 +2,7 @@
22
33 - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions)
44 - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password, on the website and in the Android app; SSH keys, API tokens and connected apps work as before
5- - **Repositories**: public or private, rename, delete, optional README on creation
5+ - **Repositories**: public or private, rename, transfer to an organization (or back to your account), delete, optional README on creation
66 - **Collaborators**: give other users push access to a repository
77 - **Organizations**, like on GitHub: accounts that own repositories for a team, at `/<org>/<repo>`. Whoever creates one is its owner and invites people by username (*Settings → Organizations*, or the organization's *Members* settings). Invited people accept or decline from their dashboard. Each member has a role that applies to all of the organization's repositories: *Read* (sees them, private ones too), *Write* (pushes, manages issues and pull requests), *Admin* (also creates repositories, changes their settings and deletes them) or *Owner* (also manages the organization, its members and invitations). Owners change roles and remove members at any time; an organization always keeps at least one owner. Single repositories can still add outside collaborators. Agents can create repositories in organizations where their person is an admin or owner (`create_repository` with `owner`)
88 - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private