@red-hat-developer-hub/backstage-plugin-bulk-import
Version:
512 lines (509 loc) • 18.4 kB
JavaScript
import { jsxs, jsx } from 'react/jsx-runtime';
import { StatusOK } from '@backstage/core-components';
import Typography from '@mui/material/Typography';
import * as jsyaml from 'js-yaml';
import { get } from 'lodash';
import * as yaml from 'yaml';
import * as yup from 'yup';
import { WaitingForPR } from '../components/WaitingForPR.esm.js';
import { RepositoryStatus, RepositorySelection } from '../types/types.esm.js';
const gitlabFeatureFlag = false;
const descendingComparator = (a, b, orderBy) => {
let value1 = get(a, orderBy);
let value2 = get(b, orderBy);
const order = {
[RepositoryStatus.ADDED]: 1,
[RepositoryStatus.Ready]: 2,
[RepositoryStatus.WAIT_PR_APPROVAL]: 3,
[RepositoryStatus.PR_ERROR]: 4,
[RepositoryStatus.CATALOG_ENTITY_CONFLICT]: 4,
[RepositoryStatus.CATALOG_INFO_FILE_EXISTS_IN_REPO]: 4,
[RepositoryStatus.CODEOWNERS_FILE_NOT_FOUND_IN_REPO]: 4,
[RepositoryStatus.REPO_EMPTY]: 4,
[RepositoryStatus.NotGenerated]: 5
};
if (orderBy === "selectedRepositories") {
value1 = Object.keys(value1)?.length;
value2 = Object.values(value2)?.length;
}
if (orderBy === "catalogInfoYaml.status") {
value1 = order[value1 || RepositoryStatus.NotGenerated];
value2 = order[value2 || RepositoryStatus.NotGenerated];
}
if (value2 < value1) {
return -1;
}
if (value2 > value1) {
return 1;
}
return 0;
};
const getComparator = (order, orderBy) => {
return order === "desc" ? (a, b) => descendingComparator(a, b, orderBy) : (a, b) => -descendingComparator(a, b, orderBy);
};
const defaultCatalogInfoYaml = (componentName, repoName, orgName, owner) => ({
apiVersion: "backstage.io/v1alpha1",
kind: "Component",
metadata: {
name: componentName,
annotations: { "github.com/project-slug": `${orgName}/${repoName}` }
},
spec: { type: "other", lifecycle: "unknown", owner }
});
const componentNameRegex = /^([a-zA-Z0-9]+[-_.])*[a-zA-Z0-9]+$|^[a-zA-Z0-9]{1,63}$/;
const cleanComponentName = (input) => {
const cleanedStr = input.replace(/^[$._-]+|[$._-]+$/g, "");
if (componentNameRegex.test(input)) {
return input;
}
if (componentNameRegex.test(cleanedStr)) {
return cleanedStr;
}
return "my-component";
};
const getPRTemplate = (componentName, orgName, entityOwner, baseUrl, repositoryUrl, defaultBranch) => {
const importJobUrl = repositoryUrl ? `${baseUrl}/bulk-import/repositories?repository=${repositoryUrl}&defaultBranch=${defaultBranch}` : `${baseUrl}/bulk-import/repositories`;
const name = cleanComponentName(componentName);
return {
componentName: name,
entityOwner,
prTitle: "Add catalog-info.yaml config file",
prDescription: `This pull request adds a **Backstage entity metadata file**
to this repository so that the component can
be added to the [software catalog](${baseUrl}/catalog).
After this pull request is merged, the component will become available.
For more information, read an [overview of the Backstage software catalog](https://backstage.io/docs/features/software-catalog/).
View the import job in your app [here](${importJobUrl}).`,
useCodeOwnersFile: false,
yaml: defaultCatalogInfoYaml(name, componentName, orgName, entityOwner)
};
};
const getYamlKeyValuePairs = (prYamlInput) => {
const keyValuePairs = {};
const keyValueEntries = prYamlInput.split(";").map((entry) => entry.trim());
keyValueEntries.forEach((entry) => {
const [key, ...valueParts] = entry.split(":");
const value = valueParts.join(":").trim();
if (key && value) {
keyValuePairs[key.trim()] = value.replace(/(^['"])|(['"]$)/g, "");
}
});
return keyValuePairs;
};
const updateWithNewSelectedRepositories = (existingSelectedRepositories, selectedRepos) => {
return Object.keys(selectedRepos).length === 0 ? {} : Object.keys(selectedRepos).reduce((acc, sr) => {
const existingRepo = existingSelectedRepositories[sr];
if (existingRepo) {
return {
...acc,
...{ [existingRepo.id]: existingRepo }
};
}
return {
...acc,
...{
[sr]: {
...selectedRepos[sr],
catalogInfoYaml: {
...selectedRepos[sr].catalogInfoYaml,
status: RepositoryStatus.Ready
}
}
}
};
}, {});
};
const filterSelectedForActiveDrawer = (repositories, selectedRepos) => {
return Object.keys(selectedRepos).reduce(
(acc, repoId) => repositories?.map((r) => r.id).includes(repoId) ? { ...acc, repoId: selectedRepos[repoId] } : acc,
{}
);
};
const filterSelectedRepositoriesOnActivePage = (activePageTableData, selectedRepositories) => {
return Object.keys(selectedRepositories).filter((repoId) => {
return activePageTableData.some((activeRepo) => activeRepo.id === repoId);
});
};
const urlHelper = (url) => {
if (!url || url === "") {
return "-";
}
return url.split("https://")[1] || url;
};
const getImportStatus = (status, showIcon, prUrl, isApprovalToolGitlab = false) => {
if (!status) {
return "";
}
const labelText = "Added";
switch (status) {
case "WAIT_PR_APPROVAL":
return showIcon ? /* @__PURE__ */ jsx(
WaitingForPR,
{
url: prUrl,
isApprovalToolGitlab
}
) : "Waiting for Approval";
case "ADDED":
return showIcon ? /* @__PURE__ */ jsxs(
Typography,
{
component: "span",
style: { display: "flex", alignItems: "baseline" },
children: [
/* @__PURE__ */ jsx(StatusOK, {}),
"Added"
]
}
) : labelText;
default:
return "";
}
};
const evaluateRowForRepo = (tableData, selectedRepositories) => {
return tableData.map((td) => {
const repo = selectedRepositories[td.id];
if (repo) {
const newtd = {
...td,
catalogInfoYaml: repo.catalogInfoYaml
};
return newtd;
}
return td;
});
};
const evaluateRowForOrg = (tableData, selectedRepositories) => {
return tableData?.map((td) => {
const selectedReposFromOrg = Object.values(selectedRepositories)?.reduce(
(acc, repo) => repo.orgName === td.orgName ? { ...acc, [repo.id]: repo } : acc,
{}
) || [];
const orgRowData = {
...td,
selectedRepositories: selectedReposFromOrg,
...Object.keys(selectedReposFromOrg)?.length > 0 ? {
catalogInfoYaml: {
status: RepositoryStatus.Ready
}
} : {}
};
return orgRowData;
});
};
const areAllRowsSelected = (repositoryType, alreadyAdded, isItemSelected, orgRepositoriesCount, selectedRepositories) => {
return repositoryType === RepositorySelection.Organization ? (Object.keys(selectedRepositories)?.length || 0) + alreadyAdded === orgRepositoriesCount : !!isItemSelected;
};
const getJobErrors = (createJobResponse) => {
return createJobResponse?.reduce(
(acc, res) => {
if (res.errors?.length > 0) {
const errs = res.status === RepositoryStatus.PR_ERROR ? [res.status] : res.errors.filter(
(e) => e !== RepositoryStatus.CATALOG_INFO_FILE_EXISTS_IN_REPO
);
const hasInfo = res.errors.includes(
RepositoryStatus.CATALOG_INFO_FILE_EXISTS_IN_REPO
);
const repoId = `${res.repository.organization}/${res.repository.name}`;
const repoErr = {
[`${repoId}`]: {
repository: {
name: res.repository.name || "",
organization: res.repository.organization || ""
},
catalogEntityName: res.catalogEntityName || "",
error: {
message: errs
}
}
};
const repoInfo = {
[`${repoId}`]: {
...repoErr[`${repoId}`],
error: {
message: [RepositoryStatus.CATALOG_INFO_FILE_EXISTS_IN_REPO]
}
}
};
return {
...acc,
...hasInfo ? { infos: { ...acc.infos, ...repoInfo } } : {},
...errs.length > 0 ? { errors: { ...acc.errors, ...repoErr } } : {}
};
}
return acc;
},
{ infos: null, errors: null }
);
};
const convertKeyValuePairsToString = (keyValuePairs) => {
return keyValuePairs ? Object.entries(keyValuePairs).filter((val) => val).map(([key, value]) => `${key.trim()}: ${value?.trim() || ""}`).join("; ") : "";
};
const prepareDataForSubmission = (repositories, approvalTool) => Object.values(repositories).reduce(
(acc, repo) => {
acc.push({
approvalTool: approvalTool.toLocaleUpperCase(),
codeOwnersFileAsEntityOwner: repo.catalogInfoYaml?.prTemplate?.useCodeOwnersFile || false,
catalogEntityName: repo.catalogInfoYaml?.prTemplate?.componentName || repo?.repoName || "my-component",
repository: {
id: repo.id,
url: repo.repoUrl || "",
name: repo.repoName || "",
organization: repo.orgName || "",
defaultBranch: repo.defaultBranch || ""
},
catalogInfoContent: yaml.stringify(
repo.catalogInfoYaml?.prTemplate?.yaml,
null,
2
),
github: {
pullRequest: {
title: repo.catalogInfoYaml?.prTemplate?.prTitle || "Add catalog-info.yaml config file",
body: repo.catalogInfoYaml?.prTemplate?.prDescription || ""
}
}
});
return acc;
},
[]
);
const getApi = (backendUrl, page, size, searchString, options) => {
if (options?.fetchOrganizations) {
return `${backendUrl}/api/bulk-import/organizations?pagePerIntegration=${page}&sizePerIntegration=${size}&search=${searchString}`;
}
if (options?.orgName) {
return `${backendUrl}/api/bulk-import/organizations/${options.orgName}/repositories?pagePerIntegration=${page}&sizePerIntegration=${size}&search=${searchString}`;
}
return `${backendUrl}/api/bulk-import/repositories?pagePerIntegration=${page}&sizePerIntegration=${size}&search=${searchString}`;
};
const getCustomisedErrorMessage = (status) => {
let message = "";
let showRepositoryLink = false;
status?.forEach((s) => {
if (s === RepositoryStatus.PR_ERROR) {
message = message.concat(
"Couldn't create a new PR due to insufficient permissions. Contact your administrator.",
"\n"
);
showRepositoryLink = true;
}
if (s === RepositoryStatus.CATALOG_INFO_FILE_EXISTS_IN_REPO) {
message = message.concat(
"Since catalog-info.yaml already exists in the repository, no new PR will be created. However, the entity will still be registered in the catalog page.",
"\n"
);
}
if (s === RepositoryStatus.CATALOG_ENTITY_CONFLICT) {
message = message.concat(
"Couldn't create a new PR because of catalog entity conflict.",
"\n"
);
}
if (s === RepositoryStatus.REPO_EMPTY) {
message = message.concat(
"Couldn't create a new PR because the repository is empty. Push an initial commit to the repository.",
"\n"
);
}
if (s === RepositoryStatus.CODEOWNERS_FILE_NOT_FOUND_IN_REPO) {
message = message.concat(
"CODEOWNERS file is missing from the repository. Add a CODEOWNERS file to create a new PR.",
"\n"
);
}
});
if (!message) {
message = status?.join("\n") || "";
}
return { message, showRepositoryLink };
};
const calculateLastUpdated = (dateString) => {
if (!dateString) {
return "";
}
const givenDate = new Date(dateString);
const currentDate = /* @__PURE__ */ new Date();
const diffInMilliseconds = currentDate.getTime() - givenDate.getTime();
const diffInSeconds = Math.floor(diffInMilliseconds / 1e3);
const diffInMinutes = Math.floor(diffInSeconds / 60);
const diffInHours = Math.floor(diffInMinutes / 60);
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays > 0) {
return `${diffInDays} ${diffInDays > 1 ? "days" : "day"} ago`;
}
if (diffInHours > 0) {
return `${diffInHours} ${diffInHours > 1 ? "hours" : "hour"} ago`;
}
if (diffInMinutes > 0) {
return `${diffInMinutes} ${diffInMinutes > 1 ? "minutes" : "minute"} ago`;
}
return `${diffInSeconds} ${diffInSeconds > 1 ? "seconds" : "second"} ago`;
};
const evaluatePRTemplate = (repositoryStatus) => {
try {
const entity = jsyaml.loadAll(
repositoryStatus.github.pullRequest.catalogInfoContent
)[0];
const isInvalid = !entity?.metadata?.name || !entity?.apiVersion || !entity?.kind;
return {
pullReqPreview: {
pullRequestUrl: repositoryStatus.github.pullRequest.url,
prTitle: repositoryStatus.github.pullRequest.title,
prDescription: repositoryStatus.github.pullRequest.body,
prAnnotations: convertKeyValuePairsToString(
entity?.metadata?.annotations
),
prLabels: convertKeyValuePairsToString(entity?.metadata?.labels),
prSpec: convertKeyValuePairsToString(
entity?.spec
),
componentName: entity?.metadata?.name,
entityOwner: entity?.spec?.owner,
useCodeOwnersFile: !entity?.spec?.owner,
yaml: entity
},
isInvalidEntity: isInvalid
};
} catch (e) {
return {
pullReqPreview: {
pullRequestUrl: repositoryStatus.github.pullRequest.url,
prTitle: repositoryStatus.github.pullRequest.title,
prDescription: repositoryStatus.github.pullRequest.body,
prAnnotations: undefined,
prLabels: undefined,
prSpec: undefined,
componentName: undefined,
entityOwner: undefined,
useCodeOwnersFile: false,
yaml: {}
},
isInvalidEntity: true
};
}
};
const prepareDataForOrganizations = (result) => {
const orgData = result?.organizations?.reduce(
(acc, val) => {
return {
...acc,
[val.id]: {
id: val.id,
orgName: val.name,
organizationUrl: `https://github.com/${val?.name}`,
totalReposInOrg: val.totalRepoCount
}
};
},
{}
) || {};
return { organizations: orgData, totalOrganizations: result?.totalCount };
};
const prepareDataForRepositories = (result, user, baseUrl) => {
const repoData = result?.repositories?.reduce((acc, val) => {
const id = val.id;
return {
...acc,
[id]: {
id,
repoName: val.name,
defaultBranch: val.defaultBranch || "main",
orgName: val.organization,
repoUrl: val.url,
organizationUrl: val.url?.substring(
0,
val.url.indexOf(val?.name || "") - 1
),
catalogInfoYaml: {
prTemplate: getPRTemplate(
val.name || "",
val.organization || "",
user,
baseUrl || "",
val.url || "",
val.defaultBranch || "main"
)
}
}
};
}, {}) || {};
return { repositories: repoData, totalRepositories: result?.totalCount };
};
const prepareDataForAddedRepositories = (addedRepositories, user, baseUrl) => {
if (!Array.isArray(addedRepositories?.imports)) {
return { repoData: {}, totalJobs: 0 };
}
const importJobs = addedRepositories;
const repoData = importJobs.imports?.reduce((acc, val) => {
const id = `${val.repository.organization}/${val.repository.name}`;
return {
...acc,
[id]: {
id,
source: val.source,
repoName: val.repository.name,
defaultBranch: val.repository.defaultBranch,
orgName: val.repository.organization,
repoUrl: val.repository.url,
organizationUrl: val?.repository?.url?.substring(
0,
val.repository.url.indexOf(val?.repository?.name || "") - 1
),
catalogInfoYaml: {
status: val.status ? RepositoryStatus[val.status] : RepositoryStatus.NotGenerated,
prTemplate: getPRTemplate(
val.repository.name || "",
val.repository.organization || "",
user,
baseUrl,
val.repository.url || "",
val.repository.defaultBranch || "main"
),
pullRequest: val?.github?.pullRequest?.url || "",
lastUpdated: val.lastUpdate
}
}
};
}, {});
return {
repoData,
totalJobs: addedRepositories?.totalCount || 0
};
};
const validateKeyValuePair = yup.string().nullable().test(
"is-key-value-pair",
"Each entry must have a key and a value separated by a colon.",
(value) => {
if (!value) return true;
const keyValuePairs = value.split(";").map((pair) => pair.trim());
for (const pair of keyValuePairs) {
if (pair) {
const [key, val] = pair.split(":").map((part) => part.trim());
if (!key || !val) {
return false;
}
}
}
return true;
}
);
const getValidationSchema = (approvalTool) => yup.object().shape({
prTitle: yup.string().required(`${approvalTool} title is required`),
prDescription: yup.string().required(`${approvalTool} description is required`),
componentName: yup.string().matches(
componentNameRegex,
`"${yup.string()}" is not valid; expected a string that is sequences of [a-zA-Z0-9] separated by any of [-_.], at most 63 characters in total. To learn more about catalog file format, visit: https://github.com/backstage/backstage/blob/master/docs/architecture-decisions/adr002-default-catalog-file-format.md`
).required("Component name is required"),
useCodeOwnersFile: yup.boolean(),
entityOwner: yup.string().when("useCodeOwnersFile", {
is: false,
then: (schema) => schema.required("Entity Owner is required"),
otherwise: (schema) => schema.notRequired()
}),
prLabels: validateKeyValuePair,
prAnnotations: validateKeyValuePair,
prSpec: validateKeyValuePair
});
export { areAllRowsSelected, calculateLastUpdated, cleanComponentName, componentNameRegex, convertKeyValuePairsToString, defaultCatalogInfoYaml, descendingComparator, evaluatePRTemplate, evaluateRowForOrg, evaluateRowForRepo, filterSelectedForActiveDrawer, filterSelectedRepositoriesOnActivePage, getApi, getComparator, getCustomisedErrorMessage, getImportStatus, getJobErrors, getPRTemplate, getValidationSchema, getYamlKeyValuePairs, gitlabFeatureFlag, prepareDataForAddedRepositories, prepareDataForOrganizations, prepareDataForRepositories, prepareDataForSubmission, updateWithNewSelectedRepositories, urlHelper };
//# sourceMappingURL=repository-utils.esm.js.map