mirror of
https://github.com/paperclipai/paperclip
synced 2026-03-25 11:21:48 +00:00
Add nested import picker
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
|
||||
import {
|
||||
buildDefaultImportSelectionState,
|
||||
buildImportSelectionCatalog,
|
||||
buildSelectedFilesFromImportSelection,
|
||||
renderCompanyImportPreview,
|
||||
renderCompanyImportResult,
|
||||
resolveCompanyImportApiPath,
|
||||
@@ -254,3 +257,167 @@ describe("renderCompanyImportResult", () => {
|
||||
expect(rendered).toContain("Review API keys");
|
||||
});
|
||||
});
|
||||
|
||||
describe("import selection catalog", () => {
|
||||
it("defaults to everything and keeps project selection separate from task selection", () => {
|
||||
const preview: CompanyPortabilityPreviewResult = {
|
||||
include: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: true,
|
||||
},
|
||||
targetCompanyId: "company-123",
|
||||
targetCompanyName: "Imported Co",
|
||||
collisionStrategy: "rename",
|
||||
selectedAgentSlugs: ["ceo"],
|
||||
plan: {
|
||||
companyAction: "create",
|
||||
agentPlans: [],
|
||||
projectPlans: [],
|
||||
issuePlans: [],
|
||||
},
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-03-23T18:00:00.000Z",
|
||||
source: {
|
||||
companyId: "company-src",
|
||||
companyName: "Source Co",
|
||||
},
|
||||
includes: {
|
||||
company: true,
|
||||
agents: true,
|
||||
projects: true,
|
||||
issues: true,
|
||||
skills: true,
|
||||
},
|
||||
company: {
|
||||
path: "COMPANY.md",
|
||||
name: "Source Co",
|
||||
description: null,
|
||||
brandColor: null,
|
||||
logoPath: "images/company-logo.png",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
slug: "ceo",
|
||||
name: "CEO",
|
||||
path: "agents/ceo/AGENT.md",
|
||||
skills: [],
|
||||
role: "ceo",
|
||||
title: null,
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
budgetMonthlyCents: 0,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
key: "skill-a",
|
||||
slug: "skill-a",
|
||||
name: "Skill A",
|
||||
path: "skills/skill-a/SKILL.md",
|
||||
description: null,
|
||||
sourceType: "inline",
|
||||
sourceLocator: null,
|
||||
sourceRef: null,
|
||||
trustLevel: null,
|
||||
compatibility: null,
|
||||
metadata: null,
|
||||
fileInventory: [{ path: "skills/skill-a/helper.md", kind: "doc" }],
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
slug: "alpha",
|
||||
name: "Alpha",
|
||||
path: "projects/alpha/PROJECT.md",
|
||||
description: null,
|
||||
ownerAgentSlug: null,
|
||||
leadAgentSlug: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
status: null,
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
issues: [
|
||||
{
|
||||
slug: "kickoff",
|
||||
identifier: null,
|
||||
title: "Kickoff",
|
||||
path: "projects/alpha/issues/kickoff/TASK.md",
|
||||
projectSlug: "alpha",
|
||||
projectWorkspaceKey: null,
|
||||
assigneeAgentSlug: "ceo",
|
||||
description: null,
|
||||
recurring: false,
|
||||
routine: null,
|
||||
legacyRecurrence: null,
|
||||
status: null,
|
||||
priority: null,
|
||||
labelIds: [],
|
||||
billingCode: null,
|
||||
executionWorkspaceSettings: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
metadata: null,
|
||||
},
|
||||
],
|
||||
envInputs: [],
|
||||
},
|
||||
files: {
|
||||
"COMPANY.md": "# Source Co",
|
||||
"README.md": "# Readme",
|
||||
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||
"images/company-logo.png": {
|
||||
encoding: "base64",
|
||||
data: "",
|
||||
contentType: "image/png",
|
||||
},
|
||||
"projects/alpha/PROJECT.md": "# Alpha",
|
||||
"projects/alpha/notes.md": "project notes",
|
||||
"projects/alpha/issues/kickoff/TASK.md": "# Kickoff",
|
||||
"projects/alpha/issues/kickoff/details.md": "task details",
|
||||
"agents/ceo/AGENT.md": "# CEO",
|
||||
"agents/ceo/prompt.md": "prompt",
|
||||
"skills/skill-a/SKILL.md": "# Skill A",
|
||||
"skills/skill-a/helper.md": "helper",
|
||||
},
|
||||
envInputs: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const catalog = buildImportSelectionCatalog(preview);
|
||||
const state = buildDefaultImportSelectionState(catalog);
|
||||
|
||||
expect(state.company).toBe(true);
|
||||
expect(state.projects.has("alpha")).toBe(true);
|
||||
expect(state.issues.has("kickoff")).toBe(true);
|
||||
expect(state.agents.has("ceo")).toBe(true);
|
||||
expect(state.skills.has("skill-a")).toBe(true);
|
||||
|
||||
state.company = false;
|
||||
state.issues.clear();
|
||||
state.agents.clear();
|
||||
state.skills.clear();
|
||||
|
||||
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
|
||||
|
||||
expect(selectedFiles).toContain(".paperclip.yaml");
|
||||
expect(selectedFiles).toContain("projects/alpha/PROJECT.md");
|
||||
expect(selectedFiles).toContain("projects/alpha/notes.md");
|
||||
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/TASK.md");
|
||||
expect(selectedFiles).not.toContain("projects/alpha/issues/kickoff/details.md");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ interface CompanyImportOptions extends BaseClientOptions {
|
||||
agents?: string;
|
||||
collision?: CompanyCollisionMode;
|
||||
ref?: string;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
@@ -83,6 +84,28 @@ const IMPORT_INCLUDE_OPTIONS: Array<{
|
||||
|
||||
const IMPORT_PREVIEW_SAMPLE_LIMIT = 6;
|
||||
|
||||
type ImportSelectableGroup = "projects" | "issues" | "agents" | "skills";
|
||||
|
||||
type ImportSelectionCatalog = {
|
||||
company: {
|
||||
includedByDefault: boolean;
|
||||
files: string[];
|
||||
};
|
||||
projects: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||
issues: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||
agents: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||
skills: Array<{ key: string; label: string; hint?: string; files: string[] }>;
|
||||
extensionPath: string | null;
|
||||
};
|
||||
|
||||
type ImportSelectionState = {
|
||||
company: boolean;
|
||||
projects: Set<string>;
|
||||
issues: Set<string>;
|
||||
agents: Set<string>;
|
||||
skills: Set<string>;
|
||||
};
|
||||
|
||||
const binaryContentTypeByExtension: Record<string, string> = {
|
||||
".gif": "image/gif",
|
||||
".jpeg": "image/jpeg",
|
||||
@@ -152,46 +175,268 @@ function isInteractiveTerminal(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
||||
function includeToValues(include: CompanyPortabilityInclude): Array<keyof CompanyPortabilityInclude> {
|
||||
return IMPORT_INCLUDE_OPTIONS
|
||||
.map((option) => option.value)
|
||||
.filter((value) => include[value]);
|
||||
function resolveImportInclude(input: string | undefined): CompanyPortabilityInclude {
|
||||
return parseInclude(input, DEFAULT_IMPORT_INCLUDE);
|
||||
}
|
||||
|
||||
async function resolveImportIncludeSelection(
|
||||
input: string | undefined,
|
||||
opts?: { prompt?: boolean },
|
||||
): Promise<CompanyPortabilityInclude> {
|
||||
if (input?.trim()) {
|
||||
return parseInclude(input, DEFAULT_IMPORT_INCLUDE);
|
||||
function normalizePortablePath(filePath: string): string {
|
||||
return filePath.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function findPortableExtensionPath(files: Record<string, CompanyPortabilityFileEntry>): string | null {
|
||||
if (files[".paperclip.yaml"] !== undefined) return ".paperclip.yaml";
|
||||
if (files[".paperclip.yml"] !== undefined) return ".paperclip.yml";
|
||||
return Object.keys(files).find((entry) => entry.endsWith("/.paperclip.yaml") || entry.endsWith("/.paperclip.yml")) ?? null;
|
||||
}
|
||||
|
||||
function collectFilesUnderDirectory(
|
||||
files: Record<string, CompanyPortabilityFileEntry>,
|
||||
directory: string,
|
||||
opts?: { excludePrefixes?: string[] },
|
||||
): string[] {
|
||||
const normalizedDirectory = normalizePortablePath(directory).replace(/\/+$/, "");
|
||||
if (!normalizedDirectory) return [];
|
||||
const prefix = `${normalizedDirectory}/`;
|
||||
const excluded = (opts?.excludePrefixes ?? []).map((entry) => normalizePortablePath(entry).replace(/\/+$/, "")).filter(Boolean);
|
||||
return Object.keys(files)
|
||||
.map(normalizePortablePath)
|
||||
.filter((filePath) => filePath.startsWith(prefix))
|
||||
.filter((filePath) => !excluded.some((excludePrefix) => filePath.startsWith(`${excludePrefix}/`)))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function collectEntityFiles(
|
||||
files: Record<string, CompanyPortabilityFileEntry>,
|
||||
entryPath: string,
|
||||
opts?: { excludePrefixes?: string[] },
|
||||
): string[] {
|
||||
const normalizedPath = normalizePortablePath(entryPath);
|
||||
const directory = normalizedPath.includes("/") ? normalizedPath.slice(0, normalizedPath.lastIndexOf("/")) : "";
|
||||
const selected = new Set<string>([normalizedPath]);
|
||||
if (directory) {
|
||||
for (const filePath of collectFilesUnderDirectory(files, directory, opts)) {
|
||||
selected.add(filePath);
|
||||
}
|
||||
}
|
||||
return Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function buildImportSelectionCatalog(preview: CompanyPortabilityPreviewResult): ImportSelectionCatalog {
|
||||
const selectedAgentSlugs = new Set(preview.selectedAgentSlugs);
|
||||
const companyFiles = new Set<string>();
|
||||
const companyPath = preview.manifest.company?.path ? normalizePortablePath(preview.manifest.company.path) : null;
|
||||
if (companyPath) {
|
||||
companyFiles.add(companyPath);
|
||||
}
|
||||
const readmePath = Object.keys(preview.files).find((entry) => normalizePortablePath(entry) === "README.md");
|
||||
if (readmePath) {
|
||||
companyFiles.add(normalizePortablePath(readmePath));
|
||||
}
|
||||
const logoPath = preview.manifest.company?.logoPath ? normalizePortablePath(preview.manifest.company.logoPath) : null;
|
||||
if (logoPath && preview.files[logoPath] !== undefined) {
|
||||
companyFiles.add(logoPath);
|
||||
}
|
||||
|
||||
if (!opts?.prompt || !isInteractiveTerminal()) {
|
||||
return { ...DEFAULT_IMPORT_INCLUDE };
|
||||
}
|
||||
|
||||
const selection = await p.multiselect<keyof CompanyPortabilityInclude>({
|
||||
message: "What should Paperclip import?",
|
||||
options: IMPORT_INCLUDE_OPTIONS,
|
||||
initialValues: includeToValues(DEFAULT_IMPORT_INCLUDE),
|
||||
required: true,
|
||||
});
|
||||
|
||||
if (p.isCancel(selection)) {
|
||||
p.cancel("Import cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const values = new Set(selection);
|
||||
return {
|
||||
company: values.has("company"),
|
||||
agents: values.has("agents"),
|
||||
projects: values.has("projects"),
|
||||
issues: values.has("issues"),
|
||||
skills: values.has("skills"),
|
||||
company: {
|
||||
includedByDefault: preview.include.company && preview.manifest.company !== null,
|
||||
files: Array.from(companyFiles).sort((left, right) => left.localeCompare(right)),
|
||||
},
|
||||
projects: preview.manifest.projects.map((project) => {
|
||||
const projectPath = normalizePortablePath(project.path);
|
||||
const projectDir = projectPath.includes("/") ? projectPath.slice(0, projectPath.lastIndexOf("/")) : "";
|
||||
return {
|
||||
key: project.slug,
|
||||
label: project.name,
|
||||
hint: project.slug,
|
||||
files: collectEntityFiles(preview.files, projectPath, {
|
||||
excludePrefixes: projectDir ? [`${projectDir}/issues`] : [],
|
||||
}),
|
||||
};
|
||||
}),
|
||||
issues: preview.manifest.issues.map((issue) => ({
|
||||
key: issue.slug,
|
||||
label: issue.title,
|
||||
hint: issue.identifier ?? issue.slug,
|
||||
files: collectEntityFiles(preview.files, normalizePortablePath(issue.path)),
|
||||
})),
|
||||
agents: preview.manifest.agents
|
||||
.filter((agent) => selectedAgentSlugs.size === 0 || selectedAgentSlugs.has(agent.slug))
|
||||
.map((agent) => ({
|
||||
key: agent.slug,
|
||||
label: agent.name,
|
||||
hint: agent.slug,
|
||||
files: collectEntityFiles(preview.files, normalizePortablePath(agent.path)),
|
||||
})),
|
||||
skills: preview.manifest.skills.map((skill) => ({
|
||||
key: skill.slug,
|
||||
label: skill.name,
|
||||
hint: skill.slug,
|
||||
files: collectEntityFiles(preview.files, normalizePortablePath(skill.path)),
|
||||
})),
|
||||
extensionPath: findPortableExtensionPath(preview.files),
|
||||
};
|
||||
}
|
||||
|
||||
function toKeySet(items: Array<{ key: string }>): Set<string> {
|
||||
return new Set(items.map((item) => item.key));
|
||||
}
|
||||
|
||||
export function buildDefaultImportSelectionState(catalog: ImportSelectionCatalog): ImportSelectionState {
|
||||
return {
|
||||
company: catalog.company.includedByDefault,
|
||||
projects: toKeySet(catalog.projects),
|
||||
issues: toKeySet(catalog.issues),
|
||||
agents: toKeySet(catalog.agents),
|
||||
skills: toKeySet(catalog.skills),
|
||||
};
|
||||
}
|
||||
|
||||
function countSelected(state: ImportSelectionState, group: ImportSelectableGroup): number {
|
||||
return state[group].size;
|
||||
}
|
||||
|
||||
function countTotal(catalog: ImportSelectionCatalog, group: ImportSelectableGroup): number {
|
||||
return catalog[group].length;
|
||||
}
|
||||
|
||||
function summarizeGroupSelection(catalog: ImportSelectionCatalog, state: ImportSelectionState, group: ImportSelectableGroup): string {
|
||||
return `${countSelected(state, group)}/${countTotal(catalog, group)} selected`;
|
||||
}
|
||||
|
||||
function getGroupLabel(group: ImportSelectableGroup): string {
|
||||
switch (group) {
|
||||
case "projects":
|
||||
return "Projects";
|
||||
case "issues":
|
||||
return "Tasks";
|
||||
case "agents":
|
||||
return "Agents";
|
||||
case "skills":
|
||||
return "Skills";
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSelectedFilesFromImportSelection(
|
||||
catalog: ImportSelectionCatalog,
|
||||
state: ImportSelectionState,
|
||||
): string[] {
|
||||
const selected = new Set<string>();
|
||||
|
||||
if (state.company) {
|
||||
for (const filePath of catalog.company.files) {
|
||||
selected.add(normalizePortablePath(filePath));
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of ["projects", "issues", "agents", "skills"] as const) {
|
||||
const selectedKeys = state[group];
|
||||
for (const item of catalog[group]) {
|
||||
if (!selectedKeys.has(item.key)) continue;
|
||||
for (const filePath of item.files) {
|
||||
selected.add(normalizePortablePath(filePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.size > 0 && catalog.extensionPath) {
|
||||
selected.add(normalizePortablePath(catalog.extensionPath));
|
||||
}
|
||||
|
||||
return Array.from(selected).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function promptForImportSelection(preview: CompanyPortabilityPreviewResult): Promise<string[]> {
|
||||
const catalog = buildImportSelectionCatalog(preview);
|
||||
const state = buildDefaultImportSelectionState(catalog);
|
||||
|
||||
while (true) {
|
||||
const choice = await p.select<ImportSelectableGroup | "company" | "confirm">({
|
||||
message: "Select what Paperclip should import",
|
||||
options: [
|
||||
{
|
||||
value: "company",
|
||||
label: state.company ? "Company: included" : "Company: skipped",
|
||||
hint: catalog.company.files.length > 0 ? "toggle company metadata" : "no company metadata in package",
|
||||
},
|
||||
{
|
||||
value: "projects",
|
||||
label: "Select Projects",
|
||||
hint: summarizeGroupSelection(catalog, state, "projects"),
|
||||
},
|
||||
{
|
||||
value: "issues",
|
||||
label: "Select Tasks",
|
||||
hint: summarizeGroupSelection(catalog, state, "issues"),
|
||||
},
|
||||
{
|
||||
value: "agents",
|
||||
label: "Select Agents",
|
||||
hint: summarizeGroupSelection(catalog, state, "agents"),
|
||||
},
|
||||
{
|
||||
value: "skills",
|
||||
label: "Select Skills",
|
||||
hint: summarizeGroupSelection(catalog, state, "skills"),
|
||||
},
|
||||
{
|
||||
value: "confirm",
|
||||
label: "Confirm",
|
||||
hint: `${buildSelectedFilesFromImportSelection(catalog, state).length} files selected`,
|
||||
},
|
||||
],
|
||||
initialValue: "confirm",
|
||||
});
|
||||
|
||||
if (p.isCancel(choice)) {
|
||||
p.cancel("Import cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (choice === "confirm") {
|
||||
const selectedFiles = buildSelectedFilesFromImportSelection(catalog, state);
|
||||
if (selectedFiles.length === 0) {
|
||||
p.note("Select at least one import target before confirming.", "Nothing selected");
|
||||
continue;
|
||||
}
|
||||
return selectedFiles;
|
||||
}
|
||||
|
||||
if (choice === "company") {
|
||||
if (catalog.company.files.length === 0) {
|
||||
p.note("This package does not include company metadata to toggle.", "No company metadata");
|
||||
continue;
|
||||
}
|
||||
state.company = !state.company;
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = choice;
|
||||
const groupItems = catalog[group];
|
||||
if (groupItems.length === 0) {
|
||||
p.note(`This package does not include any ${getGroupLabel(group).toLowerCase()}.`, `No ${getGroupLabel(group)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const selection = await p.multiselect<string>({
|
||||
message: `${getGroupLabel(group)} to import. Press enter to go back.`,
|
||||
options: groupItems.map((item) => ({
|
||||
value: item.key,
|
||||
label: item.label,
|
||||
hint: item.hint,
|
||||
})),
|
||||
initialValues: Array.from(state[group]),
|
||||
});
|
||||
|
||||
if (p.isCancel(selection)) {
|
||||
p.cancel("Import cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
state[group] = new Set(selection);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeInclude(include: CompanyPortabilityInclude): string {
|
||||
const labels = IMPORT_INCLUDE_OPTIONS
|
||||
.filter((option) => include[option.value])
|
||||
@@ -814,6 +1059,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
|
||||
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
|
||||
.option("--ref <value>", "Git ref to use for GitHub imports (branch, tag, or commit)")
|
||||
.option("--yes", "Accept the default import selection without opening the TUI", false)
|
||||
.option("--dry-run", "Run preview only without applying", false)
|
||||
.action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => {
|
||||
try {
|
||||
@@ -824,7 +1070,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||
throw new Error("Source path or URL is required.");
|
||||
}
|
||||
|
||||
const include = await resolveImportIncludeSelection(opts.include, { prompt: interactiveView });
|
||||
const include = resolveImportInclude(opts.include);
|
||||
const agents = parseAgents(opts.agents);
|
||||
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
|
||||
if (!["rename", "skip", "replace"].includes(collision)) {
|
||||
@@ -882,6 +1128,26 @@ export function registerCompanyCommands(program: Command): void {
|
||||
|
||||
const sourceLabel = formatSourceLabel(sourcePayload);
|
||||
const targetLabel = formatTargetLabel(targetPayload);
|
||||
const previewApiPath = resolveCompanyImportApiPath({
|
||||
dryRun: true,
|
||||
targetMode: targetPayload.mode,
|
||||
companyId: targetPayload.mode === "existing_company" ? targetPayload.companyId : null,
|
||||
});
|
||||
|
||||
let selectedFiles: string[] | undefined;
|
||||
if (interactiveView && !opts.yes && !opts.include?.trim()) {
|
||||
const initialPreview = await ctx.api.post<CompanyPortabilityPreviewResult>(previewApiPath, {
|
||||
source: sourcePayload,
|
||||
include,
|
||||
target: targetPayload,
|
||||
agents,
|
||||
collisionStrategy: collision,
|
||||
});
|
||||
if (!initialPreview) {
|
||||
throw new Error("Import preview returned no data.");
|
||||
}
|
||||
selectedFiles = await promptForImportSelection(initialPreview);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
source: sourcePayload,
|
||||
@@ -889,6 +1155,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||
target: targetPayload,
|
||||
agents,
|
||||
collisionStrategy: collision,
|
||||
selectedFiles,
|
||||
};
|
||||
const importApiPath = resolveCompanyImportApiPath({
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
|
||||
Reference in New Issue
Block a user