feat: add GitHub repository connection modal and integrate GitHub app client ID

This commit is contained in:
Nawaz Dhandala 2025-12-28 20:40:55 +00:00
parent 3fa6f9e7b1
commit 8635726344
No known key found for this signature in database
GPG key ID: 96C5DCA24769DBCA
3 changed files with 555 additions and 210 deletions

View file

@ -253,3 +253,6 @@ export const SlackAppClientId: string | null =
export const MicrosoftTeamsAppClientId: string | null =
env("MICROSOFT_TEAMS_APP_CLIENT_ID") || null;
export const GitHubAppClientId: string | null =
env("GITHUB_APP_CLIENT_ID") || null;

View file

@ -0,0 +1,245 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import Modal from "Common/UI/Components/Modal/Modal";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import API from "Common/Utils/API";
import URL from "Common/Types/API/URL";
import { HOME_URL } from "Common/UI/Config";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import ObjectID from "Common/Types/ObjectID";
import { JSONObject } from "Common/Types/JSON";
import HTTPResponse from "Common/Types/API/HTTPResponse";
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import Exception from "Common/Types/Exception/Exception";
import Dropdown, {
DropdownOption,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
export interface GitHubRepository {
id: number;
name: string;
fullName: string;
private: boolean;
htmlUrl: string;
description: string | null;
defaultBranch: string;
ownerLogin: string;
}
export interface ComponentProps {
projectId: ObjectID;
installationId: string;
onClose: () => void;
onSuccess: () => void;
}
const GitHubRepoSelectorModal: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [error, setError] = useState<string | undefined>(undefined);
const [repositories, setRepositories] = useState<Array<GitHubRepository>>([]);
const [selectedRepository, setSelectedRepository] = useState<
GitHubRepository | undefined
>(undefined);
const [customName, setCustomName] = useState<string>("");
const loadRepositories: () => Promise<void> = async (): Promise<void> => {
try {
setIsLoading(true);
setError(undefined);
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.get({
url: URL.fromString(
`${HOME_URL.toString()}/api/github/repositories/${props.projectId.toString()}/${props.installationId}`,
),
headers: ModelAPI.getCommonHeaders(),
});
if (response instanceof HTTPErrorResponse) {
throw response;
}
const repos: Array<GitHubRepository> =
(response.data["repositories"] as unknown as Array<GitHubRepository>) ||
[];
setRepositories(repos);
} catch (e: unknown) {
setError(API.getFriendlyErrorMessage(e as Exception));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadRepositories().catch((e: unknown) => {
setError(API.getFriendlyErrorMessage(e as Exception));
});
}, []);
const getRepositoryDropdownOptions: () => Array<DropdownOption> =
(): Array<DropdownOption> => {
return repositories.map((repo: GitHubRepository) => {
return {
label: repo.fullName + (repo.private ? " (private)" : ""),
value: repo.id.toString(),
};
});
};
const onRepositorySelect: (
value: DropdownValue | Array<DropdownValue> | null,
) => void = (value: DropdownValue | Array<DropdownValue> | null): void => {
if (!value || Array.isArray(value)) {
return;
}
const repo: GitHubRepository | undefined = repositories.find(
(r: GitHubRepository) => {
return r.id.toString() === value?.toString();
},
);
setSelectedRepository(repo);
if (repo) {
setCustomName(repo.fullName);
}
};
const onSubmit: () => Promise<void> = async (): Promise<void> => {
if (!selectedRepository) {
setError("Please select a repository");
return;
}
try {
setIsSaving(true);
setError(undefined);
const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
url: URL.fromString(
`${HOME_URL.toString()}/api/github/repository/connect`,
),
headers: ModelAPI.getCommonHeaders(),
data: {
projectId: props.projectId.toString(),
installationId: props.installationId,
repositoryName: selectedRepository.name,
organizationName: selectedRepository.ownerLogin,
name: customName || selectedRepository.fullName,
defaultBranch: selectedRepository.defaultBranch,
repositoryUrl: selectedRepository.htmlUrl,
} as JSONObject,
});
if (response instanceof HTTPErrorResponse) {
throw response;
}
props.onSuccess();
} catch (e: unknown) {
setError(API.getFriendlyErrorMessage(e as Exception));
} finally {
setIsSaving(false);
}
};
const getSelectedDropdownOption: () => DropdownOption | undefined = ():
| DropdownOption
| undefined => {
if (!selectedRepository) {
return undefined;
}
return {
label:
selectedRepository.fullName +
(selectedRepository.private ? " (private)" : ""),
value: selectedRepository.id.toString(),
};
};
return (
<Modal
title="Connect GitHub Repository"
description="Select a repository from your GitHub App installation to connect."
onClose={props.onClose}
onSubmit={onSubmit}
submitButtonText="Connect Repository"
submitButtonStyleType={ButtonStyleType.PRIMARY}
isBodyLoading={isLoading}
isLoading={isSaving}
error={error}
disableSubmitButton={!selectedRepository}
>
<div className="space-y-4">
{repositories.length === 0 && !isLoading && (
<div className="text-sm text-gray-600">
No repositories found. Make sure the GitHub App has access to at
least one repository.
</div>
)}
{repositories.length > 0 && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Repository
</label>
<Dropdown
options={getRepositoryDropdownOptions()}
onChange={onRepositorySelect}
placeholder="Select a repository"
value={getSelectedDropdownOption()}
/>
</div>
{selectedRepository && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Display Name
</label>
<input
type="text"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
value={customName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setCustomName(e.target.value);
}}
placeholder="Enter a display name"
/>
</div>
<div className="text-sm text-gray-500 space-y-1">
<div>
<strong>Owner:</strong> {selectedRepository.ownerLogin}
</div>
<div>
<strong>Default Branch:</strong>{" "}
{selectedRepository.defaultBranch}
</div>
{selectedRepository.description && (
<div>
<strong>Description:</strong>{" "}
{selectedRepository.description}
</div>
)}
</div>
</>
)}
</>
)}
</div>
</Modal>
);
};
export default GitHubRepoSelectorModal;

View file

@ -9,236 +9,333 @@ import DropdownUtil from "Common/UI/Utils/Dropdown";
import Navigation from "Common/UI/Utils/Navigation";
import Label from "Common/Models/DatabaseModels/Label";
import CodeRepository from "Common/Models/DatabaseModels/CodeRepository";
import React, { FunctionComponent, ReactElement } from "react";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import { FormStep } from "Common/UI/Components/Forms/Types/FormStep";
import { GitHubAppClientId, HOME_URL } from "Common/UI/Config";
import UserUtil from "Common/UI/Utils/User";
import GitHubRepoSelectorModal from "../../Components/CodeRepository/GitHubRepoSelectorModal";
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
import IconProp from "Common/Types/Icon/IconProp";
import Card from "Common/UI/Components/Card/Card";
import ObjectID from "Common/Types/ObjectID";
const CodeRepositoryPage: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const [showGitHubModal, setShowGitHubModal] = useState<boolean>(false);
const [gitHubInstallationId, setGitHubInstallationId] = useState<
string | null
>(null);
const [refreshToggle, setRefreshToggle] = useState<string>("");
const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
useEffect(() => {
// Check for installation_id in URL query params (returned from GitHub OAuth)
const urlParams: URLSearchParams = new URLSearchParams(
window.location.search,
);
const installationId: string | null = urlParams.get("installation_id");
if (installationId) {
setGitHubInstallationId(installationId);
setShowGitHubModal(true);
// Clean up the URL
const newUrl: string = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
}
}, []);
const handleConnectWithGitHub: () => void = (): void => {
if (!projectId) {
return;
}
const userId: ObjectID = UserUtil.getUserId();
// Redirect to GitHub App installation
const installUrl: string = `${HOME_URL.toString()}/api/github/auth/install?projectId=${projectId.toString()}&userId=${userId.toString()}`;
window.location.href = installUrl;
};
const handleGitHubModalClose: () => void = (): void => {
setShowGitHubModal(false);
setGitHubInstallationId(null);
};
const handleGitHubRepoConnected: () => void = (): void => {
setShowGitHubModal(false);
setGitHubInstallationId(null);
// Refresh the table
setRefreshToggle(Date.now().toString());
};
const isGitHubAppConfigured: boolean = Boolean(GitHubAppClientId);
return (
<ModelTable<CodeRepository>
modelType={CodeRepository}
id="code-repository-table"
userPreferencesKey="code-repository-table"
isDeleteable={false}
isEditable={false}
isCreateable={true}
name="Code Repositories"
isViewable={true}
cardProps={{
title: "Code Repositories",
description:
"Connect and manage your GitHub and GitLab repositories here.",
}}
showViewIdButton={true}
noItemsMessage={"No repositories connected."}
formSteps={[
{
title: "Basic Info",
id: "basic-info",
} as FormStep<CodeRepository>,
{
title: "Repository Details",
id: "repository-details",
} as FormStep<CodeRepository>,
{
title: "Labels",
id: "labels",
} as FormStep<CodeRepository>,
]}
formFields={[
{
field: {
name: true,
},
title: "Name",
stepId: "basic-info",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Repository Name",
validation: {
minLength: 2,
},
},
{
field: {
description: true,
},
title: "Description",
stepId: "basic-info",
fieldType: FormFieldSchemaType.LongText,
required: false,
placeholder: "Description",
},
{
field: {
repositoryHostedAt: true,
},
title: "Repository Host",
stepId: "repository-details",
description: "Where is this repository hosted?",
fieldType: FormFieldSchemaType.Dropdown,
required: true,
placeholder: "Select Host",
dropdownOptions:
DropdownUtil.getDropdownOptionsFromEnum(CodeRepositoryType),
},
{
field: {
organizationName: true,
},
title: "Organization / Username",
stepId: "repository-details",
<>
{/* GitHub Connect Button */}
{isGitHubAppConfigured && (
<Card
title="Connect with GitHub"
description="Install the OneUptime GitHub App to automatically import repositories with full access for code analysis and automatic improvements."
>
<div className="flex items-center gap-4">
<Button
title="Connect with GitHub"
icon={IconProp.Link}
buttonStyle={ButtonStyleType.PRIMARY}
onClick={handleConnectWithGitHub}
/>
<span className="text-sm text-gray-500">
Or use the manual form below to add a repository without GitHub
App access.
</span>
</div>
</Card>
)}
<ModelTable<CodeRepository>
modelType={CodeRepository}
id="code-repository-table"
userPreferencesKey="code-repository-table"
isDeleteable={false}
isEditable={false}
isCreateable={true}
name="Code Repositories"
isViewable={true}
refreshToggle={refreshToggle}
cardProps={{
title: "Code Repositories",
description:
"The GitHub organization or username that owns the repository.",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Organization Name",
},
{
field: {
repositoryName: true,
},
title: "Repository Name",
stepId: "repository-details",
description: "The name of the repository.",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Repository Name",
},
{
field: {
mainBranchName: true,
},
title: "Main Branch",
stepId: "repository-details",
description:
"The main branch of the repository (e.g., main, master).",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "main",
},
{
field: {
labels: true,
},
title: "Labels",
stepId: "labels",
description:
"Team members with access to these labels will only be able to access this resource. This is optional and an advanced feature.",
fieldType: FormFieldSchemaType.MultiSelectDropdown,
dropdownModal: {
type: Label,
labelField: "name",
valueField: "_id",
},
required: false,
placeholder: "Labels",
},
]}
showRefreshButton={true}
viewPageRoute={Navigation.getCurrentRoute()}
filters={[
{
field: {
name: true,
},
title: "Name",
type: FieldType.Text,
},
{
field: {
repositoryHostedAt: true,
},
title: "Repository Host",
type: FieldType.Dropdown,
filterDropdownOptions:
DropdownUtil.getDropdownOptionsFromEnum(CodeRepositoryType),
},
{
field: {
organizationName: true,
},
title: "Organization",
type: FieldType.Text,
},
{
field: {
repositoryName: true,
},
title: "Repository",
type: FieldType.Text,
},
{
field: {
labels: {
"Connect and manage your GitHub and GitLab repositories here.",
}}
showViewIdButton={true}
noItemsMessage={"No repositories connected."}
formSteps={[
{
title: "Basic Info",
id: "basic-info",
} as FormStep<CodeRepository>,
{
title: "Repository Details",
id: "repository-details",
} as FormStep<CodeRepository>,
{
title: "Labels",
id: "labels",
} as FormStep<CodeRepository>,
]}
formFields={[
{
field: {
name: true,
color: true,
},
title: "Name",
stepId: "basic-info",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Repository Name",
validation: {
minLength: 2,
},
},
title: "Labels",
type: FieldType.EntityArray,
filterEntityType: Label,
filterQuery: {
projectId: ProjectUtil.getCurrentProjectId()!,
{
field: {
description: true,
},
title: "Description",
stepId: "basic-info",
fieldType: FormFieldSchemaType.LongText,
required: false,
placeholder: "Description",
},
filterDropdownField: {
label: "name",
value: "_id",
{
field: {
repositoryHostedAt: true,
},
title: "Repository Host",
stepId: "repository-details",
description: "Where is this repository hosted?",
fieldType: FormFieldSchemaType.Dropdown,
required: true,
placeholder: "Select Host",
dropdownOptions:
DropdownUtil.getDropdownOptionsFromEnum(CodeRepositoryType),
},
},
]}
columns={[
{
field: {
name: true,
{
field: {
organizationName: true,
},
title: "Organization / Username",
stepId: "repository-details",
description:
"The GitHub organization or username that owns the repository.",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Organization Name",
},
title: "Name",
type: FieldType.Text,
},
{
field: {
repositoryHostedAt: true,
{
field: {
repositoryName: true,
},
title: "Repository Name",
stepId: "repository-details",
description: "The name of the repository.",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Repository Name",
},
title: "Host",
type: FieldType.Text,
},
{
field: {
organizationName: true,
{
field: {
mainBranchName: true,
},
title: "Main Branch",
stepId: "repository-details",
description:
"The main branch of the repository (e.g., main, master).",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "main",
},
title: "Organization",
type: FieldType.Text,
},
{
field: {
repositoryName: true,
{
field: {
labels: true,
},
title: "Labels",
stepId: "labels",
description:
"Team members with access to these labels will only be able to access this resource. This is optional and an advanced feature.",
fieldType: FormFieldSchemaType.MultiSelectDropdown,
dropdownModal: {
type: Label,
labelField: "name",
valueField: "_id",
},
required: false,
placeholder: "Labels",
},
title: "Repository",
type: FieldType.Text,
},
{
field: {
mainBranchName: true,
},
title: "Main Branch",
type: FieldType.Text,
},
{
field: {
labels: {
]}
showRefreshButton={true}
viewPageRoute={Navigation.getCurrentRoute()}
filters={[
{
field: {
name: true,
color: true,
},
title: "Name",
type: FieldType.Text,
},
{
field: {
repositoryHostedAt: true,
},
title: "Repository Host",
type: FieldType.Dropdown,
filterDropdownOptions:
DropdownUtil.getDropdownOptionsFromEnum(CodeRepositoryType),
},
{
field: {
organizationName: true,
},
title: "Organization",
type: FieldType.Text,
},
{
field: {
repositoryName: true,
},
title: "Repository",
type: FieldType.Text,
},
{
field: {
labels: {
name: true,
color: true,
},
},
title: "Labels",
type: FieldType.EntityArray,
filterEntityType: Label,
filterQuery: {
projectId: ProjectUtil.getCurrentProjectId()!,
},
filterDropdownField: {
label: "name",
value: "_id",
},
},
title: "Labels",
type: FieldType.EntityArray,
getElement: (item: CodeRepository): ReactElement => {
return <LabelsElement labels={item["labels"] || []} />;
]}
columns={[
{
field: {
name: true,
},
title: "Name",
type: FieldType.Text,
},
},
]}
/>
{
field: {
repositoryHostedAt: true,
},
title: "Host",
type: FieldType.Text,
},
{
field: {
organizationName: true,
},
title: "Organization",
type: FieldType.Text,
},
{
field: {
repositoryName: true,
},
title: "Repository",
type: FieldType.Text,
},
{
field: {
mainBranchName: true,
},
title: "Main Branch",
type: FieldType.Text,
},
{
field: {
labels: {
name: true,
color: true,
},
},
title: "Labels",
type: FieldType.EntityArray,
getElement: (item: CodeRepository): ReactElement => {
return <LabelsElement labels={item["labels"] || []} />;
},
},
]}
/>
{/* GitHub Repository Selector Modal */}
{showGitHubModal && gitHubInstallationId && projectId && (
<GitHubRepoSelectorModal
projectId={projectId}
installationId={gitHubInstallationId}
onClose={handleGitHubModalClose}
onSuccess={handleGitHubRepoConnected}
/>
)}
</>
);
};