diff --git a/apps/dokploy/__test__/gitlab-webhooks.test.ts b/apps/dokploy/__test__/gitlab-webhooks.test.ts new file mode 100644 index 0000000000..a7d258aa38 --- /dev/null +++ b/apps/dokploy/__test__/gitlab-webhooks.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findGitlabById: vi.fn(), + updateGitlab: vi.fn(), +})); + +vi.mock("@dokploy/server/services/gitlab", () => mocks); + +import { registerGitlabDeployWebhook } from "@dokploy/server/utils/providers/gitlab"; + +const deployWebhookUrl = "https://dokploy.example.com/api/deploy/refresh-token"; + +const createResponse = ( + body: unknown, + init: { status?: number; statusText?: string } = {}, +) => + new Response(JSON.stringify(body), { + status: init.status ?? 200, + statusText: init.statusText ?? "OK", + headers: { + "Content-Type": "application/json", + }, + }); + +const mockGitlabProvider = (overrides = {}) => ({ + gitlabId: "gitlab-id", + gitlabUrl: "https://gitlab.example.com/", + gitlabInternalUrl: null, + applicationId: "application-id", + redirectUri: "https://dokploy.example.com/api/providers/gitlab/callback", + secret: "secret", + accessToken: "access-token", + refreshToken: "refresh-token", + groupName: null, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + enableAutoDeploy: true, + gitProviderId: "git-provider-id", + ...overrides, +}); + +describe("registerGitlabDeployWebhook", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates a project hook when no hook exists for the deploy URL", async () => { + mocks.findGitlabById.mockResolvedValue(mockGitlabProvider()); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(createResponse([])) + .mockResolvedValueOnce(createResponse({ id: 1, url: deployWebhookUrl })); + + await registerGitlabDeployWebhook({ + gitlabId: "gitlab-id", + gitlabProjectId: 123, + branch: "main", + deployWebhookUrl, + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://gitlab.example.com/api/v4/projects/123/hooks?per_page=100", + { + headers: { + Authorization: "Bearer access-token", + "Content-Type": "application/json", + }, + }, + ); + + const createCall = fetchMock.mock.calls[1]; + expect(createCall?.[0]).toBe( + "https://gitlab.example.com/api/v4/projects/123/hooks", + ); + expect(createCall?.[1]).toMatchObject({ + method: "POST", + headers: { + Authorization: "Bearer access-token", + "Content-Type": "application/json", + }, + }); + expect(JSON.parse(createCall?.[1]?.body as string)).toEqual({ + url: deployWebhookUrl, + push_events: true, + enable_ssl_verification: true, + push_events_branch_filter: "main", + branch_filter_strategy: "wildcard", + }); + }); + + it("updates the existing project hook with the same deploy URL", async () => { + mocks.findGitlabById.mockResolvedValue(mockGitlabProvider()); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + createResponse([{ id: 456, url: deployWebhookUrl }]), + ) + .mockResolvedValueOnce( + createResponse({ id: 456, url: deployWebhookUrl }), + ); + + await registerGitlabDeployWebhook({ + gitlabId: "gitlab-id", + gitlabProjectId: 123, + branch: "production", + deployWebhookUrl, + }); + + const updateCall = fetchMock.mock.calls[1]; + expect(updateCall?.[0]).toBe( + "https://gitlab.example.com/api/v4/projects/123/hooks/456", + ); + expect(updateCall?.[1]).toMatchObject({ + method: "PUT", + }); + expect(JSON.parse(updateCall?.[1]?.body as string)).toMatchObject({ + url: deployWebhookUrl, + push_events_branch_filter: "production", + }); + }); + + it("uses the internal GitLab URL before the public URL", async () => { + mocks.findGitlabById.mockResolvedValue( + mockGitlabProvider({ + gitlabUrl: "https://gitlab.example.com", + gitlabInternalUrl: "http://gitlab:8080/", + }), + ); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(createResponse([])) + .mockResolvedValueOnce(createResponse({ id: 1, url: deployWebhookUrl })); + + await registerGitlabDeployWebhook({ + gitlabId: "gitlab-id", + gitlabProjectId: 123, + branch: "main", + deployWebhookUrl, + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe( + "http://gitlab:8080/api/v4/projects/123/hooks?per_page=100", + ); + expect(fetchMock.mock.calls[1]?.[0]).toBe( + "http://gitlab:8080/api/v4/projects/123/hooks", + ); + }); + + it("skips project hook registration when automatic deployments are disabled", async () => { + mocks.findGitlabById.mockResolvedValue( + mockGitlabProvider({ + enableAutoDeploy: false, + }), + ); + const fetchMock = vi.spyOn(globalThis, "fetch"); + + const result = await registerGitlabDeployWebhook({ + gitlabId: "gitlab-id", + gitlabProjectId: 123, + branch: "main", + deployWebhookUrl, + }); + + expect(result).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx index b48f8253b8..8635d5a18f 100644 --- a/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx @@ -26,6 +26,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; import { useUrl } from "@/utils/hooks/use-url"; @@ -51,6 +52,7 @@ const Schema = z.object({ message: "Redirect URI is required", }), groupName: z.string().optional(), + enableAutoDeploy: z.boolean().default(true), }); type Schema = z.infer; @@ -72,6 +74,7 @@ export const AddGitlabProvider = () => { name: "", gitlabUrl: "https://gitlab.com", gitlabInternalUrl: "", + enableAutoDeploy: true, }, resolver: zodResolver(Schema), }); @@ -87,6 +90,7 @@ export const AddGitlabProvider = () => { name: "", gitlabUrl: "https://gitlab.com", gitlabInternalUrl: "", + enableAutoDeploy: true, }); }, [form, isOpen]); @@ -100,6 +104,7 @@ export const AddGitlabProvider = () => { redirectUri: data.redirectUri || "", gitlabUrl: data.gitlabUrl || "https://gitlab.com", gitlabInternalUrl: data.gitlabInternalUrl || undefined, + enableAutoDeploy: data.enableAutoDeploy, }) .then(async () => { await utils.gitProvider.getAll.invalidate(); @@ -292,6 +297,29 @@ export const AddGitlabProvider = () => { )} /> + ( + +
+ Enable Automatic Deployments + + Automatically configure deploy webhooks when this + GitLab provider is used by an application or compose + service. + +
+ + + +
+ )} + /> + diff --git a/apps/dokploy/components/dashboard/settings/git/gitlab/edit-gitlab-provider.tsx b/apps/dokploy/components/dashboard/settings/git/gitlab/edit-gitlab-provider.tsx index 43c1740557..48571eb310 100644 --- a/apps/dokploy/components/dashboard/settings/git/gitlab/edit-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/settings/git/gitlab/edit-gitlab-provider.tsx @@ -25,6 +25,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { api } from "@/utils/api"; const Schema = z.object({ @@ -39,6 +40,7 @@ const Schema = z.object({ .optional() .transform((v) => (v === "" ? undefined : v)), groupName: z.string().optional(), + enableAutoDeploy: z.boolean().default(true), }); type Schema = z.infer; @@ -67,6 +69,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { name: "", gitlabUrl: "https://gitlab.com", gitlabInternalUrl: "", + enableAutoDeploy: true, }, resolver: zodResolver(Schema), }); @@ -79,6 +82,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { name: gitlab?.gitProvider.name || "", gitlabUrl: gitlab?.gitlabUrl || "", gitlabInternalUrl: gitlab?.gitlabInternalUrl || "", + enableAutoDeploy: gitlab?.enableAutoDeploy ?? true, }); }, [form, isOpen]); @@ -90,6 +94,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { name: data.name || "", gitlabUrl: data.gitlabUrl || "", gitlabInternalUrl: data.gitlabInternalUrl ?? null, + enableAutoDeploy: data.enableAutoDeploy, }) .then(async () => { await utils.gitProvider.getAll.invalidate(); @@ -201,6 +206,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => { )} /> + ( + +
+ Enable Automatic Deployments + + Automatically configure deploy webhooks when this + GitLab provider is used by an application or compose + service. + +
+ + + +
+ )} + /> +