.ts
TypeScript
(application/typescript)
// 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;