Skip to content

Commit c7f8490

Browse files
authored
feat: add feature toggle to disable mirror deletion (#487)
1 parent c2bacdb commit c7f8490

7 files changed

Lines changed: 132 additions & 19 deletions

File tree

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,6 @@ MIRROR_SYNC_TIMEOUT_MS=
4242

4343
# Used to configure the number of commits to push at a time when syncing a mirror (default is 100)
4444
MIRROR_PUSH_CHUNK_SIZE=
45+
46+
# Used to disable mirror deletion through private mirrors. Hides the delete action in the UI and rejects direct API calls.
47+
DISABLE_MIRROR_DELETION=

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ build
1010
!.env.example
1111
.DS_Store
1212
next-env.d.ts
13+
tsconfig.tsbuildinfo

env.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ export const env = createEnv({
9292
}
9393
return parsed
9494
}),
95+
DISABLE_MIRROR_DELETION: z
96+
.enum(['true', 'false', ''])
97+
.optional()
98+
.default('false')
99+
.transform((value) => value === 'true'),
95100
},
96101
/*
97102
* Environment variables available on the client (and server).
@@ -127,6 +132,7 @@ export const env = createEnv({
127132
process.env.DELETE_INTERNAL_MERGE_COMMITS_ON_SYNC,
128133
MIRROR_SYNC_TIMEOUT_MS: process.env.MIRROR_SYNC_TIMEOUT_MS,
129134
MIRROR_PUSH_CHUNK_SIZE: process.env.MIRROR_PUSH_CHUNK_SIZE,
135+
DISABLE_MIRROR_DELETION: process.env.DISABLE_MIRROR_DELETION,
130136
},
131137
skipValidation: process.env.SKIP_ENV_VALIDATIONS === 'true',
132138
})

src/app/[organizationId]/forks/[forkId]/page.tsx

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
RelativeTime,
2020
Stack,
2121
} from '@primer/react'
22-
import { Blankslate, DataTable, Table } from '@primer/react/drafts'
22+
import { Blankslate, DataTable, Table, Tooltip } from '@primer/react/drafts'
2323
import { ForkBreadcrumbs } from 'app/components/breadcrumbs/ForkBreadcrumbs'
2424
import { CreateMirrorDialog } from 'app/components/dialog/CreateMirrorDialog'
2525
import { DeleteMirrorDialog } from 'app/components/dialog/DeleteMirrorDialog'
@@ -185,7 +185,7 @@ const Fork = () => {
185185
} = trpc.createMirror.useMutation()
186186

187187
const {
188-
data: mirrors,
188+
data: mirrorsData,
189189
isLoading: mirrorsLoading,
190190
refetch: refetchMirrors,
191191
error: listMirrorsError,
@@ -198,6 +198,8 @@ const Fork = () => {
198198
enabled: Boolean(organizationId) && Boolean(forkData?.data?.name),
199199
},
200200
)
201+
const mirrors = mirrorsData?.mirrors
202+
const mirrorDeletionEnabled = mirrorsData?.mirrorDeletionEnabled ?? false
201203

202204
const {
203205
data: editMirrorData,
@@ -608,6 +610,23 @@ const Fork = () => {
608610
width: '50px',
609611
align: 'end',
610612
renderCell: (row) => {
613+
const deleteItem = (
614+
<ActionList.Item
615+
variant="danger"
616+
disabled={!mirrorDeletionEnabled}
617+
onSelect={() =>
618+
openDeleteDialog(row.name, mirrorPaginationSet.length)
619+
}
620+
>
621+
<Stack align="center" direction="horizontal">
622+
<Stack.Item>
623+
<Octicon icon={TrashIcon}></Octicon>
624+
</Stack.Item>
625+
<Stack.Item>Delete mirror</Stack.Item>
626+
</Stack>
627+
</ActionList.Item>
628+
)
629+
611630
return (
612631
<ActionMenu>
613632
<ActionMenu.Anchor>
@@ -631,22 +650,16 @@ const Fork = () => {
631650
<Stack.Item>Edit mirror</Stack.Item>
632651
</Stack>
633652
</ActionList.Item>
634-
<ActionList.Item
635-
variant="danger"
636-
onSelect={() => {
637-
openDeleteDialog(
638-
row.name,
639-
mirrorPaginationSet.length,
640-
)
641-
}}
642-
>
643-
<Stack align="center" direction="horizontal">
644-
<Stack.Item>
645-
<Octicon icon={TrashIcon}></Octicon>
646-
</Stack.Item>
647-
<Stack.Item>Delete mirror</Stack.Item>
648-
</Stack>
649-
</ActionList.Item>
653+
{mirrorDeletionEnabled ? (
654+
deleteItem
655+
) : (
656+
<Tooltip
657+
direction="s"
658+
text="Mirror deletion has been disabled in the application settings"
659+
>
660+
<Box as="span">{deleteItem}</Box>
661+
</Tooltip>
662+
)}
650663
</ActionList>
651664
</ActionMenu.Overlay>
652665
</ActionMenu>

src/server/repos/controller.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,10 @@ export const listMirrorsHandler = async ({
398398
(response) => response.data,
399399
)
400400

401-
return repos
401+
return {
402+
mirrors: repos,
403+
mirrorDeletionEnabled: process.env.DISABLE_MIRROR_DELETION !== 'true',
404+
}
402405
} catch (error) {
403406
reposApiLogger.info('Failed to fetch mirrors', { input, error })
404407

@@ -492,6 +495,13 @@ export const deleteMirrorHandler = async ({
492495
}: {
493496
input: DeleteMirrorSchema
494497
}) => {
498+
if (process.env.DISABLE_MIRROR_DELETION === 'true') {
499+
throw new TRPCError({
500+
code: 'FORBIDDEN',
501+
message: 'Mirror deletion is disabled',
502+
})
503+
}
504+
495505
try {
496506
reposApiLogger.info('Deleting mirror', { input })
497507

test/octomock/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,13 +308,18 @@ const mockFunctions = {
308308
createRef: vi.fn(),
309309
deleteRef: vi.fn(),
310310
},
311+
search: {
312+
repos: vi.fn(),
313+
},
311314
},
315+
paginate: vi.fn(),
312316
}
313317

314318
export class Octomock {
315319
mockFunctions: {
316320
rest: Record<string, Record<string, Mock>>
317321
config: Record<string, Mock>
322+
paginate: Mock
318323
}
319324
defaultContext: { payload: { issue: { body: string; user: object } } }
320325

test/server/repos.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,63 @@ describe('Repos router', () => {
669669
})
670670
})
671671

672+
describe('listMirror', () => {
673+
const fakeMirrorList = [{ name: 'mirror-1' }, { name: 'mirror-2' }]
674+
675+
const setupListMirrorsMocks = () => {
676+
vi.spyOn(config, 'getConfig').mockResolvedValue({
677+
publicOrg: 'github',
678+
privateOrg: 'github-test',
679+
})
680+
681+
om.mockFunctions.rest.apps.getOrgInstallation.mockResolvedValue(
682+
fakeOrgInstallation,
683+
)
684+
om.mockFunctions.rest.orgs.get.mockResolvedValue(fakeOrg)
685+
om.mockFunctions.paginate.mockResolvedValue(fakeMirrorList)
686+
}
687+
688+
it('returns mirrorDeletionEnabled: true when DISABLE_MIRROR_DELETION is unset', async () => {
689+
const caller = t.createCallerFactory(reposRouter)(createTestContext())
690+
setupListMirrorsMocks()
691+
692+
const res = await caller.listMirrors({
693+
orgId: 'test',
694+
forkName: 'fork-test',
695+
})
696+
697+
expect(res.mirrors).toEqual(fakeMirrorList)
698+
expect(res.mirrorDeletionEnabled).toBe(true)
699+
})
700+
701+
it('returns mirrorDeletionEnabled: false when DISABLE_MIRROR_DELETION is "true"', async () => {
702+
const caller = t.createCallerFactory(reposRouter)(createTestContext())
703+
setupListMirrorsMocks()
704+
vi.stubEnv('DISABLE_MIRROR_DELETION', 'true')
705+
706+
const res = await caller.listMirrors({
707+
orgId: 'test',
708+
forkName: 'fork-test',
709+
})
710+
711+
expect(res.mirrors).toEqual(fakeMirrorList)
712+
expect(res.mirrorDeletionEnabled).toBe(false)
713+
})
714+
715+
it('returns mirrorDeletionEnabled: true when DISABLE_MIRROR_DELETION is "false"', async () => {
716+
const caller = t.createCallerFactory(reposRouter)(createTestContext())
717+
setupListMirrorsMocks()
718+
vi.stubEnv('DISABLE_MIRROR_DELETION', 'false')
719+
720+
const res = await caller.listMirrors({
721+
orgId: 'test',
722+
forkName: 'fork-test',
723+
})
724+
725+
expect(res.mirrorDeletionEnabled).toBe(true)
726+
})
727+
})
728+
672729
describe('deleteMirror', () => {
673730
it('deletes the mirror and the sync branch ref on the public fork', async () => {
674731
const caller = t.createCallerFactory(reposRouter)(createTestContext())
@@ -704,6 +761,24 @@ describe('Repos router', () => {
704761
})
705762
expect(res.success).toBe(true)
706763
})
764+
765+
it('throws FORBIDDEN when DISABLE_MIRROR_DELETION is "true"', async () => {
766+
const caller = t.createCallerFactory(reposRouter)(createTestContext())
767+
vi.stubEnv('DISABLE_MIRROR_DELETION', 'true')
768+
769+
await expect(
770+
caller.deleteMirror({
771+
orgId: 'test',
772+
mirrorName: 'to-delete',
773+
}),
774+
).rejects.toMatchObject({
775+
code: 'FORBIDDEN',
776+
message: 'Mirror deletion is disabled',
777+
})
778+
779+
expect(om.mockFunctions.rest.repos.delete).not.toHaveBeenCalled()
780+
expect(om.mockFunctions.rest.git.deleteRef).not.toHaveBeenCalled()
781+
})
707782
})
708783

709784
describe('temp directory cleanup', () => {

0 commit comments

Comments
 (0)