fix(pulls): make it possible to merge / delete source branch@@ -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": {
@@ -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;
@@ -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;
@@ -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,
+ },
);
};
@@ -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"}
@@ -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}