feat(repos): impl. layout countersadd a withLayoutCounters HoC
add api getRepositoryCounters
wrap Layout with HoC on export
lazy dataloading on server-side
@@ -1,5 +1,5 @@
{
- "_generatedAtUnix": 1778513881558,
+ "_generatedAtUnix": 1778523219539,
"_hashAlgorithm": "sha1",
"_version": 2,
"assets": {
@@ -158,7 +158,7 @@
"pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestCreateView.tsx"
},
"RepositoryPullRequestDetailsView": {
- "hash": "01d5ce1ba9934f396ac51b24721d991846cbd667",
+ "hash": "81c67f32cc4b2b28b8e2949c0c2b8d09669c3a16",
"pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx"
},
"RepositoryPullRequestsView": {
@@ -2,12 +2,15 @@
import React from "react";
import styled, { css } from "styled-components";
// import Color from "color";
-
// app
import { Const } from "../const";
import { Chip } from "./Chip";
import { NamedColors } from "../utils/style";
-import { type CommonViewProps, type WithThemeSchemeProp } from "../types";
+import {
+ type RepositoryCountersDTO,
+ type CommonViewProps,
+ type WithThemeSchemeProp,
+} from "../types";
import { buildRouteLink } from "../utils/shared";
import { AppRoute } from "../routes.defs";
import { TextEllipsis } from "./TextEllipsis.styled";
@@ -34,14 +37,7 @@ export const DrawerPrimary = ({
repoSlug: string;
currentRef?: string;
path?: string;
- counters?: {
- pulls?: number;
- tests?: number;
- builds?: number;
- issues?: number;
- apiRefSymbols?: number;
- helpCenterNotifs?: number;
- };
+ counters?: RepositoryCountersDTO;
}) => {
const pathRepo = buildRouteLink(AppRoute.REPOSITORY_DETAILS, {
orgSlug: orgSlug,
@@ -2,7 +2,7 @@
import React, { FC, useState } from "react";
import styled, { css } from "styled-components";
// app
-import type { CommonViewProps, WithThemeSchemeProp } from "../types";
+import type { LayoutProps, WithThemeSchemeProp } from "../types";
import { Const } from "../const";
import { NamedColors } from "../utils/style";
// app islands
@@ -10,16 +10,7 @@ import InstantRouterIndicator from "../islands/InstantRouterIndicator";
// app components
import { PageHeader } from "./PageHeader";
import { DrawerPrimary } from "./DrawerPrimary";
-
-interface LayoutProps extends CommonViewProps {
- foo?: boolean;
- appVersion: string;
- showDrawerPrimary?: boolean;
- orgSlug?: string;
- repoSlug?: string;
- currentRef?: string;
- path?: string;
-}
+import { withLayoutCounters } from "../hocs/withLayoutCounters";
const BRANDLINE_HEIGHT = 4;
const HEADER_HEIGHT = 64;
@@ -28,7 +19,9 @@ function removeCommentsAndSpacing(str = "") {
return str.replace(/\/\*.*\*\//g, " ").replace(/\s+/g, " ");
}
-export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
+// Default Layout implementation.
+// (dataloading happens through HOC at export level lower in file)
+const LayoutComponent: FC<LayoutProps & WithThemeSchemeProp> = (props) => {
const {
appVersion,
children,
@@ -39,7 +32,8 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
repoSlug = "",
currentRef = Const.DEFAULT_HEAD_REF,
path = "",
- } = commonProps;
+ layoutCounters,
+ } = props;
const sharedProps = {
themeScheme,
@@ -99,9 +93,10 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
</div>
<StyledPageWrapper>
<DrawerPrimary
- commonProps={commonProps as any}
+ commonProps={props as any}
themeScheme={themeScheme}
visible={drawerPrimaryOpen}
+ counters={layoutCounters}
orgSlug={orgSlug}
repoSlug={repoSlug}
currentRef={currentRef}
@@ -113,7 +108,7 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
>
<StyledPageHeaderWrapper {...sharedProps}>
<PageHeader
- commonProps={commonProps}
+ commonProps={props as any}
themeScheme={themeScheme}
forceShowLogo={showDrawerPrimary !== true}
setDrawerPrimaryOpen={setDrawerPrimaryOpen}
@@ -135,6 +130,13 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
);
};
+// wrap and export the Layout as named export
+const _LayoutWithCounters = withLayoutCounters(
+ LayoutComponent as NonNullable<() => JSX.Element>,
+);
+export const Layout = _LayoutWithCounters;
+
+/* Styled components (unchanged from previous layout structure) */
const StyledLayoutWrapper = styled.div<WithThemeSchemeProp>`
display: flex;
flex-flow: column nowrap;
@@ -0,0 +1,36 @@
+// 1st-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import type { RepositoryCountersDTO } from "../../types";
+import { AppRoute, AppRouteParams } from "../../routes.defs";
+
+const getRepositoryCounters: ReqHandler<
+ AppRouteParams,
+ AppRoute.REPOSITORY_COUNTERS_API
+> = async (request, reply) => {
+ const { orgSlug, repoSlug } = request.params;
+
+ // total pulls in repository (PRs targeting this repo)
+ const totalPulls = await request.prisma.pullRequest.count({
+ where: {
+ targetRepository: {
+ organization: { slug: orgSlug },
+ slug: repoSlug,
+ },
+ },
+ });
+
+ // for now provide pulls and zeros
+ const counters: RepositoryCountersDTO = {
+ pulls: totalPulls,
+ tests: 0,
+ builds: 0,
+ issues: 0,
+ apiRefSymbols: 0,
+ helpCenterNotifs: 0,
+ };
+
+ reply.send(JSON.stringify(counters) + "\n");
+};
+
+export default getRepositoryCounters;
@@ -0,0 +1,58 @@
+// 3rd-party
+import React, { useEffect, useState } from "react";
+// app
+import type { LayoutProps, RepositoryCountersDTO } from "../types";
+import { Env } from "../env";
+
+// A lightweight HOC that loads SSR counters from /api/repos/:orgSlug/:repoSlug/counters
+// and injects them as `layoutCounters` prop into the wrapped Layout component.
+export function withLayoutCounters<P extends object>(
+ WrappedComponent: React.FC<P & LayoutProps>,
+): React.FC<P & LayoutProps> {
+ const ComponentWithCounters: React.FC<P & LayoutProps> = ({
+ orgSlug,
+ repoSlug,
+ ...props
+ }) => {
+ const [layoutCounters, setLayoutCounters] = useState<
+ RepositoryCountersDTO | undefined
+ >(undefined);
+
+ // SSR: on first render, layoutCounters may be pre-populated by the server.
+ // Client: fetch the API endpoint to hydrate counters.
+ useEffect(() => {
+ // SSR: allow server to inject counters via HTML if possible; fallback to fetch
+ const baseUrl = `${Env.DEPLOYMENT_SCHEME}://${Env.DEPLOYMENT_DOMAIN}${Env.DEPLOYMENT_SCHEME !== "https" ? `:${Env.PORT}` : ""}`;
+ const endpoint = `${baseUrl}/api/repos/${orgSlug}/${repoSlug}/counters`;
+
+ console.log("hoc(layoutCounters): will fetch url:", endpoint);
+
+ fetch(endpoint, { credentials: "same-origin" })
+ .then((r) => (r.ok ? r.json() : {}))
+ .then((data) => {
+ if (data && Object.keys(data).length > 0) {
+ console.log("hoc(layoutCounters): fetched data:", data);
+ setLayoutCounters(data);
+ }
+ })
+ .catch((err) => {
+ const { message } = err as Error;
+ console.error("hoc(layoutCounters): fetch error:", message);
+ });
+ }, [orgSlug, repoSlug]);
+
+ const WrappedComponentEl = WrappedComponent as React.FC<P & LayoutProps>;
+
+ return (
+ <WrappedComponentEl
+ {...(props as unknown as P)}
+ {...(props as unknown as LayoutProps)}
+ orgSlug={orgSlug}
+ repoSlug={repoSlug}
+ layoutCounters={layoutCounters}
+ />
+ );
+ };
+
+ return ComponentWithCounters;
+}
@@ -17,6 +17,7 @@ export enum AppRoute {
AUTH_LOGIN_ACTION = "auth.login.action",
AUTH_LOGOUT_ACTION = "auth.logout.action",
ORGANIZATION_DETAILS = "organization.details",
+ REPOSITORY_COUNTERS_API = "repository.counters.api",
REPOSITORY_BROWSER = "repository.browser",
REPOSITORY_BROWSER_WITH_PATH = "repository.browser.with_path",
REPOSITORY_COMMITS_LOG = "repository.commits_log",
@@ -61,6 +62,7 @@ export const AppRoutePaths: Record<AppRoute, string> = {
[AppRoute.AUTH_LOGIN_ACTION]: "/auth/login",
[AppRoute.AUTH_LOGOUT_ACTION]: "/auth/logout",
[AppRoute.ORGANIZATION_DETAILS]: "/:orgSlug",
+ [AppRoute.REPOSITORY_COUNTERS_API]: "/api/repos/:orgSlug/:repoSlug/counters",
[AppRoute.REPOSITORY_BROWSER]: "/:orgSlug/:repoSlug/:currentRef/tree",
[AppRoute.REPOSITORY_BROWSER_WITH_PATH]:
"/:orgSlug/:repoSlug/:currentRef/tree/*",
@@ -119,6 +121,12 @@ export interface AppRouteParams {
publicKey: string;
};
};
+ [AppRoute.REPOSITORY_COUNTERS_API]: {
+ params: {
+ orgSlug: string;
+ repoSlug: string;
+ };
+ };
[AppRoute.THEME_SET_SCHEME_ACTION]: {
params: {
themeScheme: AppThemeScheme;
@@ -410,6 +418,17 @@ export const AppRouteSchemas: Record<AppRoute, undefined | FastifySchema> = {
},
},
},
+ [AppRoute.REPOSITORY_COUNTERS_API]: {
+ params: {
+ type: "object",
+ required: ["orgSlug", "repoSlug"],
+ additionalProperties: false,
+ properties: {
+ orgSlug: { type: "string" },
+ repoSlug: { type: "string" },
+ },
+ },
+ },
[AppRoute.THEME_SET_SCHEME_ACTION]: {
params: {
type: "object",
@@ -187,21 +187,33 @@ export const makeMergePullRequest: ServiceMethodFactory<
// Optional: delete source branch on the source repo if requested
if (dto.deleteSourceBranch === true) {
- await new Promise<void>((resolve, reject) => {
- const c = spawn(
- "git",
- ["push", sourceBarePath, "--delete", pr.sourceBranch],
- {
- cwd: tmpDir,
- env: { LANG: "C" },
- },
+ try {
+ await new Promise<void>((resolve, reject) => {
+ const c = spawn(
+ "git",
+ ["push", sourceBarePath, "--delete", pr.sourceBranch],
+ {
+ cwd: tmpDir,
+ env: { LANG: "C" },
+ },
+ );
+ let err = "";
+ c.stderr.on("data", (d) => (err += d.toString()));
+ c.on("close", (code) =>
+ code === 0
+ ? resolve()
+ : reject(new Error(err || "delete source branch failed")),
+ );
+ });
+ } catch (err) {
+ // Do not fail the merge if deletion fails
+ // log error for visibility
+ // @ts-ignore
+ console.error(
+ "Failed to delete source branch after merge:",
+ (err as Error).message,
);
- let err = "";
- c.stderr.on("data", (d) => (err += d.toString()));
- c.on("close", (code) =>
- code === 0 ? resolve() : reject(new Error(err || "delete source branch failed")),
- );
- });
+ }
}
// update PR as merged
@@ -218,6 +230,17 @@ export const makeMergePullRequest: ServiceMethodFactory<
success: true,
updatedPullRequest: updatedPR,
};
-
+ } catch (err) {
+ throw err;
+ } finally {
+ // cleanup
+ try {
+ await rm(tmpDir, { recursive: true, force: true });
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+ };
+};
export default makeMergePullRequest;
@@ -42,6 +42,17 @@ export interface CommonViewProps {
export type CommonProps = { commonProps: CommonViewProps };
+export interface LayoutProps extends CommonViewProps {
+ foo?: boolean;
+ appVersion: string;
+ showDrawerPrimary?: boolean;
+ orgSlug?: string;
+ repoSlug?: string;
+ currentRef?: string;
+ path?: string;
+ layoutCounters?: RepositoryCountersDTO;
+}
+
export interface LanguageDetectFn {
(path: string, callback: (err: Error, language: string | null) => void): void;
sync: (path: string | null) => string | null;
@@ -191,3 +202,13 @@ export type RepositoryWithParentAndForkedFromRepos =
RepositoryWithForkedFromRepo & {
organization: Organization;
};
+
+// Centralized counters DTO to be reused by SSR layout and API
+export interface RepositoryCountersDTO {
+ pulls?: number;
+ tests?: number;
+ builds?: number;
+ issues?: number;
+ apiRefSymbols?: number;
+ helpCenterNotifs?: number;
+}