feat(pulls): add merge pull request
+ 364
- 79
@@ -144,7 +144,11 @@ RUN echo "" > /etc/motd
 # Change to git user home dir
 WORKDIR /home/git/
 
-# Add git-shell command no-interactive-login 
+# Set committer id of this machine
+RUN git config --global user.name "GitFOSS Agent (system)"
+RUN git config --global user.email "git@gitfoss.dev"
+
+# Add git-shell command no-interactive-login
 RUN mkdir git-shell-commands/
 COPY ./data/git-shell-commands/no-interactive-login /home/git/git-shell-commands/no-interactive-login
 RUN chown git:git git-shell-commands/no-interactive-login

@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1778495079942,
+  "_generatedAtUnix": 1778513881558,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "assets": {

...
@@ -70,7 +70,7 @@
       "pathSourceMap": "./public/.islands/RepositoryForkForm.bundle.js.map"
     },
     "RepositoryHero": {
-      "hash": "b0ab4c086a536112a3d6bc3ff3c6c65d29d25288",
+      "hash": "e291deada81cf96f29cc858d9e686c4f4a0bbe10",
       "pathSource": "./app/islands/RepositoryHero.tsx",
       "pathBundle": "./public/.islands/RepositoryHero.bundle.js",
       "pathSourceMap": "./public/.islands/RepositoryHero.bundle.js.map"

...
@@ -122,7 +122,7 @@
       "pathSource": "./app/views/organization/OrganizationDetailsView.tsx"
     },
     "RepositoryBrowserView": {
-      "hash": "570126c6a07d3a866ad7554527d4b3de55879607",
+      "hash": "6c617e1ebda50ebcf3edf3b644185ac5c687a580",
       "pathSource": "./app/views/repository/RepositoryBrowserView.tsx"
     },
     "RepositoryCommitsLogView": {

...
@@ -158,7 +158,7 @@
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestCreateView.tsx"
     },
     "RepositoryPullRequestDetailsView": {
-      "hash": "c2971a7253f86686c3f0b74570274dcb5be75af1",
+      "hash": "01d5ce1ba9934f396ac51b24721d991846cbd667",
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx"
     },
     "RepositoryPullRequestsView": {

app/components/DrawerPrimary.tsx
@@ -10,6 +10,7 @@ import { NamedColors } from "../utils/style";
 import { type CommonViewProps, type WithThemeSchemeProp } from "../types";
 import { buildRouteLink } from "../utils/shared";
 import { AppRoute } from "../routes.defs";
+import { TextEllipsis } from "./TextEllipsis.styled";
 
 export const DrawerPrimary = ({
   visible = false,

...
@@ -19,6 +20,13 @@ export const DrawerPrimary = ({
   repoSlug,
   currentRef = Const.DEFAULT_HEAD_REF,
   path = "/",
+  counters = {
+    pulls: 0,
+    tests: 0,
+    builds: 0,
+    issues: 0,
+    apiRefSymbols: 0,
+  },
 }: WithThemeSchemeProp & {
   visible: boolean;
   commonProps: CommonViewProps;

...
@@ -26,6 +34,14 @@ export const DrawerPrimary = ({
   repoSlug: string;
   currentRef?: string;
   path?: string;
+  counters?: {
+    pulls?: number;
+    tests?: number;
+    builds?: number;
+    issues?: number;
+    apiRefSymbols?: number;
+    helpCenterNotifs?: number;
+  };
 }) => {
   const pathRepo = buildRouteLink(AppRoute.REPOSITORY_DETAILS, {
     orgSlug: orgSlug,

...
@@ -67,18 +83,30 @@ export const DrawerPrimary = ({
       </StyledDrawerHeader>
       <StyledDrawerContent>
         <StyledDrawerListHeader>
-          <span>acme-org</span>
-          <span>/</span>
-          <span>my-app</span>
+          <a href={buildRouteLink(AppRoute.ORGANIZATION_DETAILS, { orgSlug })}>
+            {orgSlug}
+          </a>
+          <span>{" / "}</span>
+          <a
+            href={buildRouteLink(AppRoute.REPOSITORY_DETAILS, {
+              orgSlug,
+              repoSlug,
+            })}
+          >
+            <TextEllipsis>{repoSlug}</TextEllipsis>
+          </a>
         </StyledDrawerListHeader>
         <StyledDrawerList>
           <StyledDrawerListItem
             themeScheme={themeScheme}
             href={pathFiles}
             className={
-              [pathFiles, pathRepo, pathRepoTrailing].includes(
-                commonProps.path || "/",
-              )
+              (commonProps.path!.startsWith(pathFiles) ||
+                [pathFiles, pathRepo, pathRepoTrailing].some(
+                  (p) =>
+                    commonProps.path === p || commonProps.path!.startsWith(p),
+                )) &&
+              commonProps.path!.startsWith(pathPulls) === false
                 ? "active"
                 : undefined
             }

...
@@ -88,26 +116,31 @@ export const DrawerPrimary = ({
           <StyledDrawerListItem
             themeScheme={themeScheme}
             href={pathPulls}
-            className={commonProps.path === pathPulls ? "active" : undefined}
+            className={
+              commonProps.path! === pathPulls ||
+              commonProps.path!.startsWith(pathPulls)
+                ? "active"
+                : undefined
+            }
           >
             <span>Pull Requests</span>
-            <Chip>0</Chip>
+            <Chip>{counters.pulls || 0}</Chip>
           </StyledDrawerListItem>
           <StyledDrawerListItem themeScheme={themeScheme} disabled>
             <span>Tests & Coverage</span>
-            <Chip>0</Chip>
+            <Chip>{counters.tests || 0}</Chip>
           </StyledDrawerListItem>
           <StyledDrawerListItem themeScheme={themeScheme} disabled>
             <span>Builds</span>
-            <Chip>0</Chip>
+            <Chip>{counters.builds || 0}</Chip>
           </StyledDrawerListItem>
           <StyledDrawerListItem themeScheme={themeScheme} disabled>
             <span>Issues</span>
-            <Chip>0</Chip>
+            <Chip>{counters.issues || 0}</Chip>
           </StyledDrawerListItem>
           <StyledDrawerListItem themeScheme={themeScheme} disabled>
             <span>API Reference</span>
-            <Chip>0</Chip>
+            <Chip>{counters.apiRefSymbols || 0}</Chip>
           </StyledDrawerListItem>
         </StyledDrawerList>
         <StyledDrawerListHeader></StyledDrawerListHeader>

...
@@ -120,6 +153,9 @@ export const DrawerPrimary = ({
           </StyledDrawerListItem>
           <StyledDrawerListItem themeScheme={themeScheme} disabled>
             <span>Help Center</span>
+            {counters.helpCenterNotifs! > 0 && (
+              <Chip>{counters.helpCenterNotifs}</Chip>
+            )}
           </StyledDrawerListItem>
           <StyledDrawerListItem themeScheme={themeScheme} disabled>
             <span>Settings</span>

...
@@ -203,15 +239,20 @@ const StyledDrawerContent = styled.main`
 
 const StyledDrawerListHeader = styled.section`
   width: 100%;
-  height: 40px;
+  min-height: 40px;
 
   display: flex;
+  flex-flow: row wrap;
   justify-content: center;
   align-items: center;
 
   font-weight: bold;
 
-  margin-bottom: 4px;
+  margin-bottom: 12px;
+
+  & > span {
+    margin: 0 4px;
+  }
 `;
 
 const StyledDrawerList = styled.section`

app/components/Layout.tsx
@@ -1,5 +1,5 @@
 // 3rd-party
-import React, { FC } from "react";
+import React, { FC, useState } from "react";
 import styled, { css } from "styled-components";
 // app
 import type { CommonViewProps, WithThemeSchemeProp } from "../types";

...
@@ -45,6 +45,9 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
     themeScheme,
   };
 
+  const [drawerPrimaryOpen, setDrawerPrimaryOpen] =
+    useState<boolean>(showDrawerPrimary);
+
   return (
     <>
       <style

...
@@ -98,7 +101,7 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
           <DrawerPrimary
             commonProps={commonProps as any}
             themeScheme={themeScheme}
-            visible={showDrawerPrimary}
+            visible={drawerPrimaryOpen}
             orgSlug={orgSlug}
             repoSlug={repoSlug}
             currentRef={currentRef}

...
@@ -106,13 +109,14 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
           />
           <StyledChildrenWrapper
             {...sharedProps}
-            showDrawerPrimary={showDrawerPrimary}
+            showDrawerPrimary={drawerPrimaryOpen}
           >
             <StyledPageHeaderWrapper {...sharedProps}>
               <PageHeader
                 commonProps={commonProps}
                 themeScheme={themeScheme}
                 forceShowLogo={showDrawerPrimary !== true}
+                setDrawerPrimaryOpen={setDrawerPrimaryOpen}
               />
             </StyledPageHeaderWrapper>
             {children}

app/components/PageHeader.tsx
@@ -11,15 +11,23 @@ import { PageWrapper } from "./PageWrapper";
 
 interface PageHeaderProps extends CommonProps {
   forceShowLogo?: boolean;
+  setDrawerPrimaryOpen?: (predicate: (prev: boolean) => boolean) => void;
 }
 
 export const PageHeader: VFC<PageHeaderProps & WithThemeSchemeProp> = ({
   commonProps,
   themeScheme,
   forceShowLogo = true,
+  setDrawerPrimaryOpen = undefined,
 }) => {
   const invertThemeScheme = themeScheme === "light" ? "dark" : "light";
 
+  const toggleDrawerPrimary = () => {
+    if (setDrawerPrimaryOpen) {
+      setDrawerPrimaryOpen((prev) => !prev);
+    }
+  };
+
   const pageHeaderActions = useMemo(() => {
     if (commonProps.authenticated) {
       return (

...
@@ -65,6 +73,10 @@ export const PageHeader: VFC<PageHeaderProps & WithThemeSchemeProp> = ({
   return (
     <StyledPageHeader themeScheme={themeScheme}>
       <PageWrapper>
+        <StyledBurgerMenu
+          themeScheme={themeScheme}
+          onClick={toggleDrawerPrimary}
+        />
         <StyledLogoArea themeScheme={themeScheme} forceShowLogo={forceShowLogo}>
           <a href={"/"}>
             <h1>{Const.APP_NAME}</h1>

...
@@ -130,6 +142,25 @@ export const PageHeader: VFC<PageHeaderProps & WithThemeSchemeProp> = ({
   );
 };
 
+const StyledBurgerMenu = styled.button<WithThemeSchemeProp>`
+  ${({ themeScheme }) => css`
+    /* above mobile size */
+    @media only screen and (min-width: 768px) {
+      & {
+        display: none;
+      }
+    }
+
+    width: 40px;
+    height: 40px;
+    border-image: none;
+    border: none;
+    border-radius: 20px;
+    background: ${NamedColors.CARD_OVERLAY[themeScheme]};
+    /*color: red;*/
+  `}
+`;
+
 const StyledPageHeader = styled.header<WithThemeSchemeProp>`
   display: flex;
   flex-flow: row nowrap;

app/components/PageWrapper.tsx
@@ -10,7 +10,7 @@ export const PageWrapper = styled.div`
   width: 100%;
 
   margin: 0 auto;
-  padding: 24px 16px 64px 16px;
+  padding: 16px 16px 64px 16px;
 
   @media only screen and (min-width: 1260px) {
     max-width: 1260px;

app/controllers/repositoryPullRequests/postRepositoryPullRequestMergeAction.ts
@@ -10,9 +10,11 @@ import { makePullRequestService } from "../../services/pullRequest";
 import { makeRepositoryService } from "../../services/repository";
 import { makeUsersService } from "../../services/user";
 // app views
-import RepositoryPullRequestsView, {
-  RepositoryPullRequestsViewProps,
-} from "../../views/repositoryPullRequests/RepositoryPullRequestsView";
+// import RepositoryPullRequestsView, {
+//   RepositoryPullRequestsViewProps,
+// } from "../../views/repositoryPullRequests/RepositoryPullRequestsView";
+// new merge service
+import { buildRouteLink } from "../../utils/shared";
 
 const postRepositoryPullRequestMergeAction: ReqHandler<
   AppRouteParams,

...
@@ -40,7 +42,7 @@ const postRepositoryPullRequestMergeAction: ReqHandler<
   const pullRequest = await prService.getPullRequestByUid(
     orgSlug,
     repoSlug,
-    pullUid
+    pullUid,
   );
 
   if (pullRequest == null) {

...
@@ -48,7 +50,7 @@ const postRepositoryPullRequestMergeAction: ReqHandler<
   }
 
   const sourceRepo = await repoService.getRepositoryById(
-    pullRequest.sourceRepositoryId
+    pullRequest.sourceRepositoryId,
   );
 
   if (sourceRepo == null) {

...
@@ -56,7 +58,7 @@ const postRepositoryPullRequestMergeAction: ReqHandler<
   }
 
   const targetRepo = await repoService.getRepositoryById(
-    pullRequest.targetRepositoryId
+    pullRequest.targetRepositoryId,
   );
 
   if (targetRepo == null) {

...
@@ -64,7 +66,7 @@ const postRepositoryPullRequestMergeAction: ReqHandler<
   }
 
   const sourceParentOrg = await orgService.getOrganizationById(
-    sourceRepo.organizationId
+    sourceRepo.organizationId,
   );
 
   if (sourceParentOrg == null) {

...
@@ -83,7 +85,7 @@ const postRepositoryPullRequestMergeAction: ReqHandler<
   }
 
   const targetParentOrg = await orgService.getOrganizationById(
-    targetRepo.organizationId
+    targetRepo.organizationId,
   );
 
   if (targetParentOrg == null) {

...
@@ -108,14 +110,14 @@ const postRepositoryPullRequestMergeAction: ReqHandler<
     fileDiffs = await repoService.getRepositoryRefDiff(
       sourceRepo,
       pullRequest.targetBranch,
-      pullRequest.sourceBranch
+      pullRequest.sourceBranch,
     );
   } else {
     fileDiffs = await repoService.getRepositoryRemoteRefDiff(
       sourceRepo,
       pullRequest.sourceBranch,
       targetRepo,
-      pullRequest.targetBranch
+      pullRequest.targetBranch,
     );
   }
 

...
@@ -124,17 +126,34 @@ const postRepositoryPullRequestMergeAction: ReqHandler<
     throw new Error("Cannot merge two branches without difference.");
   }
 
-  // 3. Do the merge !
+  // 3. Do the merge via service method
+  try {
+    const result = await prService.mergePullRequest({
+      pullRequestId: pullRequest.id,
+      mergeMessage: merge_message ?? undefined,
+    });
 
-  const reqHandler = reply.makeRequestHandler(request, reply);
-  return reqHandler<RepositoryPullRequestsViewProps>(
-    RepositoryPullRequestsView.name,
-    {
-      // parentOrg,
-      pullRequest,
-      // repo,
+    if (result.success !== true) {
+      return reply.status(500).send({ error: "Merge failed" });
     }
-  );
+
+    const updatedPull = result.updatedPullRequest || pullRequest;
+
+    // Redirect to PR details page after merge
+    return reply.redirect(
+      303,
+      buildRouteLink(AppRoute.REPOSITORY_PULL_REQUEST_DETAILS, {
+        orgSlug,
+        repoSlug,
+        pullUid: updatedPull.uid,
+      }),
+    );
+  } catch (err) {
+    console.error("Merge action failed:", (err as Error).message);
+    return reply
+      .status(500)
+      .send({ error: "Merge action failed", detail: (err as Error).message });
+  }
 };
 
 export default postRepositoryPullRequestMergeAction;

app/islands/RepositoryHero.tsx
@@ -27,15 +27,20 @@ const RepositoryHero: ReactIsland<RepositoryHeroProps> = ({
   forkedFromRepo = null,
   forksCount = 0,
   path = undefined,
-  separator = "∙",
+  // separator = "∙",
   showForkButton = true,
 }) => {
   return (
     <Grid.Col fluid gap={16}>
       <Grid.Row fluid alignItems={"center"}>
-        <Grid.Col nowrap flex={"1 0 468px"} style={{ minWidth: 468 }}>
-          <h1 style={{ margin: 0 }}>
+        <Grid.Col
+          nowrap
+          flex={"1 0 468px"}
+          style={{ minWidth: 468, marginBottom: 12 }}
+        >
+          <h2 style={{ margin: 0 }}>
             <a
+              style={{ whiteSpace: "nowrap" }}
               href={buildRouteLink(AppRoute.ORGANIZATION_DETAILS, {
                 orgSlug: parentOrg.slug,
               })}

...
@@ -44,6 +49,7 @@ const RepositoryHero: ReactIsland<RepositoryHeroProps> = ({
             </a>
             {" / "}
             <a
+              style={{ whiteSpace: "nowrap" }}
               href={buildRouteLink(AppRoute.REPOSITORY_DETAILS, {
                 orgSlug: parentOrg.slug,
                 repoSlug: repo.slug,

...
@@ -51,15 +57,12 @@ const RepositoryHero: ReactIsland<RepositoryHeroProps> = ({
             >
               {repo.displayName || repo.slug}
             </a>
-            {` ${separator} `}
-            {path == null ? (
-              <span style={{ textTransform: "capitalize", fontSize: 16 }}>
-                ({repo.visibility.toLowerCase()})
-              </span>
-            ) : (
-              <span>{path}</span>
-            )}
-          </h1>
+          </h2>
+          <div style={{ flex: 1 }}>
+            <h3 style={{ whiteSpace: "nowrap", margin: 0, marginTop: 4 }}>
+              {path || "Files"}
+            </h3>
+          </div>
           <div style={{ flex: 1 }}>
             {repo.isFork && forkedFromRepo != null && (
               <h5 style={{ margin: 0, marginTop: 8 }}>

...
@@ -117,16 +120,6 @@ const RepositoryHero: ReactIsland<RepositoryHeroProps> = ({
           </ButtonAnchor>
         )}
       </Grid.Row>
-      <Grid.Col fluid nowrap gap={16}>
-        <a
-          href={buildRouteLink(AppRoute.REPOSITORY_PULL_REQUESTS, {
-            orgSlug: parentOrg.slug,
-            repoSlug: repo.slug,
-          })}
-        >
-          Pull Requests
-        </a>
-      </Grid.Col>
     </Grid.Col>
   );
 };

app/services/pullRequest/index.ts
@@ -3,17 +3,19 @@ import { makeService } from "@ethicdevs/react-monolith";
 // service types
 import type { PullRequestServiceDeps, PullRequestServiceAPI } from "./types";
 // service methods
-import { default as makeCreatePullRequest } from "./createPullRequest";
 import { default as makeGetPullRequestById } from "./getPullRequestById";
 import { default as makeGetPullRequestByUid } from "./getPullRequestByUid";
 import { default as makeGetPullRequestsInRepository } from "./getPullRequestsInRepository";
+import { default as makeCreatePullRequest } from "./createPullRequest";
+import { default as makeMergePullRequest } from "./mergePullRequest";
 
 export const makePullRequestService = makeService<
   PullRequestServiceAPI,
   PullRequestServiceDeps
 >({
-  createPullRequest: makeCreatePullRequest,
   getPullRequestById: makeGetPullRequestById,
   getPullRequestByUid: makeGetPullRequestByUid,
   getPullRequestsInRepository: makeGetPullRequestsInRepository,
+  createPullRequest: makeCreatePullRequest,
+  mergePullRequest: makeMergePullRequest,
 });

new file
app/services/pullRequest/mergePullRequest.ts
@@ -0,0 +1,186 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// 3rd-party
+import { join } from "node:path";
+import { mkdtemp, rm } from "fs/promises";
+import { spawn } from "node:child_process";
+import os from "os";
+// generated via script[generate:prisma]
+import type { Repository } from "@prisma/client";
+// app
+import { Env } from "../../env";
+import type {
+  MergePullRequestDTO,
+  MergePullRequestResult,
+  PullRequestServiceDeps,
+  // PullRequestServiceAPI,
+} from "./types";
+
+// Implementation of a merge operation for a pull request using a temporary working copy
+// This service is deliberately conservative and uses the standard merge workflow by default.
+// It does not attempt to squash or rebase unless you extend the DTO.
+
+export const makeMergePullRequest: ServiceMethodFactory<
+  PullRequestServiceDeps,
+  [MergePullRequestDTO],
+  Promise<MergePullRequestResult>
+> = ({ request }) => {
+  return async (dto) => {
+    // fetch pull request with related repos and their organizations to resolve paths
+    const pr = await request.prisma.pullRequest.findUnique({
+      where: { id: dto.pullRequestId },
+      include: {
+        sourceRepository: {
+          include: {
+            organization: true,
+          },
+        },
+        targetRepository: {
+          include: {
+            organization: true,
+          },
+        },
+      },
+    });
+
+    if (pr == null) {
+      throw new Error("Pull request not found");
+    }
+
+    const sourceRepo: Repository | null = pr.sourceRepository ?? null;
+    const targetRepo: Repository | null = pr.targetRepository ?? null;
+
+    if (sourceRepo == null || targetRepo == null) {
+      throw new Error("Invalid pull request repositories");
+    }
+
+    const sourceOrgSlug = pr.sourceRepository.organization.slug ?? null;
+    const targetOrgSlug = pr.targetRepository.organization.slug ?? null;
+
+    if (!sourceOrgSlug || !targetOrgSlug) {
+      throw new Error("Could not resolve repository organizations");
+    }
+
+    // bare repo paths
+    const sourceBarePath = `${Env.GIT_REPOSITORIES_ROOT}/${sourceOrgSlug}/${sourceRepo.slug}.git`;
+    const targetBarePath = `${Env.GIT_REPOSITORIES_ROOT}/${targetOrgSlug}/${targetRepo.slug}.git`;
+
+    // ensure bare repos exist
+    // Best-effort check; actual fs check is optional here, rely on git failing gracefully otherwise
+
+    // create a temporary working directory
+    const tmpDir = await mkdtemp(join(os.tmpdir(), "gitfoss-merge-"));
+
+    try {
+      // clone target bare repo into working copy
+      await new Promise<void>((resolve, reject) => {
+        const c = spawn("git", ["clone", targetBarePath, tmpDir], {
+          cwd: undefined,
+          env: { LANG: "C" },
+        });
+        let err = "";
+        c.stderr.on("data", (d) => (err += d.toString()));
+        c.on("close", (code) =>
+          code === 0 ? resolve() : reject(new Error(err)),
+        );
+      });
+
+      // add source as remote
+      await new Promise<void>((resolve, reject) => {
+        const c = spawn("git", ["remote", "add", "source", sourceBarePath], {
+          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)),
+        );
+      });
+
+      // fetch source branch
+      await new Promise<void>((resolve, reject) => {
+        const c = spawn("git", ["fetch", "source", 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)),
+        );
+      });
+
+      // checkout target branch
+      await new Promise<void>((resolve, reject) => {
+        const c = spawn("git", ["checkout", pr.targetBranch], {
+          cwd: tmpDir,
+          env: { LANG: "C" },
+        });
+        c.on("close", (code) =>
+          code === 0 ? resolve() : reject(new Error("checkout failed")),
+        );
+      });
+
+      // merge
+      const mergeArgs = ["merge", "--no-ff", `source/${pr.sourceBranch}`];
+      if (dto.mergeMessage && dto.mergeMessage.trim() !== "") {
+        mergeArgs.push("-m", dto.mergeMessage);
+      }
+      await new Promise<void>((resolve, reject) => {
+        const c = spawn("git", mergeArgs, {
+          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 || "merge failed")),
+        );
+      });
+
+      // push merged result back to target bare repo
+      await new Promise<void>((resolve, reject) => {
+        const c = spawn(
+          "git",
+          ["push", targetBarePath, `${pr.targetBranch}:${pr.targetBranch}`],
+          {
+            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 || "push failed")),
+        );
+      });
+
+      // update PR as merged
+      const updatedPR = await request.prisma.pullRequest.update({
+        where: { id: pr.id },
+        data: {
+          state: (require("@prisma/client").PullRequestState as any)
+            .CLOSE_MERGED,
+          closedAt: new Date(),
+        },
+      });
+
+      return {
+        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;

app/services/pullRequest/types.ts
@@ -2,7 +2,7 @@
 import type { ServiceApiContract } from "@ethicdevs/react-monolith";
 // 3rd-party
 import type { FastifyRequest } from "fastify";
-// generated via script[prisma:generate]
+// generated via script[generate via prisma]
 import { Prisma, PullRequest, Repository, User } from "@prisma/client";
 
 export interface CreatePullRequestDTO {

...
@@ -23,22 +23,34 @@ export type PullRequestSelectOrIncludes =
   | { select?: Prisma.PullRequestSelect }
   | { includes?: Prisma.PullRequestInclude };
 
+export interface MergePullRequestDTO {
+  pullRequestId: string;
+  mergeMessage?: string;
+  // could extend with mergeStrategy in future (e.g., merge, squash, rebase)
+}
+
+export interface MergePullRequestResult {
+  success: boolean;
+  updatedPullRequest?: PullRequest;
+}
+
 export interface PullRequestServiceAPI extends ServiceApiContract {
   getPullRequestById(
     pullRequestId: string,
-    selectOrIncludes?: PullRequestSelectOrIncludes
+    selectOrIncludes?: PullRequestSelectOrIncludes,
   ): Promise<PullRequest | null>;
   getPullRequestByUid<R = PullRequest | null>(
     orgSlug: string,
     repoSlug: string,
     pullRequestUid: number,
-    selectOrIncludes?: PullRequestSelectOrIncludes
+    selectOrIncludes?: PullRequestSelectOrIncludes,
   ): Promise<R>;
   getPullRequestsInRepository(
     repository: Repository,
-    selectOrIncludes?: PullRequestSelectOrIncludes
+    selectOrIncludes?: PullRequestSelectOrIncludes,
   ): Promise<PullRequest[]>;
   createPullRequest(dto: CreatePullRequestDTO): Promise<PullRequest>;
+  mergePullRequest(dto: MergePullRequestDTO): Promise<MergePullRequestResult>;
 }
 
 export interface PullRequestServiceDeps {

app/views/repository/RepositoryBrowserView.tsx
@@ -63,6 +63,7 @@ const RepositoryBrowserView: ReactView<RepositoryBrowserViewProps> = ({
       orgSlug={parentOrg.slug}
       repoSlug={repo.slug}
       currentRef={currentRef}
+      path={path}
     >
       <PageWrapper>
         <IslandWrapper data-islandid={`${RepositoryHero.name}$$0`}>

app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx
@@ -242,15 +242,7 @@ const RepositoryPullRequestDetailsView: ReactView<
                       alignItems={"center"}
                       style={{ marginTop: 8 }}
                     >
-                      <button type={"submit"} name={"merge_default"}>
-                        Merge
-                      </button>
-                      <button type={"submit"} name={"merge_squash"}>
-                        Merge w/ Squash
-                      </button>
-                      <button type={"submit"} name={"merge_rebase"}>
-                        Merge w/ Rebase
-                      </button>
+                      <button type={"submit"}>Merge</button>
                     </Grid.Row>
                   </Grid.Col>
                 </form>