feat: Add Code Repository management features

- Implement CodeRepositoryService for database interactions.
- Enhance GitHub utility functions for app authentication and repository management.
- Introduce new permissions for Code Repository actions (create, delete, edit, read).
- Create Code Repository pages and routes in the dashboard.
- Add side menu and breadcrumbs for Code Repository navigation.
- Implement settings and delete functionality for Code Repositories.
- Update Helm chart to include GitHub App configuration options.
- Modify example environment configuration to include GitHub App credentials.
This commit is contained in:
Nawaz Dhandala
2025-12-12 22:37:01 +00:00
parent 7c06b22e9d
commit f65197a0bf
28 changed files with 2188 additions and 2 deletions

View File

@@ -497,6 +497,7 @@ import ScheduledMaintenanceFeedService, {
import SlackAPI from "Common/Server/API/SlackAPI";
import MicrosoftTeamsAPI from "Common/Server/API/MicrosoftTeamsAPI";
import GitHubAPI from "Common/Server/API/GitHubAPI";
import WorkspaceProjectAuthToken from "Common/Models/DatabaseModels/WorkspaceProjectAuthToken";
import WorkspaceProjectAuthTokenService, {
@@ -1628,6 +1629,7 @@ const BaseAPIFeatureSet: FeatureSet = {
`/${APP_NAME.toLocaleLowerCase()}`,
new MicrosoftTeamsAPI().getRouter(),
);
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new GitHubAPI().getRouter());
app.use(
`/${APP_NAME.toLocaleLowerCase()}`,
new GlobalConfigAPI().getRouter(),

View File

@@ -0,0 +1,664 @@
import Label from "./Label";
import Project from "./Project";
import User from "./User";
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
import Route from "../../Types/API/Route";
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
import AccessControlColumn from "../../Types/Database/AccessControlColumn";
import ColumnLength from "../../Types/Database/ColumnLength";
import ColumnType from "../../Types/Database/ColumnType";
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
import EnableDocumentation from "../../Types/Database/EnableDocumentation";
import EnableWorkflow from "../../Types/Database/EnableWorkflow";
import SlugifyColumn from "../../Types/Database/SlugifyColumn";
import TableColumn from "../../Types/Database/TableColumn";
import TableColumnType from "../../Types/Database/TableColumnType";
import TableMetadata from "../../Types/Database/TableMetadata";
import TenantColumn from "../../Types/Database/TenantColumn";
import UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
import IconProp from "../../Types/Icon/IconProp";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import CodeRepositoryType from "../../Types/CodeRepository/CodeRepositoryType";
import URL from "../../Types/API/URL";
import {
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
} from "typeorm";
@AccessControlColumn("labels")
@EnableDocumentation()
@TenantColumn("projectId")
@TableBillingAccessControl({
create: PlanType.Growth,
read: PlanType.Growth,
update: PlanType.Growth,
delete: PlanType.Growth,
})
@TableAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
delete: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.DeleteCodeRepository,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditCodeRepository,
],
})
@EnableWorkflow({
create: true,
delete: true,
update: true,
read: true,
})
@CrudApiEndpoint(new Route("/code-repository"))
@SlugifyColumn("name", "slug")
@TableMetadata({
tableName: "CodeRepository",
singularName: "Code Repository",
pluralName: "Code Repositories",
icon: IconProp.Code,
tableDescription:
"Connect and manage code repositories from GitHub, GitLab, and other providers",
})
@Entity({
name: "CodeRepository",
})
export default class CodeRepository extends BaseModel {
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "projectId",
type: TableColumnType.Entity,
modelType: Project,
title: "Project",
description: "Relation to Project Resource in which this object belongs",
})
@ManyToOne(
() => {
return Project;
},
{
eager: false,
nullable: true,
onDelete: "CASCADE",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "projectId" })
public project?: Project = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@Index()
@TableColumn({
type: TableColumnType.ObjectID,
required: true,
canReadOnRelationQuery: true,
title: "Project ID",
description: "ID of your OneUptime Project in which this object belongs",
example: "5f8b9c0d-e1a2-4b3c-8d5e-6f7a8b9c0d1e",
})
@Column({
type: ColumnType.ObjectID,
nullable: false,
transformer: ObjectID.getDatabaseTransformer(),
})
public projectId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditCodeRepository,
],
})
@TableColumn({
required: true,
type: TableColumnType.ShortText,
canReadOnRelationQuery: true,
title: "Name",
description: "A friendly name for this code repository",
example: "My Backend API",
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
@UniqueColumnBy("projectId")
public name?: string = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
required: true,
unique: true,
type: TableColumnType.Slug,
computed: true,
title: "Slug",
description: "Friendly globally unique name for your object",
})
@Column({
nullable: false,
type: ColumnType.Slug,
length: ColumnLength.Slug,
})
public slug?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditCodeRepository,
],
})
@TableColumn({
required: false,
type: TableColumnType.LongText,
title: "Description",
description: "A description of this code repository",
example: "Main backend API service for user authentication and data access",
})
@Column({
nullable: true,
type: ColumnType.LongText,
length: ColumnLength.LongText,
})
public description?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
required: true,
type: TableColumnType.ShortText,
canReadOnRelationQuery: true,
title: "Repository Hosted At",
description: "Where is this repository hosted (GitHub, GitLab, etc.)",
example: "GitHub",
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public repositoryHostedAt?: CodeRepositoryType = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
required: true,
type: TableColumnType.ShortText,
canReadOnRelationQuery: true,
title: "Organization Name",
description: "GitHub organization or username that owns this repository",
example: "my-organization",
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public organizationName?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
required: true,
type: TableColumnType.ShortText,
canReadOnRelationQuery: true,
title: "Repository Name",
description: "The name of the repository",
example: "my-backend-api",
})
@Column({
nullable: false,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
})
public repositoryName?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditCodeRepository,
],
})
@TableColumn({
required: false,
type: TableColumnType.ShortText,
canReadOnRelationQuery: true,
title: "Main Branch Name",
description: "The name of the main/default branch",
example: "main",
})
@Column({
nullable: true,
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
default: "main",
})
public mainBranchName?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.LongURL,
canReadOnRelationQuery: true,
title: "Repository URL",
description: "The HTTPS URL to the repository",
example: "https://github.com/my-organization/my-backend-api",
})
@Column({
nullable: true,
type: ColumnType.LongURL,
transformer: URL.getDatabaseTransformer(),
})
public repositoryUrl?: URL = undefined;
// GitHub App specific fields
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.LongText,
canReadOnRelationQuery: false,
title: "GitHub App Installation ID",
description:
"The GitHub App installation ID used to authenticate with this repository",
example: "12345678",
})
@Column({
nullable: true,
type: ColumnType.LongText,
length: ColumnLength.LongText,
})
public gitHubAppInstallationId?: string = undefined;
// GitLab specific fields
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
required: false,
type: TableColumnType.LongText,
canReadOnRelationQuery: false,
title: "GitLab Project ID",
description: "The GitLab project ID for this repository",
example: "12345678",
})
@Column({
nullable: true,
type: ColumnType.LongText,
length: ColumnLength.LongText,
})
public gitLabProjectId?: string = undefined;
// Webhook secret for verifying incoming webhooks
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.CreateCodeRepository,
],
read: [],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.EditCodeRepository,
],
})
@TableColumn({
required: false,
type: TableColumnType.VeryLongText,
canReadOnRelationQuery: false,
title: "Secret Token",
description: "Secret token used to verify incoming webhooks",
})
@Column({
nullable: true,
type: ColumnType.VeryLongText,
})
public secretToken?: string = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "createdByUserId",
type: TableColumnType.Entity,
modelType: User,
title: "Created by User",
description:
"Relation to User who created this object (if this object was created by a User)",
})
@ManyToOne(
() => {
return User;
},
{
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "createdByUserId" })
public createdByUser?: User = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
type: TableColumnType.ObjectID,
title: "Created by User ID",
description:
"User ID who created this object (if this object was created by a User)",
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public createdByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
manyToOneRelationColumn: "deletedByUserId",
type: TableColumnType.Entity,
title: "Deleted by User",
modelType: User,
description:
"Relation to User who deleted this object (if this object was deleted by a User)",
})
@ManyToOne(
() => {
return User;
},
{
cascade: false,
eager: false,
nullable: true,
onDelete: "SET NULL",
orphanedRowAction: "nullify",
},
)
@JoinColumn({ name: "deletedByUserId" })
public deletedByUser?: User = undefined;
@ColumnAccessControl({
create: [],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [],
})
@TableColumn({
type: TableColumnType.ObjectID,
title: "Deleted by User ID",
description:
"User ID who deleted this object (if this object was deleted by a User)",
example: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
})
@Column({
type: ColumnType.ObjectID,
nullable: true,
transformer: ObjectID.getDatabaseTransformer(),
})
public deletedByUserId?: ObjectID = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateCodeRepository,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadCodeRepository,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditCodeRepository,
],
})
@TableColumn({
required: false,
type: TableColumnType.EntityArray,
modelType: Label,
title: "Labels",
description:
"Relation to Labels Array where this object is categorized in.",
})
@ManyToMany(
() => {
return Label;
},
{ eager: false },
)
@JoinTable({
name: "CodeRepositoryLabel",
inverseJoinColumn: {
name: "labelId",
referencedColumnName: "_id",
},
joinColumn: {
name: "codeRepositoryId",
referencedColumnName: "_id",
},
})
public labels?: Array<Label> = undefined;
}

View File

@@ -96,6 +96,7 @@ import ScheduledMaintenanceStateTimeline from "./ScheduledMaintenanceStateTimeli
import ServiceCatalog from "./ServiceCatalog";
import ServiceCatalogOwnerTeam from "./ServiceCatalogOwnerTeam";
import ServiceCatalogOwnerUser from "./ServiceCatalogOwnerUser";
import CodeRepository from "./CodeRepository";
// Short link.
import ShortLink from "./ShortLink";
// SMS
@@ -369,6 +370,9 @@ const AllModelTypes: Array<{
ServiceCatalogMonitor,
ServiceCatalogTelemetryService,
// Code Repository
CodeRepository,
ProbeOwnerTeam,
ProbeOwnerUser,

View File

@@ -0,0 +1,330 @@
import Express, {
ExpressRequest,
ExpressResponse,
ExpressRouter,
} from "../Utils/Express";
import Response from "../Utils/Response";
import BadDataException from "../../Types/Exception/BadDataException";
import logger from "../Utils/Logger";
import { JSONObject } from "../../Types/JSON";
import {
DashboardClientUrl,
GitHubAppClientId,
} from "../EnvironmentConfig";
import ObjectID from "../../Types/ObjectID";
import GitHubUtil, {
GitHubRepository,
} from "../Utils/CodeRepository/GitHub/GitHub";
import CodeRepositoryService from "../Services/CodeRepositoryService";
import CodeRepository from "../../Models/DatabaseModels/CodeRepository";
import CodeRepositoryType from "../../Types/CodeRepository/CodeRepositoryType";
import URL from "../../Types/API/URL";
import UserMiddleware from "../Middleware/UserAuthorization";
import BaseModel from "../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
export default class GitHubAPI {
public getRouter(): ExpressRouter {
const router: ExpressRouter = Express.getRouter();
// GitHub App installation callback
// This is called after a user installs the GitHub App
router.get(
"/github/auth/:projectId/:userId/callback",
async (req: ExpressRequest, res: ExpressResponse) => {
try {
const projectId: string | undefined =
req.params["projectId"]?.toString();
const userId: string | undefined = req.params["userId"]?.toString();
if (!projectId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Project ID is required"),
);
}
if (!userId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("User ID is required"),
);
}
// GitHub sends installation_id in query params after app installation
const installationId: string | undefined =
req.query["installation_id"]?.toString();
if (!installationId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"Installation ID is required. Please install the GitHub App first.",
),
);
}
// Store the installation ID - we'll create repositories when user selects them
// For now, redirect back to dashboard with installation ID
const redirectUrl: string = `${DashboardClientUrl.toString()}/dashboard/${projectId}/code-repository?installation_id=${installationId}`;
return Response.redirect(req, res, URL.fromString(redirectUrl));
} catch (error) {
logger.error("GitHub Auth Callback Error:");
logger.error(error);
return Response.sendErrorResponse(
req,
res,
error instanceof Error
? new BadDataException(error.message)
: new BadDataException("An error occurred"),
);
}
},
);
// Initiate GitHub App installation
router.get(
"/github/auth/:projectId/:userId/install",
async (req: ExpressRequest, res: ExpressResponse) => {
try {
if (!GitHubAppClientId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException(
"GitHub App is not configured. Please set GITHUB_APP_CLIENT_ID.",
),
);
}
const projectId: string | undefined =
req.params["projectId"]?.toString();
const userId: string | undefined = req.params["userId"]?.toString();
if (!projectId || !userId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Project ID and User ID are required"),
);
}
// Redirect to GitHub App installation page
// The state parameter helps us track the installation
const state: string = Buffer.from(
JSON.stringify({ projectId, userId }),
).toString("base64");
const installUrl: string = `https://github.com/apps/${GitHubAppClientId}/installations/new?state=${state}`;
return Response.redirect(req, res, URL.fromString(installUrl));
} catch (error) {
logger.error("GitHub Install Redirect Error:");
logger.error(error);
return Response.sendErrorResponse(
req,
res,
error instanceof Error
? new BadDataException(error.message)
: new BadDataException("An error occurred"),
);
}
},
);
// List repositories for an installation
router.get(
"/github/repositories/:projectId/:installationId",
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
try {
const installationId: string | undefined =
req.params["installationId"]?.toString();
if (!installationId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Installation ID is required"),
);
}
const repositories: Array<GitHubRepository> =
await GitHubUtil.listRepositoriesForInstallation(installationId);
return Response.sendJsonObjectResponse(req, res, {
repositories: repositories as unknown,
} as JSONObject);
} catch (error) {
logger.error("GitHub List Repositories Error:");
logger.error(error);
return Response.sendErrorResponse(
req,
res,
error instanceof Error
? new BadDataException(error.message)
: new BadDataException("An error occurred"),
);
}
},
);
// Connect a repository to a project
router.post(
"/github/repository/connect",
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse) => {
try {
const body: JSONObject = req.body;
const projectId: string | undefined = body["projectId"]?.toString();
const installationId: string | undefined =
body["installationId"]?.toString();
const repositoryName: string | undefined =
body["repositoryName"]?.toString();
const organizationName: string | undefined =
body["organizationName"]?.toString();
const name: string | undefined = body["name"]?.toString();
const defaultBranch: string | undefined =
body["defaultBranch"]?.toString();
const repositoryUrl: string | undefined =
body["repositoryUrl"]?.toString();
if (!projectId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Project ID is required"),
);
}
if (!installationId) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Installation ID is required"),
);
}
if (!repositoryName) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Repository name is required"),
);
}
if (!organizationName) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Organization name is required"),
);
}
// Create the code repository record
const codeRepository: CodeRepository = new CodeRepository();
codeRepository.projectId = new ObjectID(projectId);
codeRepository.name = name || `${organizationName}/${repositoryName}`;
codeRepository.repositoryHostedAt = CodeRepositoryType.GitHub;
codeRepository.organizationName = organizationName;
codeRepository.repositoryName = repositoryName;
codeRepository.mainBranchName = defaultBranch || "main";
codeRepository.gitHubAppInstallationId = installationId;
if (repositoryUrl) {
codeRepository.repositoryUrl = URL.fromString(repositoryUrl);
}
const createdRepository: CodeRepository =
await CodeRepositoryService.create({
data: codeRepository,
props: {
isRoot: true,
},
});
return Response.sendJsonObjectResponse(req, res, {
repository: BaseModel.toJSON(createdRepository, CodeRepository),
} as JSONObject);
} catch (error) {
logger.error("GitHub Connect Repository Error:");
logger.error(error);
return Response.sendErrorResponse(
req,
res,
error instanceof Error
? new BadDataException(error.message)
: new BadDataException("An error occurred"),
);
}
},
);
// GitHub webhook handler
router.post(
"/github/webhook",
async (req: ExpressRequest, res: ExpressResponse) => {
try {
const signature: string | undefined = req.headers[
"x-hub-signature-256"
] as string | undefined;
const event: string | undefined = req.headers["x-github-event"] as
| string
| undefined;
if (!signature) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Missing webhook signature"),
);
}
// Get raw body for signature verification
const rawBody: string = JSON.stringify(req.body);
// Verify webhook signature
const isValid: boolean = GitHubUtil.verifyWebhookSignature(
rawBody,
signature,
);
if (!isValid) {
return Response.sendErrorResponse(
req,
res,
new BadDataException("Invalid webhook signature"),
);
}
logger.debug(`Received GitHub webhook event: ${event}`);
// Handle different webhook events here
// For now, just acknowledge receipt
// Future: Handle push, pull_request, check_run events
return Response.sendJsonObjectResponse(req, res, {
success: true,
message: "Webhook received",
} as JSONObject);
} catch (error) {
logger.error("GitHub Webhook Error:");
logger.error(error);
return Response.sendErrorResponse(
req,
res,
error instanceof Error
? new BadDataException(error.message)
: new BadDataException("An error occurred"),
);
}
},
);
return router;
}
}

View File

@@ -44,6 +44,7 @@ const FRONTEND_ENV_ALLOW_LIST: Array<string> = [
"DISABLE_TELEMETRY",
"SLACK_APP_CLIENT_ID",
"MICROSOFT_TEAMS_APP_CLIENT_ID",
"GITHUB_APP_CLIENT_ID",
"CAPTCHA_ENABLED",
"CAPTCHA_SITE_KEY",
];
@@ -458,6 +459,18 @@ export const MicrosoftTeamsAppClientSecret: string | null =
export const MicrosoftTeamsAppTenantId: string | null =
process.env["MICROSOFT_TEAMS_APP_TENANT_ID"] || null;
// GitHub App Configuration
export const GitHubAppId: string | null =
process.env["GITHUB_APP_ID"] || null;
export const GitHubAppClientId: string | null =
process.env["GITHUB_APP_CLIENT_ID"] || null;
export const GitHubAppClientSecret: string | null =
process.env["GITHUB_APP_CLIENT_SECRET"] || null;
export const GitHubAppPrivateKey: string | null =
process.env["GITHUB_APP_PRIVATE_KEY"] || null;
export const GitHubAppWebhookSecret: string | null =
process.env["GITHUB_APP_WEBHOOK_SECRET"] || null;
// VAPID Configuration for Web Push Notifications
export const VapidPublicKey: string | undefined =
process.env["VAPID_PUBLIC_KEY"] || undefined;

View File

@@ -0,0 +1,10 @@
import DatabaseService from "./DatabaseService";
import Model from "../../Models/DatabaseModels/CodeRepository";
export class Service extends DatabaseService<Model> {
public constructor() {
super(Model);
}
}
export default new Service();

View File

@@ -10,6 +10,29 @@ import OneUptimeDate from "../../../../Types/Date";
import { JSONArray, JSONObject } from "../../../../Types/JSON";
import API from "../../../../Utils/API";
import CaptureSpan from "../../Telemetry/CaptureSpan";
import {
GitHubAppId,
GitHubAppPrivateKey,
GitHubAppWebhookSecret,
} from "../../../EnvironmentConfig";
import BadDataException from "../../../../Types/Exception/BadDataException";
import * as crypto from "crypto";
export interface GitHubRepository {
id: number;
name: string;
fullName: string;
private: boolean;
htmlUrl: string;
description: string | null;
defaultBranch: string;
ownerLogin: string;
}
export interface GitHubInstallationToken {
token: string;
expiresAt: Date;
}
export default class GitHubUtil extends HostedCodeRepository {
private getPullRequestFromJSONObject(data: {
@@ -258,4 +281,198 @@ export default class GitHubUtil extends HostedCodeRepository {
repositoryName: data.repositoryName,
});
}
// GitHub App Authentication Methods
/**
* Generates a JWT for GitHub App authentication
* @returns JWT string valid for 10 minutes
*/
@CaptureSpan()
public static generateAppJWT(): string {
if (!GitHubAppId) {
throw new BadDataException("GITHUB_APP_ID environment variable is not set");
}
if (!GitHubAppPrivateKey) {
throw new BadDataException(
"GITHUB_APP_PRIVATE_KEY environment variable is not set",
);
}
const now: number = Math.floor(Date.now() / 1000);
const payload: JSONObject = {
iat: now - 60, // Issued at time (60 seconds in the past to allow for clock drift)
exp: now + 600, // Expiration time (10 minutes from now)
iss: GitHubAppId,
};
// Create JWT header
const header: JSONObject = {
alg: "RS256",
typ: "JWT",
};
const encodedHeader: string = Buffer.from(JSON.stringify(header)).toString(
"base64url",
);
const encodedPayload: string = Buffer.from(JSON.stringify(payload)).toString(
"base64url",
);
const signatureInput: string = `${encodedHeader}.${encodedPayload}`;
// Sign with private key
const sign: crypto.Sign = crypto.createSign("RSA-SHA256");
sign.update(signatureInput);
const signature: string = sign.sign(GitHubAppPrivateKey, "base64url");
return `${signatureInput}.${signature}`;
}
/**
* Gets an installation access token for a GitHub App installation
* @param installationId - The GitHub App installation ID
* @returns Installation token and expiration date
*/
@CaptureSpan()
public static async getInstallationAccessToken(
installationId: string,
): Promise<GitHubInstallationToken> {
const jwt: string = GitHubUtil.generateAppJWT();
const url: URL = URL.fromString(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
);
const result: HTTPErrorResponse | HTTPResponse<JSONObject> = await API.post({
url: url,
data: {},
headers: {
Authorization: `Bearer ${jwt}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (result instanceof HTTPErrorResponse) {
throw result;
}
return {
token: result.data["token"] as string,
expiresAt: OneUptimeDate.fromString(result.data["expires_at"] as string),
};
}
/**
* Lists repositories accessible to a GitHub App installation
* @param installationId - The GitHub App installation ID
* @returns Array of repositories
*/
@CaptureSpan()
public static async listRepositoriesForInstallation(
installationId: string,
): Promise<Array<GitHubRepository>> {
const tokenData: GitHubInstallationToken =
await GitHubUtil.getInstallationAccessToken(installationId);
const allRepositories: Array<GitHubRepository> = [];
let page: number = 1;
let hasMore: boolean = true;
while (hasMore) {
const url: URL = URL.fromString(
`https://api.github.com/installation/repositories?per_page=100&page=${page}`,
);
const result: HTTPErrorResponse | HTTPResponse<JSONObject> = await API.get(
{
url: url,
data: {},
headers: {
Authorization: `Bearer ${tokenData.token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
},
);
if (result instanceof HTTPErrorResponse) {
throw result;
}
const repositories: JSONArray =
(result.data["repositories"] as JSONArray) || [];
for (const repo of repositories) {
const repoData: JSONObject = repo as JSONObject;
const owner: JSONObject = repoData["owner"] as JSONObject;
allRepositories.push({
id: repoData["id"] as number,
name: repoData["name"] as string,
fullName: repoData["full_name"] as string,
private: repoData["private"] as boolean,
htmlUrl: repoData["html_url"] as string,
description: (repoData["description"] as string) || null,
defaultBranch: repoData["default_branch"] as string,
ownerLogin: owner["login"] as string,
});
}
// Check if there are more pages
if (repositories.length < 100) {
hasMore = false;
} else {
page++;
}
}
return allRepositories;
}
/**
* Verifies a GitHub webhook signature
* @param payload - The raw request body
* @param signature - The X-Hub-Signature-256 header value
* @returns true if signature is valid
*/
public static verifyWebhookSignature(
payload: string,
signature: string,
): boolean {
if (!GitHubAppWebhookSecret) {
logger.warn("GITHUB_APP_WEBHOOK_SECRET is not set, skipping verification");
return true;
}
const expectedSignature: string = `sha256=${crypto
.createHmac("sha256", GitHubAppWebhookSecret)
.update(payload)
.digest("hex")}`;
try {
return crypto.timingSafeEqual(
Buffer.from(signature) as Uint8Array,
Buffer.from(expectedSignature) as Uint8Array,
);
} catch {
return false;
}
}
/**
* Gets the GitHub App installation URL for a project to install the app
* @returns The installation URL
*/
public static getAppInstallationUrl(): string {
if (!GitHubAppId) {
throw new BadDataException("GITHUB_APP_ID environment variable is not set");
}
// This is the standard GitHub App installation URL format
// The app slug would typically come from another env var, but we can use the client ID approach
return `https://github.com/apps`;
}
}

View File

@@ -1,6 +1,6 @@
enum CodeRepositoryType {
GitHub = "GitHub",
// GitLab = 'GitLab',
GitLab = "GitLab",
}
export default CodeRepositoryType;

View File

@@ -624,6 +624,12 @@ enum Permission {
EditServiceCatalogTelemetryService = "EditServiceCatalogTelemetryService",
ReadServiceCatalogTelemetryService = "ReadServiceCatalogTelemetryService",
// Code Repository
CreateCodeRepository = "CreateCodeRepository",
DeleteCodeRepository = "DeleteCodeRepository",
EditCodeRepository = "EditCodeRepository",
ReadCodeRepository = "ReadCodeRepository",
CreateProbeOwnerTeam = "CreateProbeOwnerTeam",
DeleteProbeOwnerTeam = "DeleteProbeOwnerTeam",
EditProbeOwnerTeam = "EditProbeOwnerTeam",
@@ -3417,6 +3423,40 @@ export class PermissionHelper {
isAccessControlPermission: false,
},
// Code Repository Permissions
{
permission: Permission.CreateCodeRepository,
title: "Create Code Repository",
description:
"This permission can create Code Repositories in this project.",
isAssignableToTenant: true,
isAccessControlPermission: true,
},
{
permission: Permission.DeleteCodeRepository,
title: "Delete Code Repository",
description:
"This permission can delete Code Repositories of this project.",
isAssignableToTenant: true,
isAccessControlPermission: true,
},
{
permission: Permission.EditCodeRepository,
title: "Edit Code Repository",
description:
"This permission can edit Code Repositories of this project.",
isAssignableToTenant: true,
isAccessControlPermission: true,
},
{
permission: Permission.ReadCodeRepository,
title: "Read Code Repository",
description:
"This permission can read Code Repositories of this project.",
isAssignableToTenant: true,
isAccessControlPermission: true,
},
{
permission: Permission.CreateTelemetryServiceTraces,
title: "Create Telemetry Service Traces",

View File

@@ -93,6 +93,11 @@ const ServiceCatalogRoutes: React.LazyExoticComponent<
> = lazy(() => {
return import("./Routes/ServiceCatalogRoutes");
});
const CodeRepositoryRoutes: React.LazyExoticComponent<
React.FunctionComponent<PageComponentProps>
> = lazy(() => {
return import("./Routes/CodeRepositoryRoutes");
});
const IncidentsRoutes: React.LazyExoticComponent<
React.FunctionComponent<PageComponentProps>
> = lazy(() => {
@@ -448,6 +453,12 @@ const App: () => JSX.Element = () => {
element={<ServiceCatalogRoutes {...commonPageProps} />}
/>
{/* Code Repository */}
<PageRoute
path={RouteMap[PageMap.CODE_REPOSITORY_ROOT]?.toString() || ""}
element={<CodeRepositoryRoutes {...commonPageProps} />}
/>
{/* Incidents */}
<PageRoute
path={RouteMap[PageMap.INCIDENTS_ROOT]?.toString() || ""}

View File

@@ -91,6 +91,14 @@ const DashboardNavbar: FunctionComponent<ComponentProps> = (
),
icon: IconProp.SquareStack,
},
{
title: "Code Repositories",
description: "Connect and manage your GitHub and GitLab repositories.",
route: RouteUtil.populateRouteParams(
RouteMap[PageMap.CODE_REPOSITORY] as Route,
),
icon: IconProp.Code,
},
{
title: "Scheduled Maintenance",
description: "Manage your scheduled maintenance events.",

View File

@@ -0,0 +1,205 @@
import LabelsElement from "Common/UI/Components/Label/Labels";
import ProjectUtil from "Common/UI/Utils/Project";
import PageComponentProps from "../PageComponentProps";
import CodeRepositoryType from "Common/Types/CodeRepository/CodeRepositoryType";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import ModelTable from "Common/UI/Components/ModelTable/ModelTable";
import FieldType from "Common/UI/Components/Types/FieldType";
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";
const CodeRepositoryPage: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
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."}
formFields={[
{
field: {
name: true,
},
title: "Name",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Repository Name",
validation: {
minLength: 2,
},
},
{
field: {
description: true,
},
title: "Description",
fieldType: FormFieldSchemaType.LongText,
required: false,
placeholder: "Description",
},
{
field: {
repositoryHostedAt: true,
},
title: "Repository Host",
description: "Where is this repository hosted?",
fieldType: FormFieldSchemaType.Dropdown,
required: true,
placeholder: "Select Host",
dropdownOptions:
DropdownUtil.getDropdownOptionsFromEnum(CodeRepositoryType),
},
{
field: {
organizationName: true,
},
title: "Organization / Username",
description: "The GitHub organization or username that owns the repository.",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Organization Name",
},
{
field: {
repositoryName: true,
},
title: "Repository Name",
description: "The name of the repository.",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Repository Name",
},
{
field: {
mainBranchName: true,
},
title: "Main Branch",
description: "The main branch of the repository (e.g., main, master).",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "main",
},
]}
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: {
name: true,
color: true,
},
},
title: "Labels",
type: FieldType.EntityArray,
filterEntityType: Label,
filterQuery: {
projectId: ProjectUtil.getCurrentProjectId()!,
},
filterDropdownField: {
label: "name",
value: "_id",
},
},
]}
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"] || []} />;
},
},
]}
/>
);
};
export default CodeRepositoryPage;

View File

@@ -0,0 +1,25 @@
import { getCodeRepositoryBreadcrumbs } from "../../Utils/Breadcrumbs";
import { RouteUtil } from "../../Utils/RouteMap";
import LayoutPageComponentProps from "../LayoutPageComponentProps";
import SideMenu from "./SideMenu";
import Page from "Common/UI/Components/Page/Page";
import Navigation from "Common/UI/Utils/Navigation";
import React, { FunctionComponent, ReactElement } from "react";
import { Outlet } from "react-router-dom";
const CodeRepositoryLayout: FunctionComponent<LayoutPageComponentProps> = (
_props: LayoutPageComponentProps,
): ReactElement => {
const path: string = Navigation.getRoutePath(RouteUtil.getRoutes());
return (
<Page
title={"Code Repositories"}
sideMenu={<SideMenu />}
breadcrumbLinks={getCodeRepositoryBreadcrumbs(path)}
>
<Outlet />
</Page>
);
};
export default CodeRepositoryLayout;

View File

@@ -0,0 +1,31 @@
import PageMap from "../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import IconProp from "Common/Types/Icon/IconProp";
import SideMenu, {
SideMenuSectionProps,
} from "Common/UI/Components/SideMenu/SideMenu";
import React, { FunctionComponent, ReactElement } from "react";
const CodeRepositorySideMenu: FunctionComponent = (): ReactElement => {
const sections: SideMenuSectionProps[] = [
{
title: "Code Repositories",
items: [
{
link: {
title: "All Repositories",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.CODE_REPOSITORY] as Route,
),
},
icon: IconProp.List,
},
],
},
];
return <SideMenu sections={sections} />;
};
export default CodeRepositorySideMenu;

View File

@@ -0,0 +1,29 @@
import PageMap from "../../../Utils/PageMap";
import RouteMap from "../../../Utils/RouteMap";
import PageComponentProps from "../../PageComponentProps";
import Route from "Common/Types/API/Route";
import ObjectID from "Common/Types/ObjectID";
import ModelDelete from "Common/UI/Components/ModelDelete/ModelDelete";
import Navigation from "Common/UI/Utils/Navigation";
import CodeRepository from "Common/Models/DatabaseModels/CodeRepository";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
const CodeRepositoryDelete: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
return (
<Fragment>
<ModelDelete
modelType={CodeRepository}
modelId={modelId}
onDeleteSuccess={() => {
Navigation.navigate(RouteMap[PageMap.CODE_REPOSITORY] as Route);
}}
/>
</Fragment>
);
};
export default CodeRepositoryDelete;

View File

@@ -0,0 +1,196 @@
import LabelsElement from "Common/UI/Components/Label/Labels";
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import CodeRepositoryType from "Common/Types/CodeRepository/CodeRepositoryType";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import FieldType from "Common/UI/Components/Types/FieldType";
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, { Fragment, FunctionComponent, ReactElement } from "react";
const CodeRepositoryView: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID();
return (
<Fragment>
<CardModelDetail<CodeRepository>
name="Repository > Repository Details"
cardProps={{
title: "Repository Details",
description: "Here are more details for this repository.",
}}
formSteps={[
{
title: "Repository Info",
id: "repository-info",
},
{
title: "Labels",
id: "labels",
},
]}
isEditable={true}
formFields={[
{
field: {
name: true,
},
title: "Name",
stepId: "repository-info",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Repository Name",
validation: {
minLength: 2,
},
},
{
field: {
description: true,
},
stepId: "repository-info",
title: "Description",
fieldType: FormFieldSchemaType.LongText,
required: false,
placeholder: "Description",
},
{
field: {
repositoryHostedAt: true,
},
stepId: "repository-info",
title: "Repository Host",
description: "Where is this repository hosted?",
fieldType: FormFieldSchemaType.Dropdown,
required: true,
placeholder: "Select Host",
dropdownOptions:
DropdownUtil.getDropdownOptionsFromEnum(CodeRepositoryType),
},
{
field: {
organizationName: true,
},
stepId: "repository-info",
title: "Organization / Username",
description:
"The GitHub organization or username that owns the repository.",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Organization Name",
},
{
field: {
repositoryName: true,
},
stepId: "repository-info",
title: "Repository Name",
description: "The name of the repository.",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "Repository Name",
},
{
field: {
mainBranchName: true,
},
stepId: "repository-info",
title: "Main Branch",
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",
},
]}
modelDetailProps={{
showDetailsInNumberOfColumns: 2,
modelType: CodeRepository,
id: "model-detail-code-repository",
fields: [
{
field: {
_id: true,
},
title: "Repository ID",
},
{
field: {
name: true,
},
title: "Name",
},
{
field: {
repositoryHostedAt: true,
},
title: "Repository Host",
},
{
field: {
organizationName: true,
},
title: "Organization",
},
{
field: {
repositoryName: true,
},
title: "Repository",
},
{
field: {
mainBranchName: true,
},
title: "Main Branch",
},
{
field: {
labels: {
name: true,
color: true,
},
},
title: "Labels",
fieldType: FieldType.Element,
getElement: (item: CodeRepository): ReactElement => {
return <LabelsElement labels={item["labels"] || []} />;
},
},
{
field: {
description: true,
},
title: "Description",
},
],
modelId: modelId,
}}
/>
</Fragment>
);
};
export default CodeRepositoryView;

View File

@@ -0,0 +1,32 @@
import { getCodeRepositoryBreadcrumbs } from "../../../Utils/Breadcrumbs";
import { RouteUtil } from "../../../Utils/RouteMap";
import PageComponentProps from "../../PageComponentProps";
import SideMenu from "./SideMenu";
import ObjectID from "Common/Types/ObjectID";
import ModelPage from "Common/UI/Components/Page/ModelPage";
import Navigation from "Common/UI/Utils/Navigation";
import CodeRepository from "Common/Models/DatabaseModels/CodeRepository";
import React, { FunctionComponent, ReactElement } from "react";
import { Outlet, useParams } from "react-router-dom";
const CodeRepositoryViewLayout: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const { id } = useParams();
const modelId: ObjectID = new ObjectID(id || "");
const path: string = Navigation.getRoutePath(RouteUtil.getRoutes());
return (
<ModelPage
title="Repository"
modelType={CodeRepository}
modelId={modelId}
modelNameField="name"
breadcrumbLinks={getCodeRepositoryBreadcrumbs(path)}
sideMenu={<SideMenu modelId={modelId} />}
>
<Outlet />
</ModelPage>
);
};
export default CodeRepositoryViewLayout;

View File

@@ -0,0 +1,58 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import FormFieldSchemaType from "Common/UI/Components/Forms/Types/FormFieldSchemaType";
import CardModelDetail from "Common/UI/Components/ModelDetail/CardModelDetail";
import FieldType from "Common/UI/Components/Types/FieldType";
import Navigation from "Common/UI/Utils/Navigation";
import CodeRepository from "Common/Models/DatabaseModels/CodeRepository";
import React, { Fragment, FunctionComponent, ReactElement } from "react";
const CodeRepositorySettings: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
return (
<Fragment>
<CardModelDetail
name="Repository Settings"
cardProps={{
title: "Repository Settings",
description: "Configure settings for your repository.",
}}
isEditable={true}
editButtonText="Edit Settings"
formFields={[
{
field: {
mainBranchName: true,
},
title: "Main Branch",
description:
"The main branch of the repository (e.g., main, master).",
fieldType: FormFieldSchemaType.Text,
required: true,
placeholder: "main",
},
]}
modelDetailProps={{
modelType: CodeRepository,
id: "model-detail-code-repository-settings",
fields: [
{
field: {
mainBranchName: true,
},
title: "Main Branch",
description: "The main branch of the repository.",
fieldType: FieldType.Text,
},
],
modelId: modelId,
}}
/>
</Fragment>
);
};
export default CodeRepositorySettings;

View File

@@ -0,0 +1,60 @@
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import IconProp from "Common/Types/Icon/IconProp";
import ObjectID from "Common/Types/ObjectID";
import SideMenu from "Common/UI/Components/SideMenu/SideMenu";
import SideMenuItem from "Common/UI/Components/SideMenu/SideMenuItem";
import SideMenuSection from "Common/UI/Components/SideMenu/SideMenuSection";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
modelId: ObjectID;
}
const CodeRepositorySideMenu: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
<SideMenu>
<SideMenuSection title="Basic">
<SideMenuItem
link={{
title: "Overview",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.CODE_REPOSITORY_VIEW] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Info}
/>
</SideMenuSection>
<SideMenuSection title="Advanced">
<SideMenuItem
link={{
title: "Settings",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.CODE_REPOSITORY_VIEW_SETTINGS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Settings}
/>
<SideMenuItem
link={{
title: "Delete Repository",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.CODE_REPOSITORY_VIEW_DELETE] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Trash}
className="danger-on-hover"
/>
</SideMenuSection>
</SideMenu>
);
};
export default CodeRepositorySideMenu;

View File

@@ -0,0 +1,115 @@
import Loader from "../Components/Loader/Loader";
import ComponentProps from "../Pages/PageComponentProps";
import CodeRepositoryViewLayout from "../Pages/CodeRepository/View/Layout";
import CodeRepositoryLayout from "../Pages/CodeRepository/Layout";
import PageMap from "../Utils/PageMap";
import RouteMap, {
RouteUtil,
CodeRepositoryRoutePath,
} from "../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import React, {
FunctionComponent,
LazyExoticComponent,
ReactElement,
Suspense,
lazy,
} from "react";
import { Route as PageRoute, Routes } from "react-router-dom";
// Pages
const CodeRepository: LazyExoticComponent<FunctionComponent<ComponentProps>> =
lazy(() => {
return import("../Pages/CodeRepository/CodeRepository");
});
const CodeRepositoryView: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
return import("../Pages/CodeRepository/View/Index");
});
const CodeRepositoryViewDelete: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
return import("../Pages/CodeRepository/View/Delete");
});
const CodeRepositoryViewSettings: LazyExoticComponent<
FunctionComponent<ComponentProps>
> = lazy(() => {
return import("../Pages/CodeRepository/View/Settings");
});
const CodeRepositoryRoutes: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
<Routes>
<PageRoute path="/" element={<CodeRepositoryLayout {...props} />}>
<PageRoute
path={CodeRepositoryRoutePath[PageMap.CODE_REPOSITORY] || ""}
element={
<Suspense fallback={Loader}>
<CodeRepository
{...props}
pageRoute={RouteMap[PageMap.CODE_REPOSITORY] as Route}
/>
</Suspense>
}
/>
</PageRoute>
<PageRoute
path={CodeRepositoryRoutePath[PageMap.CODE_REPOSITORY_VIEW] || ""}
element={<CodeRepositoryViewLayout {...props} />}
>
<PageRoute
index
element={
<Suspense fallback={Loader}>
<CodeRepositoryView
{...props}
pageRoute={RouteMap[PageMap.CODE_REPOSITORY_VIEW] as Route}
/>
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.CODE_REPOSITORY_VIEW_DELETE,
)}
element={
<Suspense fallback={Loader}>
<CodeRepositoryViewDelete
{...props}
pageRoute={
RouteMap[PageMap.CODE_REPOSITORY_VIEW_DELETE] as Route
}
/>
</Suspense>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.CODE_REPOSITORY_VIEW_SETTINGS,
)}
element={
<Suspense fallback={Loader}>
<CodeRepositoryViewSettings
{...props}
pageRoute={
RouteMap[PageMap.CODE_REPOSITORY_VIEW_SETTINGS] as Route
}
/>
</Suspense>
}
/>
</PageRoute>
</Routes>
);
};
export default CodeRepositoryRoutes;

View File

@@ -0,0 +1,33 @@
import PageMap from "../PageMap";
import { BuildBreadcrumbLinksByTitles } from "./Helper";
import Dictionary from "Common/Types/Dictionary";
import Link from "Common/Types/Link";
export function getCodeRepositoryBreadcrumbs(
path: string,
): Array<Link> | undefined {
const breadcrumpLinksMap: Dictionary<Link[]> = {
...BuildBreadcrumbLinksByTitles(PageMap.CODE_REPOSITORY, [
"Project",
"Code Repositories",
]),
...BuildBreadcrumbLinksByTitles(PageMap.CODE_REPOSITORY_VIEW, [
"Project",
"Code Repositories",
"View Repository",
]),
...BuildBreadcrumbLinksByTitles(PageMap.CODE_REPOSITORY_VIEW_DELETE, [
"Project",
"Code Repositories",
"View Repository",
"Delete Repository",
]),
...BuildBreadcrumbLinksByTitles(PageMap.CODE_REPOSITORY_VIEW_SETTINGS, [
"Project",
"Code Repositories",
"View Repository",
"Settings",
]),
};
return breadcrumpLinksMap[path];
}

View File

@@ -8,4 +8,5 @@ export * from "./TelemetryBreadcrumbs";
export * from "./SettingsBreadcrumbs";
export * from "./MonitorGroupBreadcrumbs";
export * from "./ServiceCatalogBreadcrumbs";
export * from "./CodeRepositoryBreadcrumbs";
export * from "./DashboardBreadCrumbs";

View File

@@ -174,6 +174,13 @@ enum PageMap {
SERVICE_CATALOG_VIEW_DEPENDENCIES = "SERVICE_CATALOG_VIEW_DEPENDENCIES",
SERVICE_CATALOG_DEPENDENCY_GRAPH = "SERVICE_CATALOG_DEPENDENCY_GRAPH",
// Code Repository
CODE_REPOSITORY_ROOT = "CODE_REPOSITORY_ROOT",
CODE_REPOSITORY = "CODE_REPOSITORY",
CODE_REPOSITORY_VIEW = "CODE_REPOSITORY_VIEW",
CODE_REPOSITORY_VIEW_DELETE = "CODE_REPOSITORY_VIEW_DELETE",
CODE_REPOSITORY_VIEW_SETTINGS = "CODE_REPOSITORY_VIEW_SETTINGS",
DASHBOARDS_ROOT = "DASHBOARDS_ROOT",
DASHBOARDS = "DASHBOARDS",
DASHBOARD_VIEW = "DASHBOARD_VIEW",

View File

@@ -46,6 +46,12 @@ export const ServiceCatalogRoutePath: Dictionary<string> = {
[PageMap.SERVICE_CATALOG_VIEW_TELEMETRY_SERVICES]: `${RouteParams.ModelID}/telemetry-service`,
};
export const CodeRepositoryRoutePath: Dictionary<string> = {
[PageMap.CODE_REPOSITORY_VIEW]: `${RouteParams.ModelID}`,
[PageMap.CODE_REPOSITORY_VIEW_DELETE]: `${RouteParams.ModelID}/delete`,
[PageMap.CODE_REPOSITORY_VIEW_SETTINGS]: `${RouteParams.ModelID}/settings`,
};
export const WorkflowRoutePath: Dictionary<string> = {
[PageMap.WORKFLOWS_LOGS]: "logs",
[PageMap.WORKFLOWS_VARIABLES]: "variables",
@@ -908,6 +914,34 @@ const RouteMap: Dictionary<Route> = {
}`,
),
// Code Repository
[PageMap.CODE_REPOSITORY_ROOT]: new Route(
`/dashboard/${RouteParams.ProjectID}/code-repository/*`,
),
[PageMap.CODE_REPOSITORY]: new Route(
`/dashboard/${RouteParams.ProjectID}/code-repository`,
),
[PageMap.CODE_REPOSITORY_VIEW]: new Route(
`/dashboard/${RouteParams.ProjectID}/code-repository/${
CodeRepositoryRoutePath[PageMap.CODE_REPOSITORY_VIEW]
}`,
),
[PageMap.CODE_REPOSITORY_VIEW_DELETE]: new Route(
`/dashboard/${RouteParams.ProjectID}/code-repository/${
CodeRepositoryRoutePath[PageMap.CODE_REPOSITORY_VIEW_DELETE]
}`,
),
[PageMap.CODE_REPOSITORY_VIEW_SETTINGS]: new Route(
`/dashboard/${RouteParams.ProjectID}/code-repository/${
CodeRepositoryRoutePath[PageMap.CODE_REPOSITORY_VIEW_SETTINGS]
}`,
),
// Dashboards
[PageMap.DASHBOARDS_ROOT]: new Route(

View File

@@ -53,6 +53,10 @@ Usage:
{{- end }}
- name: SLACK_APP_CLIENT_ID
value: {{ $.Values.slackApp.clientId | quote }}
- name: GITHUB_APP_ID
value: {{ $.Values.gitHubApp.id | quote }}
- name: GITHUB_APP_CLIENT_ID
value: {{ $.Values.gitHubApp.clientId | quote }}
- name: HOST
value: {{ $.Values.host }}
- name: PROVISION_SSL
@@ -196,6 +200,15 @@ Usage:
- name: MICROSOFT_TEAMS_APP_CLIENT_SECRET
value: {{ $.Values.microsoftTeamsApp.clientSecret }}
- name: GITHUB_APP_CLIENT_SECRET
value: {{ $.Values.gitHubApp.clientSecret }}
- name: GITHUB_APP_PRIVATE_KEY
value: {{ $.Values.gitHubApp.privateKey | quote }}
- name: GITHUB_APP_WEBHOOK_SECRET
value: {{ $.Values.gitHubApp.webhookSecret }}
- name: CAPTCHA_SECRET_KEY
value: {{ default "" $.Values.captcha.secretKey | quote }}

View File

@@ -2011,6 +2011,27 @@
},
"additionalProperties": false
},
"gitHubApp": {
"type": "object",
"properties": {
"id": {
"type": ["string", "null"]
},
"clientId": {
"type": ["string", "null"]
},
"clientSecret": {
"type": ["string", "null"]
},
"privateKey": {
"type": ["string", "null"]
},
"webhookSecret": {
"type": ["string", "null"]
}
},
"additionalProperties": false
},
"keda": {
"type": "object",
"properties": {

View File

@@ -797,5 +797,23 @@ microsoftTeamsApp:
clientSecret:
tenantId:
# GitHub Example Configuration
# gitHubApp:
# id: "123456"
# clientId: "Iv1.abc123"
# clientSecret: "your-client-secret"
# privateKey: |
# -----BEGIN RSA PRIVATE KEY-----
# ...
# -----END RSA PRIVATE KEY-----
# webhookSecret: "your-webhook-secret"
gitHubApp:
id:
clientId:
clientSecret:
privateKey:
webhookSecret:
keda:
enabled: true

View File

@@ -345,4 +345,13 @@ NGINX_LISTEN_OPTIONS=
# The secret value is typically longer and includes more characters
MICROSOFT_TEAMS_APP_CLIENT_ID=
MICROSOFT_TEAMS_APP_CLIENT_SECRET=
MICROSOFT_TEAMS_APP_TENANT_ID=
MICROSOFT_TEAMS_APP_TENANT_ID=
# GitHub App Configuration
# Create a GitHub App at https://github.com/settings/apps
# Required for connecting GitHub repositories to OneUptime
GITHUB_APP_ID=
GITHUB_APP_CLIENT_ID=
GITHUB_APP_CLIENT_SECRET=
GITHUB_APP_PRIVATE_KEY=
GITHUB_APP_WEBHOOK_SECRET=