mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
fix(export): strip project env values from company packages
This commit is contained in:
parent
1de1393413
commit
48704c6586
4 changed files with 275 additions and 64 deletions
|
|
@ -13,6 +13,7 @@ export interface CompanyPortabilityEnvInput {
|
||||||
key: string;
|
key: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
agentSlug: string | null;
|
agentSlug: string | null;
|
||||||
|
projectSlug: string | null;
|
||||||
kind: "secret" | "plain";
|
kind: "secret" | "plain";
|
||||||
requirement: "required" | "optional";
|
requirement: "required" | "optional";
|
||||||
defaultValue: string | null;
|
defaultValue: string | null;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const portabilityEnvInputSchema = z.object({
|
||||||
key: z.string().min(1),
|
key: z.string().min(1),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
agentSlug: z.string().min(1).nullable(),
|
agentSlug: z.string().min(1).nullable(),
|
||||||
|
projectSlug: z.string().min(1).nullable(),
|
||||||
kind: z.enum(["secret", "plain"]),
|
kind: z.enum(["secret", "plain"]),
|
||||||
requirement: z.enum(["required", "optional"]),
|
requirement: z.enum(["required", "optional"]),
|
||||||
defaultValue: z.string().nullable(),
|
defaultValue: z.string().nullable(),
|
||||||
|
|
|
||||||
|
|
@ -1149,6 +1149,7 @@ describe("company portability", () => {
|
||||||
key: "ANTHROPIC_API_KEY",
|
key: "ANTHROPIC_API_KEY",
|
||||||
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
|
description: "Provide ANTHROPIC_API_KEY for agent claudecoder",
|
||||||
agentSlug: "claudecoder",
|
agentSlug: "claudecoder",
|
||||||
|
projectSlug: null,
|
||||||
kind: "secret",
|
kind: "secret",
|
||||||
requirement: "optional",
|
requirement: "optional",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
|
|
@ -1158,6 +1159,7 @@ describe("company portability", () => {
|
||||||
key: "GH_TOKEN",
|
key: "GH_TOKEN",
|
||||||
description: "Provide GH_TOKEN for agent claudecoder",
|
description: "Provide GH_TOKEN for agent claudecoder",
|
||||||
agentSlug: "claudecoder",
|
agentSlug: "claudecoder",
|
||||||
|
projectSlug: null,
|
||||||
kind: "secret",
|
kind: "secret",
|
||||||
requirement: "optional",
|
requirement: "optional",
|
||||||
defaultValue: "",
|
defaultValue: "",
|
||||||
|
|
@ -1166,6 +1168,128 @@ describe("company portability", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exports project env as portable inputs without concrete values", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
projectSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
description: "Ship it",
|
||||||
|
leadAgentId: "agent-1",
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: "planned",
|
||||||
|
env: {
|
||||||
|
OPENAI_API_KEY: {
|
||||||
|
type: "plain",
|
||||||
|
value: "sk-project-secret",
|
||||||
|
},
|
||||||
|
DOCS_MODE: {
|
||||||
|
type: "plain",
|
||||||
|
value: "strict",
|
||||||
|
},
|
||||||
|
GITHUB_TOKEN: {
|
||||||
|
type: "secret_ref",
|
||||||
|
secretId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
version: "latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
workspaces: [],
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: false,
|
||||||
|
agents: false,
|
||||||
|
projects: true,
|
||||||
|
issues: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||||
|
expect(extension).toContain("OPENAI_API_KEY:");
|
||||||
|
expect(extension).toContain("DOCS_MODE:");
|
||||||
|
expect(extension).toContain("GITHUB_TOKEN:");
|
||||||
|
expect(extension).not.toContain("sk-project-secret");
|
||||||
|
expect(extension).not.toContain('type: "secret_ref"');
|
||||||
|
expect(extension).not.toContain("11111111-1111-1111-1111-111111111111");
|
||||||
|
expect(extension).toContain('default: "strict"');
|
||||||
|
expect(extension).toContain('kind: "secret"');
|
||||||
|
expect(extension).toContain('kind: "plain"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads project env inputs back from .paperclip.yaml during preview import", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
projectSvc.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "project-1",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
description: "Ship it",
|
||||||
|
leadAgentId: "agent-1",
|
||||||
|
targetDate: null,
|
||||||
|
color: null,
|
||||||
|
status: "planned",
|
||||||
|
env: {
|
||||||
|
OPENAI_API_KEY: {
|
||||||
|
type: "plain",
|
||||||
|
value: "sk-project-secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
executionWorkspacePolicy: null,
|
||||||
|
workspaces: [],
|
||||||
|
metadata: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const exported = await portability.exportBundle("company-1", {
|
||||||
|
include: {
|
||||||
|
company: false,
|
||||||
|
agents: false,
|
||||||
|
projects: true,
|
||||||
|
issues: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await portability.previewImport({
|
||||||
|
source: {
|
||||||
|
type: "inline",
|
||||||
|
rootPath: exported.rootPath,
|
||||||
|
files: exported.files,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: false,
|
||||||
|
agents: false,
|
||||||
|
projects: true,
|
||||||
|
issues: false,
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
mode: "new_company",
|
||||||
|
newCompanyName: "Imported Paperclip",
|
||||||
|
},
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.errors).toEqual([]);
|
||||||
|
expect(preview.envInputs).toContainEqual({
|
||||||
|
key: "OPENAI_API_KEY",
|
||||||
|
description: "Optional default for OPENAI_API_KEY on project launch",
|
||||||
|
agentSlug: null,
|
||||||
|
projectSlug: "launch",
|
||||||
|
kind: "secret",
|
||||||
|
requirement: "optional",
|
||||||
|
defaultValue: "",
|
||||||
|
portability: "portable",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
|
it("exports routines as recurring task packages with Paperclip routine extensions", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,83 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
|
||||||
return parsed.success ? parsed.data : null;
|
return parsed.success ? parsed.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractPortableScopedEnvInputs(
|
||||||
|
scope: {
|
||||||
|
label: string;
|
||||||
|
warningPrefix: string;
|
||||||
|
agentSlug: string | null;
|
||||||
|
projectSlug: string | null;
|
||||||
|
},
|
||||||
|
envValue: unknown,
|
||||||
|
warnings: string[],
|
||||||
|
): CompanyPortabilityEnvInput[] {
|
||||||
|
if (!isPlainRecord(envValue)) return [];
|
||||||
|
const env = envValue as Record<string, unknown>;
|
||||||
|
const inputs: CompanyPortabilityEnvInput[] = [];
|
||||||
|
|
||||||
|
for (const [key, binding] of Object.entries(env)) {
|
||||||
|
if (key.toUpperCase() === "PATH") {
|
||||||
|
warnings.push(`${scope.warningPrefix} PATH override was omitted from export because it is system-dependent.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainRecord(binding) && binding.type === "secret_ref") {
|
||||||
|
inputs.push({
|
||||||
|
key,
|
||||||
|
description: `Provide ${key} for ${scope.label}`,
|
||||||
|
agentSlug: scope.agentSlug,
|
||||||
|
projectSlug: scope.projectSlug,
|
||||||
|
kind: "secret",
|
||||||
|
requirement: "optional",
|
||||||
|
defaultValue: "",
|
||||||
|
portability: "portable",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlainRecord(binding) && binding.type === "plain") {
|
||||||
|
const defaultValue = asString(binding.value);
|
||||||
|
const isSensitive = isSensitiveEnvKey(key);
|
||||||
|
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
||||||
|
? "system_dependent"
|
||||||
|
: "portable";
|
||||||
|
if (portability === "system_dependent") {
|
||||||
|
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
|
||||||
|
}
|
||||||
|
inputs.push({
|
||||||
|
key,
|
||||||
|
description: `Optional default for ${key} on ${scope.label}`,
|
||||||
|
agentSlug: scope.agentSlug,
|
||||||
|
projectSlug: scope.projectSlug,
|
||||||
|
kind: isSensitive ? "secret" : "plain",
|
||||||
|
requirement: "optional",
|
||||||
|
defaultValue: isSensitive ? "" : defaultValue ?? "",
|
||||||
|
portability,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof binding === "string") {
|
||||||
|
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
|
||||||
|
if (portability === "system_dependent") {
|
||||||
|
warnings.push(`${scope.warningPrefix} env ${key} default was exported as system-dependent.`);
|
||||||
|
}
|
||||||
|
inputs.push({
|
||||||
|
key,
|
||||||
|
description: `Optional default for ${key} on ${scope.label}`,
|
||||||
|
agentSlug: scope.agentSlug,
|
||||||
|
projectSlug: scope.projectSlug,
|
||||||
|
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
|
||||||
|
requirement: "optional",
|
||||||
|
defaultValue: isSensitiveEnvKey(key) ? "" : binding,
|
||||||
|
portability,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs;
|
||||||
|
}
|
||||||
|
|
||||||
type ResolvedSource = {
|
type ResolvedSource = {
|
||||||
manifest: CompanyPortabilityManifest;
|
manifest: CompanyPortabilityManifest;
|
||||||
files: Record<string, CompanyPortabilityFileEntry>;
|
files: Record<string, CompanyPortabilityFileEntry>;
|
||||||
|
|
@ -1536,68 +1613,33 @@ function extractPortableEnvInputs(
|
||||||
envValue: unknown,
|
envValue: unknown,
|
||||||
warnings: string[],
|
warnings: string[],
|
||||||
): CompanyPortabilityEnvInput[] {
|
): CompanyPortabilityEnvInput[] {
|
||||||
if (!isPlainRecord(envValue)) return [];
|
return extractPortableScopedEnvInputs(
|
||||||
const env = envValue as Record<string, unknown>;
|
{
|
||||||
const inputs: CompanyPortabilityEnvInput[] = [];
|
label: `agent ${agentSlug}`,
|
||||||
|
warningPrefix: `Agent ${agentSlug}`,
|
||||||
|
agentSlug,
|
||||||
|
projectSlug: null,
|
||||||
|
},
|
||||||
|
envValue,
|
||||||
|
warnings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const [key, binding] of Object.entries(env)) {
|
function extractPortableProjectEnvInputs(
|
||||||
if (key.toUpperCase() === "PATH") {
|
projectSlug: string,
|
||||||
warnings.push(`Agent ${agentSlug} PATH override was omitted from export because it is system-dependent.`);
|
envValue: unknown,
|
||||||
continue;
|
warnings: string[],
|
||||||
}
|
): CompanyPortabilityEnvInput[] {
|
||||||
|
return extractPortableScopedEnvInputs(
|
||||||
if (isPlainRecord(binding) && binding.type === "secret_ref") {
|
{
|
||||||
inputs.push({
|
label: `project ${projectSlug}`,
|
||||||
key,
|
warningPrefix: `Project ${projectSlug}`,
|
||||||
description: `Provide ${key} for agent ${agentSlug}`,
|
agentSlug: null,
|
||||||
agentSlug,
|
projectSlug,
|
||||||
kind: "secret",
|
},
|
||||||
requirement: "optional",
|
envValue,
|
||||||
defaultValue: "",
|
warnings,
|
||||||
portability: "portable",
|
);
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlainRecord(binding) && binding.type === "plain") {
|
|
||||||
const defaultValue = asString(binding.value);
|
|
||||||
const isSensitive = isSensitiveEnvKey(key);
|
|
||||||
const portability = defaultValue && isAbsoluteCommand(defaultValue)
|
|
||||||
? "system_dependent"
|
|
||||||
: "portable";
|
|
||||||
if (portability === "system_dependent") {
|
|
||||||
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
|
|
||||||
}
|
|
||||||
inputs.push({
|
|
||||||
key,
|
|
||||||
description: `Optional default for ${key} on agent ${agentSlug}`,
|
|
||||||
agentSlug,
|
|
||||||
kind: isSensitive ? "secret" : "plain",
|
|
||||||
requirement: "optional",
|
|
||||||
defaultValue: isSensitive ? "" : defaultValue ?? "",
|
|
||||||
portability,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof binding === "string") {
|
|
||||||
const portability = isAbsoluteCommand(binding) ? "system_dependent" : "portable";
|
|
||||||
if (portability === "system_dependent") {
|
|
||||||
warnings.push(`Agent ${agentSlug} env ${key} default was exported as system-dependent.`);
|
|
||||||
}
|
|
||||||
inputs.push({
|
|
||||||
key,
|
|
||||||
description: `Optional default for ${key} on agent ${agentSlug}`,
|
|
||||||
agentSlug,
|
|
||||||
kind: isSensitiveEnvKey(key) ? "secret" : "plain",
|
|
||||||
requirement: "optional",
|
|
||||||
defaultValue: binding,
|
|
||||||
portability,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inputs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function jsonEqual(left: unknown, right: unknown): boolean {
|
function jsonEqual(left: unknown, right: unknown): boolean {
|
||||||
|
|
@ -2183,7 +2225,7 @@ function dedupeEnvInputs(values: CompanyPortabilityManifest["envInputs"]) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: CompanyPortabilityManifest["envInputs"] = [];
|
const out: CompanyPortabilityManifest["envInputs"] = [];
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const key = `${value.agentSlug ?? ""}:${value.key.toUpperCase()}`;
|
const key = `${value.agentSlug ?? ""}:${value.projectSlug ?? ""}:${value.key.toUpperCase()}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
out.push(value);
|
out.push(value);
|
||||||
|
|
@ -2240,6 +2282,31 @@ function readAgentEnvInputs(
|
||||||
key,
|
key,
|
||||||
description: asString(record.description) ?? null,
|
description: asString(record.description) ?? null,
|
||||||
agentSlug,
|
agentSlug,
|
||||||
|
projectSlug: null,
|
||||||
|
kind: record.kind === "plain" ? "plain" : "secret",
|
||||||
|
requirement: record.requirement === "required" ? "required" : "optional",
|
||||||
|
defaultValue: typeof record.default === "string" ? record.default : null,
|
||||||
|
portability: record.portability === "system_dependent" ? "system_dependent" : "portable",
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProjectEnvInputs(
|
||||||
|
extension: Record<string, unknown>,
|
||||||
|
projectSlug: string,
|
||||||
|
): CompanyPortabilityManifest["envInputs"] {
|
||||||
|
const inputs = isPlainRecord(extension.inputs) ? extension.inputs : null;
|
||||||
|
const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null;
|
||||||
|
if (!env) return [];
|
||||||
|
|
||||||
|
return Object.entries(env).flatMap(([key, value]) => {
|
||||||
|
if (!isPlainRecord(value)) return [];
|
||||||
|
const record = value as EnvInputRecord;
|
||||||
|
return [{
|
||||||
|
key,
|
||||||
|
description: asString(record.description) ?? null,
|
||||||
|
agentSlug: null,
|
||||||
|
projectSlug,
|
||||||
kind: record.kind === "plain" ? "plain" : "secret",
|
kind: record.kind === "plain" ? "plain" : "secret",
|
||||||
requirement: record.requirement === "required" ? "required" : "optional",
|
requirement: record.requirement === "required" ? "required" : "optional",
|
||||||
defaultValue: typeof record.default === "string" ? record.default : null,
|
defaultValue: typeof record.default === "string" ? record.default : null,
|
||||||
|
|
@ -2546,6 +2613,7 @@ function buildManifestFromPackageFiles(
|
||||||
workspaces,
|
workspaces,
|
||||||
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
|
||||||
});
|
});
|
||||||
|
manifest.envInputs.push(...readProjectEnvInputs(extension, slug));
|
||||||
if (frontmatter.kind && frontmatter.kind !== "project") {
|
if (frontmatter.kind && frontmatter.kind !== "project") {
|
||||||
warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`);
|
warnings.push(`Project markdown ${projectPath} does not declare kind: project in frontmatter.`);
|
||||||
}
|
}
|
||||||
|
|
@ -3153,6 +3221,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
for (const project of selectedProjectRows) {
|
for (const project of selectedProjectRows) {
|
||||||
const slug = projectSlugById.get(project.id)!;
|
const slug = projectSlugById.get(project.id)!;
|
||||||
const projectPath = `projects/${slug}/PROJECT.md`;
|
const projectPath = `projects/${slug}/PROJECT.md`;
|
||||||
|
const envInputsStart = envInputs.length;
|
||||||
|
const exportedEnvInputs = extractPortableProjectEnvInputs(slug, project.env, warnings);
|
||||||
|
envInputs.push(...exportedEnvInputs);
|
||||||
|
const projectEnvInputs = dedupeEnvInputs(
|
||||||
|
envInputs
|
||||||
|
.slice(envInputsStart)
|
||||||
|
.filter((inputValue) => inputValue.projectSlug === slug),
|
||||||
|
);
|
||||||
const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
|
const portableWorkspaces = await buildPortableProjectWorkspaces(slug, project.workspaces, warnings);
|
||||||
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
|
projectWorkspaceKeyByProjectId.set(project.id, portableWorkspaces.workspaceKeyById);
|
||||||
files[projectPath] = buildMarkdown(
|
files[projectPath] = buildMarkdown(
|
||||||
|
|
@ -3168,7 +3244,6 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
targetDate: project.targetDate ?? null,
|
targetDate: project.targetDate ?? null,
|
||||||
color: project.color ?? null,
|
color: project.color ?? null,
|
||||||
status: project.status,
|
status: project.status,
|
||||||
env: normalizePortableProjectEnv(project.env) ?? undefined,
|
|
||||||
executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy(
|
executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy(
|
||||||
slug,
|
slug,
|
||||||
project.executionWorkspacePolicy,
|
project.executionWorkspacePolicy,
|
||||||
|
|
@ -3177,6 +3252,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
) ?? undefined,
|
) ?? undefined,
|
||||||
workspaces: portableWorkspaces.extension,
|
workspaces: portableWorkspaces.extension,
|
||||||
});
|
});
|
||||||
|
if (isPlainRecord(extension) && projectEnvInputs.length > 0) {
|
||||||
|
extension.inputs = {
|
||||||
|
env: buildEnvInputMap(projectEnvInputs),
|
||||||
|
};
|
||||||
|
}
|
||||||
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
|
paperclipProjectsOut[slug] = isPlainRecord(extension) ? extension : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3516,7 +3596,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||||
|
|
||||||
for (const envInput of manifest.envInputs) {
|
for (const envInput of manifest.envInputs) {
|
||||||
if (envInput.portability === "system_dependent") {
|
if (envInput.portability === "system_dependent") {
|
||||||
warnings.push(`Environment input ${envInput.key}${envInput.agentSlug ? ` for ${envInput.agentSlug}` : ""} is system-dependent and may need manual adjustment after import.`);
|
const scope = envInput.agentSlug
|
||||||
|
? ` for agent ${envInput.agentSlug}`
|
||||||
|
: envInput.projectSlug
|
||||||
|
? ` for project ${envInput.projectSlug}`
|
||||||
|
: "";
|
||||||
|
warnings.push(`Environment input ${envInput.key}${scope} is system-dependent and may need manual adjustment after import.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue