feat(repos): impl. layout counters
+ 197
- 42
add 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": {

app/components/DrawerPrimary.tsx
@@ -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,

app/components/Layout.tsx
@@ -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;

new file
app/controllers/api/getRepositoryCounters.ts
@@ -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;

new file
app/hocs/withLayoutCounters.tsx
@@ -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",

app/services/pullRequest/mergePullRequest.ts
@@ -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;
+}