fix(pulls): make it possible to merge / delete source branch
+ 319
- 45
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1778523219539,
+  "_generatedAtUnix": 1778580625817,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "assets": {

...
@@ -158,7 +158,7 @@
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestCreateView.tsx"
     },
     "RepositoryPullRequestDetailsView": {
-      "hash": "81c67f32cc4b2b28b8e2949c0c2b8d09669c3a16",
+      "hash": "19899c1ff646728a0031e47e70e2aaecbbe16dde",
       "pathSource": "./app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx"
     },
     "RepositoryPullRequestsView": {

app/components/Card.styled.ts
@@ -13,7 +13,7 @@ export const Card = styled.div<WithThemeSchemeProp>`
 
   ${({ themeScheme = "light" }) => css`
     background-color: ${NamedColors.CARD[themeScheme]};
-    border: 1px solid ${NamedColors.BORDER_CARD[themeScheme]};
+    border: none !important;
   `};
 
   border-radius: 12px;

new file
app/controllers/repositoryPullRequests/deleteSourceBranchAction.ts
@@ -0,0 +1,174 @@
+// 1st-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+import { ResourceVisibility, PullRequestState } from "@prisma/client";
+// 3rd-party
+import { spawn } from "node:child_process";
+import { mkdtemp, rm } from "fs/promises";
+import os from "os";
+import { join } from "node:path";
+// app
+import { AppRoute, AppRouteParams } from "../../routes.defs";
+// app services
+import { makeOrganizationService } from "../../services/organization";
+import { makePullRequestService } from "../../services/pullRequest";
+import { makeRepositoryService } from "../../services/repository";
+import { makeUsersService } from "../../services/user";
+// app env
+import { Env } from "../../env";
+
+// This controller deletes the source branch of a merged PR by deleting the branch
+// from the PR's source repository's bare path. It clones the source bare path
+// and runs: git push <bare> --delete <branch>
+const deleteSourceBranchAction: ReqHandler<
+  AppRouteParams,
+  AppRoute.REPOSITORY_PULL_REQUEST_DELETE_SOURCE_BRANCH_ACTION
+> = async (request, reply) => {
+  const { orgSlug, repoSlug, pullUid } = request.params as any;
+
+  const orgService = makeOrganizationService({ request });
+  const prService = makePullRequestService({ request });
+  const repoService = makeRepositoryService({ request });
+  const usersService = makeUsersService({ request });
+
+  const currentUser =
+    request.session.data.authenticated &&
+    request.session.data.curr_user_uid != null
+      ? await usersService.getUserById(request.session.data.curr_user_uid)
+      : null;
+
+  const pullRequest = await prService.getPullRequestByUid(
+    orgSlug,
+    repoSlug,
+    pullUid,
+  );
+
+  if (pullRequest == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  // Enforce that deletion is allowed only after a merge has completed
+  if (pullRequest.state !== PullRequestState.CLOSE_MERGED) {
+    return reply
+      .status(403)
+      .send({ error: "Deletion allowed only after PR is merged" });
+  }
+
+  const sourceRepo = await repoService.getRepositoryById(
+    pullRequest.sourceRepositoryId,
+  );
+
+  if (sourceRepo == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  const sourceParentOrg = await orgService.getOrganizationById(
+    sourceRepo.organizationId,
+  );
+
+  if (sourceParentOrg == null) {
+    return reply.status(404).callNotFound();
+  }
+
+  if (sourceRepo.visibility === ResourceVisibility.PRIVATE) {
+    if (currentUser == null) {
+      return reply.status(404).callNotFound();
+    } else if (
+      (await repoService.canUserAccessRepository(currentUser, sourceRepo)) ===
+      false
+    ) {
+      return reply.status(404).callNotFound();
+    }
+  }
+
+  // bare repo path for source
+  const sourceBarePath = `${Env.GIT_REPOSITORIES_ROOT}/${sourceParentOrg.slug}/${sourceRepo.slug}.git`;
+
+  // create a temporary working directory
+  const tmpDir = await mkdtemp(join(os.tmpdir(), "gitfoss-delete-src-"));
+
+  try {
+    // clone source bare repo into working copy
+    await new Promise<void>((resolve, reject) => {
+      const c = spawn("git", ["clone", sourceBarePath, 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)),
+      );
+    });
+
+    // setup committer identity
+    await new Promise<void>((resolve, reject) => {
+      const c = spawn(
+        "git",
+        ["config", "user.name", "GitFOSS Agent (system)"],
+        {
+          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)),
+      );
+    });
+
+    await new Promise<void>((resolve, reject) => {
+      const c = spawn("git", ["config", "user.email", "git@gitfoss.dev"], {
+        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 using bare path as remote
+    await new Promise<void>((resolve, reject) => {
+      const c = spawn(
+        "git",
+        ["push", sourceBarePath, "--delete", pullRequest.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")),
+      );
+    });
+
+    // cleanup
+    return reply
+      .status(200)
+      .send({ success: true, deletedBranch: pullRequest.sourceBranch });
+  } catch (err) {
+    console.error(
+      "Delete source branch action failed:",
+      (err as Error).message,
+    );
+    return reply.status(500).send({
+      error: "Delete source branch action failed",
+      detail: (err as Error).message,
+    });
+  } finally {
+    try {
+      // best-effort cleanup
+      await rm(tmpDir, { recursive: true, force: true });
+    } catch {
+      // ignore
+    }
+  }
+};
+
+export default deleteSourceBranchAction;

app/controllers/repositoryPullRequests/getRepositoryPullRequestDetailsView.ts
@@ -28,7 +28,7 @@ const getRepositoryPullRequestDetailsView: ReqHandler<
   const pullRequest = await prService.getPullRequestByUid(
     orgSlug,
     repoSlug,
-    pullUid
+    pullUid,
   );
 
   if (pullRequest == null) {

...
@@ -57,10 +57,10 @@ const getRepositoryPullRequestDetailsView: ReqHandler<
       : null;
 
   const sourceRepo = await repoService.getRepositoryById(
-    pullRequest.sourceRepositoryId
+    pullRequest.sourceRepositoryId,
   );
   const targetRepo = await repoService.getRepositoryById(
-    pullRequest.targetRepositoryId
+    pullRequest.targetRepositoryId,
   );
 
   if (sourceRepo == null || targetRepo == null) {

...
@@ -68,11 +68,11 @@ const getRepositoryPullRequestDetailsView: ReqHandler<
   }
 
   const sourceParentOrg = await orgService.getOrganizationById(
-    sourceRepo.organizationId
+    sourceRepo.organizationId,
   );
 
   const targetParentOrg = await orgService.getOrganizationById(
-    targetRepo.organizationId
+    targetRepo.organizationId,
   );
 
   if (sourceParentOrg == null || targetParentOrg == null) {

...
@@ -101,11 +101,37 @@ const getRepositoryPullRequestDetailsView: ReqHandler<
     }
   }
 
+  // Determine if current user can delete the source branch: PR author or admin in source org
+  let canDeleteSourceBranch = false;
+  // new: also compute whether user can merge the PR (to show Merge form accordingly)
+  let canMergePullRequest = false;
+  if (currentUser != null) {
+    try {
+      const memberships = await usersService.getUserOrganizationMemberships(
+        currentUser.id,
+      );
+      const isAuthor = pullRequest.authorId === currentUser.id;
+      const isAdminInSourceOrg = memberships?.some((m: any) => {
+        // Ensure we have sourceParentOrg available; guard against undefined
+        return (
+          sourceParentOrg?.id != null &&
+          m.organizationId === sourceParentOrg.id &&
+          (m.role === "ADMIN" || m.role === "OWNER")
+        );
+      });
+      canDeleteSourceBranch = isAuthor || Boolean(isAdminInSourceOrg);
+      canMergePullRequest = isAuthor || Boolean(isAdminInSourceOrg);
+    } catch {
+      canDeleteSourceBranch = pullRequest.authorId === currentUser.id;
+      canMergePullRequest = pullRequest.authorId === currentUser.id;
+    }
+  }
+
   const commitLogs = await repoService.getRepositoryCommitLog(
     sourceRepo,
     "",
     pullRequest.sourceBranch,
-    true
+    true,
   );
 
   const lastCommit = commitLogs.length >= 1 ? commitLogs[0] : null;

...
@@ -116,14 +142,14 @@ const getRepositoryPullRequestDetailsView: ReqHandler<
     filesDiffs = await repoService.getRepositoryRefDiff(
       sourceRepo,
       pullRequest.targetBranch,
-      pullRequest.sourceBranch
+      pullRequest.sourceBranch,
     );
   } else {
     filesDiffs = await repoService.getRepositoryRemoteRefDiff(
       sourceRepo,
       pullRequest.sourceBranch,
       targetRepo,
-      pullRequest.targetBranch
+      pullRequest.targetBranch,
     );
   }
 

...
@@ -139,8 +165,9 @@ const getRepositoryPullRequestDetailsView: ReqHandler<
       sourceRepo,
       targetParentOrg,
       targetRepo,
-      isCurrentUserAllowedToMerge: true,
-    }
+      canDeleteSourceBranch,
+      canMergePullRequest,
+    },
   );
 };
 

app/controllers/repositoryPullRequests/index.ts
@@ -9,6 +9,7 @@ import { default as getRepositoryPullRequestDetailsView } from "./getRepositoryP
 import { default as postRepositoryPullRequestMergeAction } from "./postRepositoryPullRequestMergeAction";
 import { default as getRepositoryPullRequestUpdateAction } from "./getRepositoryPullRequestUpdateAction";
 import { default as getRepositoryPullRequestsView } from "./getRepositoryPullRequestsView";
+import { default as deleteSourceBranchAction } from "./deleteSourceBranchAction";
 
 export const RepositoryPullRequestsController = {
   getRepositoryPullRequestCloseAction,

...
@@ -22,4 +23,5 @@ export const RepositoryPullRequestsController = {
   getRepositoryPullRequestUpdateAction,
   getRepositoryPullRequestsView,
   postRepositoryPullRequestCreateAction,
+  deleteSourceBranchAction,
 };

@@ -38,6 +38,7 @@ export enum AppRoute {
   REPOSITORY_PULL_REQUEST_DELETE_ACTION = "repository.pull_request.delete.action",
   REPOSITORY_PULL_REQUEST_DETAILS = "repository.pull_request.details",
   REPOSITORY_PULL_REQUEST_MERGE_ACTION = "repository.pull_request.merge.action",
+  REPOSITORY_PULL_REQUEST_DELETE_SOURCE_BRANCH_ACTION = "repository.pull_request.delete_source_branch.action",
   REPOSITORY_PULL_REQUEST_UPDATE_ACTION = "repository.pull_request.update.action",
   REPOSITORY_PULL_REQUESTS = "repository.pull_requests",
   REPOSITORY_SHOW_OBJECT = "repository.show_object",

...
@@ -92,6 +93,8 @@ export const AppRoutePaths: Record<AppRoute, string> = {
     "/:orgSlug/:repoSlug/pulls/:pullUid",
   [AppRoute.REPOSITORY_PULL_REQUEST_MERGE_ACTION]:
     "/:orgSlug/:repoSlug/pulls/:pullUid/merge",
+  [AppRoute.REPOSITORY_PULL_REQUEST_DELETE_SOURCE_BRANCH_ACTION]:
+    "/:orgSlug/:repoSlug/pulls/:pullUid/delete-source-branch",
   [AppRoute.REPOSITORY_PULL_REQUEST_UPDATE_ACTION]:
     "/:orgSlug/:repoSlug/pulls/:pullUid/edit",
   [AppRoute.REPOSITORY_PULL_REQUESTS]: "/:orgSlug/:repoSlug/pulls",

...
@@ -323,6 +326,13 @@ export interface AppRouteParams {
       delete_source_branch?: boolean;
     };
   };
+  [AppRoute.REPOSITORY_PULL_REQUEST_DELETE_SOURCE_BRANCH_ACTION]: {
+    params: {
+      orgSlug: string;
+      repoSlug: string;
+      pullUid: number;
+    };
+  };
   [AppRoute.REPOSITORY_PULL_REQUEST_UPDATE_ACTION]: {
     params: {
       orgSlug: string;

...
@@ -950,6 +960,24 @@ export const AppRouteSchemas: Record<AppRoute, undefined | FastifySchema> = {
       },
     },
   },
+  [AppRoute.REPOSITORY_PULL_REQUEST_DELETE_SOURCE_BRANCH_ACTION]: {
+    params: {
+      type: "object",
+      required: ["orgSlug", "repoSlug", "pullUid"],
+      additionalProperties: false,
+      properties: {
+        orgSlug: {
+          type: "string",
+        },
+        repoSlug: {
+          type: "string",
+        },
+        pullUid: {
+          type: "number",
+        },
+      },
+    },
+  },
   [AppRoute.REPOSITORY_PULL_REQUEST_UPDATE_ACTION]: {
     params: {
       type: "object",

@@ -74,13 +74,6 @@ const RootAppRouter: AppRouter<AppRouteParams> = () => {
           schema={AppRouteSchemas[AppRoute.SSH_AUTH]}
           handler={SSHAuthController.onSSHAuth}
         />
-        <Route
-          name={AppRoute.REPOSITORY_COUNTERS_API}
-          method={"GET"}
-          path={AppRoutePaths[AppRoute.REPOSITORY_COUNTERS_API]}
-          schema={AppRouteSchemas[AppRoute.REPOSITORY_COUNTERS_API]}
-          handler={APIControllers.getRepositoryCounters}
-        />
         {/* --- */}
         <Route
           name={AppRoute.AUTH_REGISTER}

...
@@ -387,6 +380,22 @@ const RootAppRouter: AppRouter<AppRouteParams> = () => {
             RepositoryPullRequestsController.postRepositoryPullRequestMergeAction
           }
         />
+        <Route
+          name={AppRoute.REPOSITORY_PULL_REQUEST_DELETE_SOURCE_BRANCH_ACTION}
+          method={"POST"}
+          path={
+            AppRoutePaths[
+              AppRoute.REPOSITORY_PULL_REQUEST_DELETE_SOURCE_BRANCH_ACTION
+            ]
+          }
+          schema={
+            AppRouteSchemas[
+              AppRoute.REPOSITORY_PULL_REQUEST_DELETE_SOURCE_BRANCH_ACTION
+            ]
+          }
+          preHandler={loggedOrLoginRedirect}
+          handler={RepositoryPullRequestsController.deleteSourceBranchAction}
+        />
         <Route
           name={AppRoute.REPOSITORY_PULL_REQUEST_UPDATE_ACTION}
           method={"POST"}

app/views/repositoryPullRequests/RepositoryPullRequestDetailsView.tsx
@@ -42,7 +42,8 @@ export interface RepositoryPullRequestDetailsViewProps extends CommonProps {
   sourceRepo: RepositoryWithForkedFromRepo;
   targetParentOrg: Organization;
   targetRepo: RepositoryWithForkedFromRepo;
-  isCurrentUserAllowedToMerge?: boolean;
+  canDeleteSourceBranch?: boolean;
+  canMergePullRequest?: boolean;
 }
 
 const RepositoryPullRequestDetailsView: ReactView<

...
@@ -57,7 +58,8 @@ const RepositoryPullRequestDetailsView: ReactView<
   sourceRepo,
   targetParentOrg: parentOrg,
   targetRepo: repo,
-  isCurrentUserAllowedToMerge = false,
+  canDeleteSourceBranch = false,
+  canMergePullRequest = false,
 }) => {
   const totalDiff = filesDiffs.reduce(
     (acc, diff) => {

...
@@ -71,9 +73,9 @@ const RepositoryPullRequestDetailsView: ReactView<
     { additions: 0, deletions: 0 },
   );
 
-  if (pr.state !== PullRequestState.OPEN) {
-    isCurrentUserAllowedToMerge = false;
-  }
+  // if (pr.state !== PullRequestState.OPEN) {
+  //   canMergePullRequest = false;
+  // }
 
   return (
     <Layout

...
@@ -120,7 +122,7 @@ const RepositoryPullRequestDetailsView: ReactView<
               >
                 {prAuthor.displayName || prAuthor.username}
               </a>
-              {isCurrentUserAllowedToMerge && (
+              {canMergePullRequest && (
                 <>
                   <a
                     href={buildRouteLink(

...
@@ -174,7 +176,7 @@ const RepositoryPullRequestDetailsView: ReactView<
             <Grid.Row
               fluid
               alignItems={"center"}
-              style={{ opacity: 0.67, marginTop: 8 }}
+              style={{ opacity: 0.67, marginTop: 8, gap: 8 }}
             >
               {new Date(pr.createdAt).getTime() <=
                 new Date(pr.updatedAt).getTime() && (

...
@@ -191,14 +193,11 @@ const RepositoryPullRequestDetailsView: ReactView<
                 </span>
               )}
               {pr.closedAt != null && (
-                <span>
-                  closed on
-                  {new Date(pr.closedAt).toLocaleString()}
-                </span>
+                <span>closed on {new Date(pr.closedAt).toLocaleString()}</span>
               )}
             </Grid.Row>
           </Grid.Col>
-          {isCurrentUserAllowedToMerge && (
+          {canMergePullRequest && pr.state === PullRequestState.OPEN && (
             <Card
               style={{ width: "100%", padding: 8, marginTop: 16 }}
               themeScheme={commonProps.themeScheme}

...
@@ -245,24 +244,59 @@ const RepositoryPullRequestDetailsView: ReactView<
                       <button type={"submit"}>Merge</button>
                     </Grid.Row>
                     {/* Delete source branch after merge checkbox */}
-                    <Grid.Col fluid style={{ marginTop: 8 }}>
-                      <label
-                        htmlFor={"delete_source_branch"}
-                        style={{ display: "block" }}
-                      >
-                        <input
-                          type={"checkbox"}
-                          id={"delete_source_branch"}
-                          name={"delete_source_branch"}
-                        />
-                        {" Delete source branch after merge"}
-                      </label>
-                    </Grid.Col>
+                    {canDeleteSourceBranch && (
+                      <Grid.Col fluid style={{ marginTop: 8 }}>
+                        <label
+                          htmlFor={"delete_source_branch"}
+                          style={{ display: "block" }}
+                        >
+                          <input
+                            type={"checkbox"}
+                            id={"delete_source_branch"}
+                            name={"delete_source_branch"}
+                          />
+                          {" Delete source branch after merge"}
+                        </label>
+                      </Grid.Col>
+                    )}
                   </Grid.Col>
                 </form>
               </Grid.Col>
             </Card>
           )}
+          {canDeleteSourceBranch &&
+            pr.state === PullRequestState.CLOSE_MERGED && (
+              <Card
+                style={{ width: "100%", padding: 8, marginTop: 16 }}
+                themeScheme={commonProps.themeScheme}
+              >
+                <Grid.Col fluid style={{ marginTop: 8 }}>
+                  <form
+                    style={{ width: "100%" }}
+                    method={"POST"}
+                    action={buildRouteLink(
+                      AppRoute.REPOSITORY_PULL_REQUEST_DELETE_SOURCE_BRANCH_ACTION,
+                      {
+                        orgSlug: parentOrg.slug,
+                        repoSlug: repo.slug,
+                        pullUid: pr.uid,
+                      },
+                    )}
+                  >
+                    <Grid.Row
+                      fluid
+                      nowrap
+                      justifyContent={"flex-end"}
+                      gap={8}
+                      alignItems={"center"}
+                      style={{ marginTop: 8 }}
+                    >
+                      <button type={"submit"}>Delete Source Branch</button>
+                    </Grid.Row>
+                  </form>
+                </Grid.Col>
+              </Card>
+            )}
           <Card
             style={{ width: "100%", padding: 8, marginTop: 8 }}
             themeScheme={commonProps.themeScheme}