mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
Merge d611b0ee9f into 853ca33659
This commit is contained in:
commit
d50380e93e
@ -0,0 +1,230 @@
|
||||
[
|
||||
{
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-github-event": "pull_request",
|
||||
"x-gitea-delivery": "00000000-0000-0000-0000-000000000000",
|
||||
"x-gitea-event": "pull_request",
|
||||
"x-gitea-event-type": "pull_request"
|
||||
},
|
||||
"body": {
|
||||
"action": "opened",
|
||||
"number": 1,
|
||||
"repository": {
|
||||
"name": "web",
|
||||
"full_name": "repoowner/web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
},
|
||||
"pull_request": {
|
||||
"id": 1,
|
||||
"number": 1,
|
||||
"title": "Add a thing",
|
||||
"html_url": "http://localhost:3939/repoowner/web/pulls/1",
|
||||
"merged": false,
|
||||
"user": {
|
||||
"login": "writer"
|
||||
},
|
||||
"labels": [],
|
||||
"base": {
|
||||
"ref": "main"
|
||||
},
|
||||
"head": {
|
||||
"ref": "feature/thing",
|
||||
"sha": "d2253306c8cccbc2b76bbaea89092d23db7778f3",
|
||||
"repo": {
|
||||
"name": "web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-github-event": "pull_request",
|
||||
"x-gitea-delivery": "00000000-0000-0000-0000-000000000001",
|
||||
"x-gitea-event": "pull_request",
|
||||
"x-gitea-event-type": "pull_request_sync"
|
||||
},
|
||||
"body": {
|
||||
"action": "synchronized",
|
||||
"number": 1,
|
||||
"repository": {
|
||||
"name": "web",
|
||||
"full_name": "repoowner/web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
},
|
||||
"pull_request": {
|
||||
"id": 1,
|
||||
"number": 1,
|
||||
"title": "Add a thing",
|
||||
"html_url": "http://localhost:3939/repoowner/web/pulls/1",
|
||||
"merged": false,
|
||||
"user": {
|
||||
"login": "writer"
|
||||
},
|
||||
"labels": [],
|
||||
"base": {
|
||||
"ref": "main"
|
||||
},
|
||||
"head": {
|
||||
"ref": "feature/thing",
|
||||
"sha": "03ed76d4fc6b09b7e793dc009f918f47aeca45c1",
|
||||
"repo": {
|
||||
"name": "web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-github-event": "pull_request",
|
||||
"x-gitea-delivery": "00000000-0000-0000-0000-000000000002",
|
||||
"x-gitea-event": "pull_request",
|
||||
"x-gitea-event-type": "pull_request_label"
|
||||
},
|
||||
"body": {
|
||||
"action": "label_updated",
|
||||
"number": 1,
|
||||
"repository": {
|
||||
"name": "web",
|
||||
"full_name": "repoowner/web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
},
|
||||
"pull_request": {
|
||||
"id": 1,
|
||||
"number": 1,
|
||||
"title": "Add a thing",
|
||||
"html_url": "http://localhost:3939/repoowner/web/pulls/1",
|
||||
"merged": false,
|
||||
"user": {
|
||||
"login": "writer"
|
||||
},
|
||||
"labels": [
|
||||
{
|
||||
"name": "preview"
|
||||
}
|
||||
],
|
||||
"base": {
|
||||
"ref": "main"
|
||||
},
|
||||
"head": {
|
||||
"ref": "feature/thing",
|
||||
"sha": "03ed76d4fc6b09b7e793dc009f918f47aeca45c1",
|
||||
"repo": {
|
||||
"name": "web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-github-event": "pull_request",
|
||||
"x-gitea-delivery": "00000000-0000-0000-0000-000000000003",
|
||||
"x-gitea-event": "pull_request",
|
||||
"x-gitea-event-type": "pull_request_label"
|
||||
},
|
||||
"body": {
|
||||
"action": "label_cleared",
|
||||
"number": 1,
|
||||
"repository": {
|
||||
"name": "web",
|
||||
"full_name": "repoowner/web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
},
|
||||
"pull_request": {
|
||||
"id": 1,
|
||||
"number": 1,
|
||||
"title": "Add a thing",
|
||||
"html_url": "http://localhost:3939/repoowner/web/pulls/1",
|
||||
"merged": false,
|
||||
"user": {
|
||||
"login": "writer"
|
||||
},
|
||||
"labels": [
|
||||
{
|
||||
"name": "preview"
|
||||
}
|
||||
],
|
||||
"base": {
|
||||
"ref": "main"
|
||||
},
|
||||
"head": {
|
||||
"ref": "feature/thing",
|
||||
"sha": "03ed76d4fc6b09b7e793dc009f918f47aeca45c1",
|
||||
"repo": {
|
||||
"name": "web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"headers": {
|
||||
"content-type": "application/json",
|
||||
"x-github-event": "issue_comment",
|
||||
"x-gitea-delivery": "00000000-0000-0000-0000-000000000004",
|
||||
"x-gitea-event": "issue_comment",
|
||||
"x-gitea-event-type": "pull_request_comment"
|
||||
},
|
||||
"body": {
|
||||
"action": "created",
|
||||
"number": null,
|
||||
"repository": {
|
||||
"name": "web",
|
||||
"full_name": "repoowner/web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
},
|
||||
"pull_request": {
|
||||
"id": 1,
|
||||
"number": 1,
|
||||
"title": "Add a thing",
|
||||
"html_url": "http://localhost:3939/repoowner/web/pulls/1",
|
||||
"merged": false,
|
||||
"user": {
|
||||
"login": "writer"
|
||||
},
|
||||
"labels": [],
|
||||
"base": {
|
||||
"ref": "main"
|
||||
},
|
||||
"head": {
|
||||
"ref": "feature/thing",
|
||||
"sha": "03ed76d4fc6b09b7e793dc009f918f47aeca45c1",
|
||||
"repo": {
|
||||
"name": "web",
|
||||
"owner": {
|
||||
"login": "repoowner"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
214
apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts
Normal file
214
apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* These fixtures are real webhook deliveries captured from a Gitea 1.24.3
|
||||
* instance: a pull request was opened by a write collaborator, a second commit
|
||||
* was pushed to it, a label was added and then all labels were cleared. The
|
||||
* fifth delivery is a comment on that same pull request, which Gitea sends as
|
||||
* `X-Gitea-Event: issue_comment` even though its event *type* starts with
|
||||
* `pull_request` - Dokploy posts preview status comments itself, so those
|
||||
* deliveries must never be mistaken for pull request events.
|
||||
*/
|
||||
const deliveries: {
|
||||
headers: Record<string, string>;
|
||||
body: any;
|
||||
}[] = JSON.parse(
|
||||
readFileSync(
|
||||
path.resolve(__dirname, "fixtures/gitea-pull-request-deliveries.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createPreviewDeployment: vi.fn(),
|
||||
createPreviewSecurityBlockedComment: vi.fn(),
|
||||
checkPreviewAuthorPermissions: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId: vi.fn(),
|
||||
findPreviewDeploymentsByPullRequestId: vi.fn(),
|
||||
removePreviewDeployment: vi.fn(),
|
||||
queueAdd: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
checkPreviewAuthorPermissions: mocks.checkPreviewAuthorPermissions,
|
||||
createPreviewDeployment: mocks.createPreviewDeployment,
|
||||
createPreviewSecurityBlockedComment:
|
||||
mocks.createPreviewSecurityBlockedComment,
|
||||
findPreviewDeploymentByApplicationId:
|
||||
mocks.findPreviewDeploymentByApplicationId,
|
||||
findPreviewDeploymentsByPullRequestId:
|
||||
mocks.findPreviewDeploymentsByPullRequestId,
|
||||
getPreviewCommentContext: (application: any) =>
|
||||
application.giteaId && application.giteaOwner && application.giteaRepository
|
||||
? {
|
||||
provider: "gitea",
|
||||
providerId: application.giteaId,
|
||||
owner: application.giteaOwner,
|
||||
repository: application.giteaRepository,
|
||||
}
|
||||
: null,
|
||||
removePreviewDeployment: mocks.removePreviewDeployment,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
myQueue: { add: mocks.queueAdd },
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/deploy", () => ({ deploy: vi.fn() }));
|
||||
|
||||
const { handleGiteaPullRequestEvent } = await import(
|
||||
"@/server/utils/gitea-preview"
|
||||
);
|
||||
const { isGiteaPullRequestEvent } = await import(
|
||||
"@/pages/api/deploy/[refreshToken]"
|
||||
);
|
||||
|
||||
const byAction = (action: string) =>
|
||||
deliveries.find(
|
||||
(delivery) =>
|
||||
delivery.body.action === action &&
|
||||
delivery.headers["x-gitea-event"] === "pull_request",
|
||||
) as { headers: Record<string, string>; body: any };
|
||||
|
||||
const application = {
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
sourceType: "gitea",
|
||||
serverId: null,
|
||||
giteaId: "gitea-1",
|
||||
giteaOwner: "repoowner",
|
||||
giteaRepository: "web",
|
||||
giteaBranch: "main",
|
||||
isPreviewDeploymentsActive: true,
|
||||
previewLabels: null,
|
||||
previewLimit: 3,
|
||||
previewRequireCollaboratorPermissions: true,
|
||||
previewDeployments: [],
|
||||
} as any;
|
||||
|
||||
describe("gitea webhook routing with real deliveries", () => {
|
||||
it("routes every pull request sub event, since Gitea folds them all into one event name", () => {
|
||||
const pullRequestDeliveries = deliveries.filter(
|
||||
(delivery) => delivery.headers["x-gitea-event"] === "pull_request",
|
||||
);
|
||||
|
||||
expect(
|
||||
pullRequestDeliveries.map((d) => d.headers["x-gitea-event-type"]),
|
||||
).toEqual([
|
||||
"pull_request",
|
||||
"pull_request_sync",
|
||||
"pull_request_label",
|
||||
"pull_request_label",
|
||||
]);
|
||||
|
||||
for (const delivery of pullRequestDeliveries) {
|
||||
expect(isGiteaPullRequestEvent(delivery.headers)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not route a comment delivery as a pull request event", () => {
|
||||
const comment = deliveries.find(
|
||||
(delivery) => delivery.headers["x-gitea-event"] === "issue_comment",
|
||||
);
|
||||
|
||||
expect(comment?.headers["x-gitea-event-type"]).toBe("pull_request_comment");
|
||||
expect(isGiteaPullRequestEvent(comment?.headers)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores the GitHub compatibility header Gitea also sends", () => {
|
||||
expect(deliveries[0]?.headers["x-github-event"]).toBe("pull_request");
|
||||
expect(isGiteaPullRequestEvent({ "x-github-event": "pull_request" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gitea preview deployments with real deliveries", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkPreviewAuthorPermissions.mockResolvedValue({
|
||||
hasWriteAccess: true,
|
||||
permission: "write",
|
||||
verified: true,
|
||||
});
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
|
||||
mocks.createPreviewDeployment.mockResolvedValue({
|
||||
previewDeploymentId: "preview-1",
|
||||
});
|
||||
mocks.findPreviewDeploymentsByPullRequestId.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("creates a preview from the 'opened' delivery", async () => {
|
||||
const { body } = byAction("opened");
|
||||
|
||||
const result = await handleGiteaPullRequestEvent({ application, body });
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith({
|
||||
applicationId: "app-1",
|
||||
branch: "feature/thing",
|
||||
pullRequestId: `${body.pull_request.id}`,
|
||||
pullRequestNumber: `${body.pull_request.number}`,
|
||||
pullRequestTitle: "Add a thing",
|
||||
pullRequestURL: body.pull_request.html_url,
|
||||
});
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationType: "application-preview",
|
||||
previewDeploymentId: "preview-1",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("redeploys the existing preview from the 'synchronized' delivery", async () => {
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
|
||||
previewDeploymentId: "preview-existing",
|
||||
});
|
||||
const { body } = byAction("synchronized");
|
||||
|
||||
await handleGiteaPullRequestEvent({ application, body });
|
||||
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
previewDeploymentId: "preview-existing",
|
||||
descriptionLog: `Hash: ${body.pull_request.head.sha}`,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("deploys on 'label_updated' but never creates a preview on 'label_cleared'", async () => {
|
||||
await handleGiteaPullRequestEvent({
|
||||
application,
|
||||
body: byAction("label_updated").body,
|
||||
});
|
||||
expect(mocks.createPreviewDeployment).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.clearAllMocks();
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
|
||||
|
||||
await handleGiteaPullRequestEvent({
|
||||
application,
|
||||
body: byAction("label_cleared").body,
|
||||
});
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a real delivery aimed at a different repository", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: { ...application, giteaRepository: "other" },
|
||||
body: byAction("opened").body,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
366
apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts
Normal file
366
apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts
Normal file
@ -0,0 +1,366 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createPreviewDeployment: vi.fn(),
|
||||
createPreviewSecurityBlockedComment: vi.fn(),
|
||||
checkPreviewAuthorPermissions: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId: vi.fn(),
|
||||
findPreviewDeploymentsByPullRequestId: vi.fn(),
|
||||
removePreviewDeployment: vi.fn(),
|
||||
queueAdd: vi.fn(),
|
||||
deploy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
checkPreviewAuthorPermissions: mocks.checkPreviewAuthorPermissions,
|
||||
createPreviewDeployment: mocks.createPreviewDeployment,
|
||||
createPreviewSecurityBlockedComment:
|
||||
mocks.createPreviewSecurityBlockedComment,
|
||||
findPreviewDeploymentByApplicationId:
|
||||
mocks.findPreviewDeploymentByApplicationId,
|
||||
findPreviewDeploymentsByPullRequestId:
|
||||
mocks.findPreviewDeploymentsByPullRequestId,
|
||||
getPreviewCommentContext: (application: any) =>
|
||||
application.giteaId && application.giteaOwner && application.giteaRepository
|
||||
? {
|
||||
provider: "gitea",
|
||||
providerId: application.giteaId,
|
||||
owner: application.giteaOwner,
|
||||
repository: application.giteaRepository,
|
||||
}
|
||||
: null,
|
||||
removePreviewDeployment: mocks.removePreviewDeployment,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
myQueue: { add: mocks.queueAdd },
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/deploy", () => ({
|
||||
deploy: mocks.deploy,
|
||||
}));
|
||||
|
||||
import { handleGiteaPullRequestEvent } from "@/server/utils/gitea-preview";
|
||||
|
||||
const createApplication = (overrides: Record<string, unknown> = {}) => ({
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
sourceType: "gitea",
|
||||
serverId: null,
|
||||
giteaId: "gitea-1",
|
||||
giteaOwner: "acme",
|
||||
giteaRepository: "web",
|
||||
giteaBranch: "main",
|
||||
isPreviewDeploymentsActive: true,
|
||||
previewLabels: null,
|
||||
previewLimit: 3,
|
||||
previewRequireCollaboratorPermissions: true,
|
||||
previewDeployments: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createBody = ({
|
||||
pull_request: pullRequestOverrides,
|
||||
...overrides
|
||||
}: Record<string, any> = {}) => ({
|
||||
action: "opened",
|
||||
repository: {
|
||||
name: "web",
|
||||
owner: { login: "acme" },
|
||||
},
|
||||
...overrides,
|
||||
pull_request: {
|
||||
id: 42,
|
||||
number: 7,
|
||||
title: "Add a thing",
|
||||
html_url: "https://gitea.example.com/acme/web/pulls/7",
|
||||
user: { login: "contributor" },
|
||||
labels: [],
|
||||
base: { ref: "main" },
|
||||
head: {
|
||||
ref: "feature/thing",
|
||||
sha: "deadbeef",
|
||||
repo: { name: "web", owner: { login: "acme" } },
|
||||
},
|
||||
...(pullRequestOverrides ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
describe("handleGiteaPullRequestEvent", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkPreviewAuthorPermissions.mockResolvedValue({
|
||||
hasWriteAccess: true,
|
||||
permission: "write",
|
||||
verified: true,
|
||||
});
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
|
||||
mocks.createPreviewDeployment.mockResolvedValue({
|
||||
previewDeploymentId: "preview-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a preview deployment and queues a job when a pull request is opened", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody(),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith({
|
||||
applicationId: "app-1",
|
||||
branch: "feature/thing",
|
||||
pullRequestId: "42",
|
||||
pullRequestNumber: "7",
|
||||
pullRequestTitle: "Add a thing",
|
||||
pullRequestURL: "https://gitea.example.com/acme/web/pulls/7",
|
||||
});
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "app-1",
|
||||
applicationType: "application-preview",
|
||||
previewDeploymentId: "preview-1",
|
||||
type: "deploy",
|
||||
descriptionLog: "Hash: deadbeef",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("redeploys the existing preview on 'synchronized' without creating a second one", async () => {
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
|
||||
previewDeploymentId: "preview-existing",
|
||||
});
|
||||
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody({ action: "synchronized" }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({ previewDeploymentId: "preview-existing" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes only the previews of this application when the pull request is closed", async () => {
|
||||
mocks.findPreviewDeploymentsByPullRequestId.mockResolvedValue([
|
||||
{ previewDeploymentId: "preview-mine", applicationId: "app-1" },
|
||||
{ previewDeploymentId: "preview-other", applicationId: "app-2" },
|
||||
]);
|
||||
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody({ action: "closed" }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.removePreviewDeployment).toHaveBeenCalledExactlyOnceWith(
|
||||
"preview-mine",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a pull request that does not target the configured branch", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody({ pull_request: { base: { ref: "develop" } } }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a payload from a different repository than the application is configured for", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody({
|
||||
repository: { name: "other", owner: { login: "acme" } },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips pull requests opened from a fork", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody({
|
||||
pull_request: {
|
||||
head: {
|
||||
ref: "feature/thing",
|
||||
sha: "deadbeef",
|
||||
repo: { name: "web", owner: { login: "someone-else" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks an author without write access and reports it on the pull request", async () => {
|
||||
mocks.checkPreviewAuthorPermissions.mockResolvedValue({
|
||||
hasWriteAccess: false,
|
||||
permission: "read",
|
||||
verified: true,
|
||||
});
|
||||
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody(),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.createPreviewSecurityBlockedComment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "gitea" }),
|
||||
{ prNumber: 7, prAuthor: "contributor", permission: "read" },
|
||||
);
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips without blaming the author when Gitea refuses the permission lookup", async () => {
|
||||
mocks.checkPreviewAuthorPermissions.mockResolvedValue({
|
||||
hasWriteAccess: false,
|
||||
permission: null,
|
||||
verified: false,
|
||||
});
|
||||
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody(),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.createPreviewSecurityBlockedComment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("authorizes the repository owner without calling the permission endpoint", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody({ pull_request: { user: { login: "Acme" } } }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.checkPreviewAuthorPermissions).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the permission lookup when the security check is disabled", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication({
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
}) as any,
|
||||
body: createBody(),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.checkPreviewAuthorPermissions).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the preview limit to new previews but still redeploys existing ones", async () => {
|
||||
const application = createApplication({
|
||||
previewLimit: 1,
|
||||
previewDeployments: [{ previewDeploymentId: "preview-existing" }],
|
||||
}) as any;
|
||||
|
||||
const blocked = await handleGiteaPullRequestEvent({
|
||||
application,
|
||||
body: createBody(),
|
||||
});
|
||||
|
||||
expect(blocked.message).toContain("limit");
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
|
||||
previewDeploymentId: "preview-existing",
|
||||
});
|
||||
|
||||
const redeployed = await handleGiteaPullRequestEvent({
|
||||
application,
|
||||
body: createBody({ action: "synchronized" }),
|
||||
});
|
||||
|
||||
expect(redeployed.status).toBe(200);
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({ previewDeploymentId: "preview-existing" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never creates a preview on 'label_cleared'", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody({ action: "label_cleared" }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only deploys labelled pull requests when preview labels are configured", async () => {
|
||||
const application = createApplication({
|
||||
previewLabels: ["preview"],
|
||||
}) as any;
|
||||
|
||||
const withoutLabel = await handleGiteaPullRequestEvent({
|
||||
application,
|
||||
body: createBody({ pull_request: { labels: [{ name: "bug" }] } }),
|
||||
});
|
||||
|
||||
expect(withoutLabel.status).toBe(200);
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
|
||||
const withLabel = await handleGiteaPullRequestEvent({
|
||||
application,
|
||||
body: createBody({ pull_request: { labels: [{ name: "preview" }] } }),
|
||||
});
|
||||
|
||||
expect(withLabel.status).toBe(200);
|
||||
expect(mocks.queueAdd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does nothing when preview deployments are disabled for the application", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication({
|
||||
isPreviewDeploymentsActive: false,
|
||||
}) as any,
|
||||
body: createBody(),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a payload without a pull request id", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody({ pull_request: { id: undefined } }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects a payload without a pull request author", async () => {
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application: createApplication() as any,
|
||||
body: createBody({ pull_request: { user: {} } }),
|
||||
});
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
288
apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts
Normal file
288
apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts
Normal file
@ -0,0 +1,288 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Only the HTTP surface of the Gitea helpers is under test here, so provider
|
||||
// lookup and token refresh are stubbed out.
|
||||
const mockFindGiteaById = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@dokploy/server/services/gitea", () => ({
|
||||
findGiteaById: mockFindGiteaById,
|
||||
updateGitea: vi.fn(),
|
||||
}));
|
||||
|
||||
const {
|
||||
checkGiteaUserRepositoryPermissions,
|
||||
cloneGiteaRepository,
|
||||
createGiteaIssueComment,
|
||||
giteaIssueCommentExists,
|
||||
updateGiteaIssueComment,
|
||||
} = await import("@dokploy/server/utils/providers/gitea");
|
||||
|
||||
const { getPreviewCommentContext } = await import(
|
||||
"@dokploy/server/services/preview-comment"
|
||||
);
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: `status ${status}`,
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
}) as unknown as Response;
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockFindGiteaById.mockResolvedValue({
|
||||
giteaId: "gitea-1",
|
||||
giteaUrl: "https://gitea.example.com",
|
||||
giteaInternalUrl: null,
|
||||
accessToken: "gitea-token",
|
||||
refreshToken: null,
|
||||
clientId: null,
|
||||
clientSecret: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPreviewCommentContext", () => {
|
||||
it("maps a github application to the github provider", () => {
|
||||
expect(
|
||||
getPreviewCommentContext({
|
||||
sourceType: "github",
|
||||
githubId: "gh-1",
|
||||
owner: "acme",
|
||||
repository: "web",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "github",
|
||||
providerId: "gh-1",
|
||||
owner: "acme",
|
||||
repository: "web",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a gitea application to the gitea provider", () => {
|
||||
expect(
|
||||
getPreviewCommentContext({
|
||||
sourceType: "gitea",
|
||||
giteaId: "gitea-1",
|
||||
giteaOwner: "acme",
|
||||
giteaRepository: "web",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "gitea",
|
||||
providerId: "gitea-1",
|
||||
owner: "acme",
|
||||
repository: "web",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for source types that cannot host preview deployments", () => {
|
||||
expect(getPreviewCommentContext({ sourceType: "docker" })).toBeNull();
|
||||
expect(
|
||||
getPreviewCommentContext({
|
||||
sourceType: "git",
|
||||
customGitUrl: "https://gitea.example.com/acme/web.git",
|
||||
} as never),
|
||||
).toBeNull();
|
||||
// A gitea application that is not fully configured yet.
|
||||
expect(
|
||||
getPreviewCommentContext({ sourceType: "gitea", giteaId: "gitea-1" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("gitea issue comment helpers", () => {
|
||||
it("creates a comment on the pull request index and returns its id", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ id: 987 }));
|
||||
|
||||
const comment = await createGiteaIssueComment({
|
||||
giteaId: "gitea-1",
|
||||
owner: "acme",
|
||||
repository: "web",
|
||||
index: 7,
|
||||
body: "hello",
|
||||
});
|
||||
|
||||
expect(comment.id).toBe(987);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ method: string; headers: Record<string, string>; body: string },
|
||||
];
|
||||
expect(url).toBe(
|
||||
"https://gitea.example.com/api/v1/repos/acme/web/issues/7/comments",
|
||||
);
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers.Authorization).toBe("token gitea-token");
|
||||
expect(JSON.parse(init.body)).toEqual({ body: "hello" });
|
||||
});
|
||||
|
||||
it("updates a comment by its own id, not by the pull request index", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ id: 987 }));
|
||||
|
||||
await updateGiteaIssueComment({
|
||||
giteaId: "gitea-1",
|
||||
owner: "acme",
|
||||
repository: "web",
|
||||
commentId: 987,
|
||||
body: "updated",
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] as [
|
||||
string,
|
||||
{ method: string; headers: Record<string, string>; body: string },
|
||||
];
|
||||
expect(url).toBe(
|
||||
"https://gitea.example.com/api/v1/repos/acme/web/issues/comments/987",
|
||||
);
|
||||
expect(init.method).toBe("PATCH");
|
||||
});
|
||||
|
||||
it("prefers the internal url when one is configured", async () => {
|
||||
mockFindGiteaById.mockResolvedValue({
|
||||
giteaUrl: "https://gitea.example.com",
|
||||
giteaInternalUrl: "http://gitea:3000/",
|
||||
accessToken: "gitea-token",
|
||||
});
|
||||
fetchMock.mockResolvedValue(jsonResponse({ id: 1 }));
|
||||
|
||||
await createGiteaIssueComment({
|
||||
giteaId: "gitea-1",
|
||||
owner: "acme",
|
||||
repository: "web",
|
||||
index: 7,
|
||||
body: "hello",
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"http://gitea:3000/api/v1/repos/acme/web/issues/7/comments",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a deleted comment as missing", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "not found" }, 404));
|
||||
|
||||
await expect(
|
||||
giteaIssueCommentExists({
|
||||
giteaId: "gitea-1",
|
||||
owner: "acme",
|
||||
repository: "web",
|
||||
commentId: 987,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkGiteaUserRepositoryPermissions", () => {
|
||||
it.each([
|
||||
["write", true],
|
||||
["admin", true],
|
||||
["owner", true],
|
||||
["read", false],
|
||||
["none", false],
|
||||
])("treats '%s' as write access: %s", async (permission, hasWriteAccess) => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ permission }));
|
||||
|
||||
await expect(
|
||||
checkGiteaUserRepositoryPermissions(
|
||||
"gitea-1",
|
||||
"acme",
|
||||
"web",
|
||||
"contributor",
|
||||
),
|
||||
).resolves.toEqual({ hasWriteAccess, permission, verified: true });
|
||||
});
|
||||
|
||||
// On a public repository Gitea reports a non-collaborator as `read` rather
|
||||
// than 404, which the `read` case above already covers.
|
||||
it("treats a 404 as a verified 'no permission'", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "not found" }, 404));
|
||||
|
||||
await expect(
|
||||
checkGiteaUserRepositoryPermissions(
|
||||
"gitea-1",
|
||||
"acme",
|
||||
"web",
|
||||
"contributor",
|
||||
),
|
||||
).resolves.toEqual({
|
||||
hasWriteAccess: false,
|
||||
permission: null,
|
||||
verified: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a 403 as unverified, since Dokploy itself lacks access", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ message: "forbidden" }, 403));
|
||||
|
||||
await expect(
|
||||
checkGiteaUserRepositoryPermissions(
|
||||
"gitea-1",
|
||||
"acme",
|
||||
"web",
|
||||
"contributor",
|
||||
),
|
||||
).resolves.toEqual({
|
||||
hasWriteAccess: false,
|
||||
permission: null,
|
||||
verified: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloneGiteaRepository", () => {
|
||||
it("fails with a clear message when the provider is not authorized", async () => {
|
||||
mockFindGiteaById.mockResolvedValue({
|
||||
giteaUrl: "https://gitea.example.com",
|
||||
giteaInternalUrl: null,
|
||||
accessToken: null,
|
||||
});
|
||||
|
||||
const command = await cloneGiteaRepository({
|
||||
appName: "preview-app",
|
||||
giteaBranch: "feature",
|
||||
giteaId: "gitea-1",
|
||||
giteaOwner: "acme",
|
||||
giteaRepository: "web",
|
||||
enableSubmodules: false,
|
||||
serverId: null,
|
||||
});
|
||||
|
||||
expect(command).toContain("not authorized");
|
||||
expect(command).not.toContain("git clone");
|
||||
});
|
||||
|
||||
it("fails instead of cloning a repository with a missing owner", async () => {
|
||||
const command = await cloneGiteaRepository({
|
||||
appName: "preview-app",
|
||||
giteaBranch: "feature",
|
||||
giteaId: "gitea-1",
|
||||
giteaOwner: null,
|
||||
giteaRepository: "web",
|
||||
enableSubmodules: false,
|
||||
serverId: null,
|
||||
});
|
||||
|
||||
expect(command).toContain("Owner not specified");
|
||||
expect(command).not.toContain("git clone");
|
||||
});
|
||||
|
||||
it("clones the preview branch of the configured repository", async () => {
|
||||
const command = await cloneGiteaRepository({
|
||||
appName: "preview-app",
|
||||
giteaBranch: "feature/thing",
|
||||
giteaId: "gitea-1",
|
||||
giteaOwner: "acme",
|
||||
giteaRepository: "web",
|
||||
enableSubmodules: false,
|
||||
serverId: null,
|
||||
});
|
||||
|
||||
expect(command).toContain("--branch feature/thing");
|
||||
expect(command).toContain(
|
||||
"oauth2\\:gitea-token\\@gitea.example.com/acme/web.git",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -10,7 +10,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
import { toast } from "sonner";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { GiteaIcon, GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
@ -42,6 +43,7 @@ interface Props {
|
||||
|
||||
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
const { data } = api.application.one.useQuery({ applicationId });
|
||||
const isGitea = data?.sourceType === "gitea";
|
||||
|
||||
const { mutateAsync: deletePreviewDeployment, isPending } =
|
||||
api.previewDeployment.delete.useMutation();
|
||||
@ -95,6 +97,19 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
each pull request you create.
|
||||
</span>
|
||||
</div>
|
||||
{isGitea && (
|
||||
<AlertBlock type="info">
|
||||
<strong>Gitea / Forgejo:</strong> preview deployments are driven
|
||||
by the webhook you added for this application (its URL is shown
|
||||
in the Deployments tab). In the repository webhook settings,
|
||||
choose <strong>Custom Events</strong> and enable{" "}
|
||||
<strong>Pull Request</strong> and{" "}
|
||||
<strong>Pull Request Synchronized</strong> - without the latter,
|
||||
previews are created but never updated when new commits are
|
||||
pushed. Enable <strong>Pull Request Label</strong> as well if
|
||||
you use the preview labels filter.
|
||||
</AlertBlock>
|
||||
)}
|
||||
{isLoadingPreviewDeployments ? (
|
||||
<div className="flex w-full flex-row items-center justify-center gap-3 min-h-[35vh]">
|
||||
<Loader2 className="size-5 text-muted-foreground animate-spin" />
|
||||
@ -173,7 +188,11 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
window.open(deployment.pullRequestURL, "_blank")
|
||||
}
|
||||
>
|
||||
<GithubIcon className="size-4" />
|
||||
{isGitea ? (
|
||||
<GiteaIcon className="size-4" />
|
||||
) : (
|
||||
<GithubIcon className="size-4" />
|
||||
)}
|
||||
Pull Request
|
||||
</Button>
|
||||
<ShowModalLogs
|
||||
|
||||
@ -11,6 +11,7 @@ import { applications } from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { deploy } from "@/server/utils/deploy";
|
||||
import { handleGiteaPullRequestEvent } from "@/server/utils/gitea-preview";
|
||||
|
||||
/**
|
||||
* Log a webhook handler error server-side without leaking its shape to the HTTP
|
||||
@ -51,6 +52,7 @@ export default async function handler(
|
||||
},
|
||||
},
|
||||
bitbucket: true,
|
||||
previewDeployments: true,
|
||||
},
|
||||
});
|
||||
|
||||
@ -58,6 +60,27 @@ export default async function handler(
|
||||
res.status(404).json({ message: "Application Not Found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Preview deployments are driven by pull request events and are a separate
|
||||
// feature from push auto deployments, so they are handled before the
|
||||
// `autoDeploy` gate below.
|
||||
if (isGiteaPullRequestEvent(req.headers)) {
|
||||
if (application.sourceType !== "gitea") {
|
||||
res.status(400).json({
|
||||
message:
|
||||
"Preview deployments require the Gitea source type, a custom Git URL cannot be used",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await handleGiteaPullRequestEvent({
|
||||
application,
|
||||
body: req.body,
|
||||
});
|
||||
res.status(result.status).json({ message: result.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!application?.autoDeploy) {
|
||||
res.status(400).json({
|
||||
message: "Automatic deployments are disabled for this application",
|
||||
@ -549,6 +572,41 @@ export const extractBranchName = (headers: any, body: any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect whether a Gitea/Forgejo webhook delivery is a pull request event.
|
||||
*
|
||||
* Gitea folds every pull request sub event into `X-Gitea-Event: pull_request`
|
||||
* and keeps the specific one in `X-Gitea-Event-Type` (`pull_request_sync`,
|
||||
* `pull_request_label`, ...), so the generic header alone covers every pull
|
||||
* request sub event. Forgejo mirrors both into `X-Forgejo-*`. Both also populate
|
||||
* the GitHub compatibility headers, which are deliberately ignored here so that
|
||||
* GitHub deliveries keep taking the existing code path.
|
||||
*
|
||||
* @link https://github.com/go-gitea/gitea/blob/main/services/webhook/deliver.go
|
||||
*/
|
||||
const GITEA_PULL_REQUEST_EVENT_TYPES = [
|
||||
"pull_request",
|
||||
"pull_request_sync",
|
||||
"pull_request_label",
|
||||
"pull_request_assign",
|
||||
"pull_request_milestone",
|
||||
"pull_request_review_request",
|
||||
];
|
||||
|
||||
export const isGiteaPullRequestEvent = (headers: any): boolean => {
|
||||
const event = headers["x-gitea-event"] ?? headers["x-forgejo-event"];
|
||||
|
||||
if (event) {
|
||||
return event === "pull_request";
|
||||
}
|
||||
|
||||
// Only reached when the generic header was stripped on the way in.
|
||||
const eventType =
|
||||
headers["x-gitea-event-type"] ?? headers["x-forgejo-event-type"];
|
||||
|
||||
return GITEA_PULL_REQUEST_EVENT_TYPES.includes(eventType);
|
||||
};
|
||||
|
||||
export const getProviderByHeader = (headers: any) => {
|
||||
if (headers["x-github-event"]) {
|
||||
return "github";
|
||||
|
||||
340
apps/dokploy/server/utils/gitea-preview.ts
Normal file
340
apps/dokploy/server/utils/gitea-preview.ts
Normal file
@ -0,0 +1,340 @@
|
||||
import {
|
||||
checkPreviewAuthorPermissions,
|
||||
createPreviewDeployment,
|
||||
createPreviewSecurityBlockedComment,
|
||||
findPreviewDeploymentByApplicationId,
|
||||
findPreviewDeploymentsByPullRequestId,
|
||||
getPreviewCommentContext,
|
||||
IS_CLOUD,
|
||||
removePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { deploy } from "@/server/utils/deploy";
|
||||
|
||||
/**
|
||||
* Gitea and Forgejo name some pull request actions differently than GitHub:
|
||||
* new commits arrive as `synchronized` rather than `synchronize`, and any label
|
||||
* change is `label_updated` (adding *or* removing a single label) while
|
||||
* `label_cleared` only fires when every label is removed at once.
|
||||
*
|
||||
* @link https://github.com/go-gitea/gitea/blob/main/modules/structs/hook.go
|
||||
*/
|
||||
const CREATE_ACTIONS = ["opened", "reopened", "synchronized", "label_updated"];
|
||||
|
||||
/**
|
||||
* Actions that refresh an existing preview but never create one - the GitHub
|
||||
* handler treats `unlabeled` the same way.
|
||||
*/
|
||||
const UPDATE_ONLY_ACTIONS = ["label_cleared"];
|
||||
|
||||
/**
|
||||
* Gitea serializes a user handle as `login` (`username` is only kept as a
|
||||
* backwards compatible alias), and handles are case insensitive.
|
||||
*/
|
||||
const getPayloadOwner = (repository: any): string | undefined =>
|
||||
repository?.owner?.login ??
|
||||
repository?.owner?.username ??
|
||||
repository?.owner?.name;
|
||||
|
||||
const sameHandle = (a?: string | null, b?: string | null) =>
|
||||
!!a && !!b && a.toLowerCase() === b.toLowerCase();
|
||||
|
||||
interface PreviewApplication {
|
||||
applicationId: string;
|
||||
name: string;
|
||||
sourceType: string;
|
||||
serverId: string | null;
|
||||
giteaId: string | null;
|
||||
giteaOwner: string | null;
|
||||
giteaRepository: string | null;
|
||||
giteaBranch: string | null;
|
||||
isPreviewDeploymentsActive: boolean | null;
|
||||
previewLabels: string[] | null;
|
||||
previewLimit: number | null;
|
||||
previewRequireCollaboratorPermissions: boolean | null;
|
||||
previewDeployments?: { previewDeploymentId: string }[];
|
||||
}
|
||||
|
||||
interface HandlerResult {
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Gitea/Forgejo `pull_request` webhook event for a single application.
|
||||
*
|
||||
* Unlike GitHub - where one app installation webhook serves every repository -
|
||||
* the Gitea webhook is created per application (the URL carries the
|
||||
* application's refresh token), so the event is always scoped to `application`.
|
||||
*/
|
||||
export const handleGiteaPullRequestEvent = async ({
|
||||
application,
|
||||
body,
|
||||
}: {
|
||||
application: PreviewApplication;
|
||||
body: any;
|
||||
}): Promise<HandlerResult> => {
|
||||
const action = body?.action;
|
||||
const pullRequest = body?.pull_request;
|
||||
const pullRequestId = pullRequest?.id;
|
||||
|
||||
if (!pullRequestId) {
|
||||
return {
|
||||
status: 400,
|
||||
message: "Pull request id missing in webhook payload",
|
||||
};
|
||||
}
|
||||
|
||||
// The webhook URL identifies the application, not the repository, so the
|
||||
// payload has to be checked against the repository the application is
|
||||
// configured for. Without this, a webhook on any repository could deploy an
|
||||
// arbitrary branch of the configured one.
|
||||
const payloadRepository = body?.repository?.name;
|
||||
const payloadOwner = getPayloadOwner(body?.repository);
|
||||
|
||||
if (
|
||||
!sameHandle(payloadRepository, application.giteaRepository) ||
|
||||
!sameHandle(payloadOwner, application.giteaOwner)
|
||||
) {
|
||||
return {
|
||||
status: 400,
|
||||
message:
|
||||
"Pull request repository does not match the repository configured for this application",
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "closed") {
|
||||
const previewDeploymentResult = await findPreviewDeploymentsByPullRequestId(
|
||||
`${pullRequestId}`,
|
||||
);
|
||||
|
||||
let removed = 0;
|
||||
for (const previewDeployment of previewDeploymentResult) {
|
||||
// Pull request ids are only unique per Gitea instance, so never touch
|
||||
// previews that belong to another application.
|
||||
if (previewDeployment.applicationId !== application.applicationId) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await removePreviewDeployment(previewDeployment.previewDeploymentId);
|
||||
removed++;
|
||||
} catch (error) {
|
||||
console.error("Error removing preview deployment:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
message: `Preview Deployment Closed (${removed} removed)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!application.isPreviewDeploymentsActive) {
|
||||
return {
|
||||
status: 200,
|
||||
message: "Preview deployments are disabled for this application",
|
||||
};
|
||||
}
|
||||
|
||||
const isCreateAction = CREATE_ACTIONS.includes(action);
|
||||
|
||||
if (!isCreateAction && !UPDATE_ONLY_ACTIONS.includes(action)) {
|
||||
return {
|
||||
status: 200,
|
||||
message: `Pull request action '${action}' does not trigger preview deployments`,
|
||||
};
|
||||
}
|
||||
|
||||
const baseBranch = pullRequest?.base?.ref;
|
||||
if (!baseBranch || baseBranch !== application.giteaBranch) {
|
||||
return {
|
||||
status: 200,
|
||||
message: "Pull request does not target the configured branch",
|
||||
};
|
||||
}
|
||||
|
||||
const prAuthor = pullRequest?.user?.login ?? pullRequest?.user?.username;
|
||||
if (!prAuthor) {
|
||||
console.warn(
|
||||
"⚠️ SECURITY: PR author information missing in webhook payload",
|
||||
);
|
||||
return { status: 400, message: "PR author information missing" };
|
||||
}
|
||||
|
||||
const commentContext = getPreviewCommentContext(application);
|
||||
if (!commentContext) {
|
||||
return {
|
||||
status: 400,
|
||||
message:
|
||||
"Preview deployments require a Gitea provider with a repository and owner configured",
|
||||
};
|
||||
}
|
||||
|
||||
const prNumber = pullRequest?.number;
|
||||
const repositorySlug = `${application.giteaOwner}/${application.giteaRepository}`;
|
||||
|
||||
// `cloneGiteaRepository` always clones the configured repository, so a branch
|
||||
// that only exists in a fork can never be checked out. Bail out with a clear
|
||||
// message instead of producing a failing build.
|
||||
const headRepository = pullRequest?.head?.repo;
|
||||
if (
|
||||
headRepository &&
|
||||
(!sameHandle(headRepository?.name, application.giteaRepository) ||
|
||||
!sameHandle(getPayloadOwner(headRepository), application.giteaOwner))
|
||||
) {
|
||||
return {
|
||||
status: 200,
|
||||
message:
|
||||
"Preview deployments are not supported for pull requests from forks",
|
||||
};
|
||||
}
|
||||
|
||||
// SECURITY: preview deployments build and run pull request code on the
|
||||
// Dokploy host, so only authors with write access may trigger them.
|
||||
if (application.previewRequireCollaboratorPermissions !== false) {
|
||||
// The repository owner always has admin access, and Gitea only answers
|
||||
// the permission endpoint for repository admins, so short circuit here.
|
||||
const isRepositoryOwner = sameHandle(prAuthor, application.giteaOwner);
|
||||
|
||||
if (!isRepositoryOwner) {
|
||||
try {
|
||||
const { hasWriteAccess, permission, verified } =
|
||||
await checkPreviewAuthorPermissions(commentContext, prAuthor);
|
||||
|
||||
if (!verified) {
|
||||
// Gitea refused to answer - this is a Dokploy side
|
||||
// misconfiguration, so do not blame the pull request author.
|
||||
console.error(
|
||||
`🚨 SECURITY: Could not verify permissions of ${prAuthor} on ${repositorySlug}; the Gitea account connected to Dokploy needs admin access on the repository. Skipping preview deployment for ${application.name}.`,
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
message:
|
||||
"Preview deployment skipped: the Gitea account connected to Dokploy cannot read collaborator permissions for this repository",
|
||||
};
|
||||
}
|
||||
|
||||
if (!hasWriteAccess) {
|
||||
console.warn(
|
||||
`🚨 SECURITY: Blocked preview deployment for ${application.name} from unauthorized user ${prAuthor} on ${repositorySlug}. Permission: ${permission || "none"}`,
|
||||
);
|
||||
await createPreviewSecurityBlockedComment(commentContext, {
|
||||
prNumber: Number.parseInt(`${prNumber}`),
|
||||
prAuthor,
|
||||
permission,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
message: "Preview deployment blocked: author lacks write access",
|
||||
};
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ SECURITY: Preview deployment authorized for ${application.name} from user ${prAuthor} on ${repositorySlug}. Permission: ${permission}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error validating PR author permissions for ${application.name}:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
message:
|
||||
"Preview deployment blocked: author permissions unverifiable",
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`⚠️ SECURITY: Preview deployment for ${application.name} allows deployment from any PR author (security check disabled)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (application.previewLabels && application.previewLabels.length > 0) {
|
||||
const labels: { name?: string }[] = pullRequest?.labels ?? [];
|
||||
const hasLabel = labels.some(
|
||||
(label) => label?.name && application.previewLabels?.includes(label.name),
|
||||
);
|
||||
|
||||
if (!hasLabel) {
|
||||
return {
|
||||
status: 200,
|
||||
message: "Pull request does not carry any of the configured labels",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const previewDeploymentResult = await findPreviewDeploymentByApplicationId(
|
||||
application.applicationId,
|
||||
`${pullRequestId}`,
|
||||
);
|
||||
|
||||
let previewDeploymentId = previewDeploymentResult?.previewDeploymentId ?? "";
|
||||
|
||||
if (!previewDeploymentResult) {
|
||||
if (!isCreateAction) {
|
||||
return {
|
||||
status: 200,
|
||||
message: "No existing preview deployment to redeploy",
|
||||
};
|
||||
}
|
||||
|
||||
// The limit only applies to new previews, existing ones must still be
|
||||
// redeployed when the pull request is updated.
|
||||
const previewLimit = application.previewLimit ?? 3;
|
||||
if ((application.previewDeployments?.length ?? 0) >= previewLimit) {
|
||||
console.warn(
|
||||
`⚠️ Preview deployment limit (${previewLimit}) reached for ${application.name}, skipping preview for pull request #${prNumber}`,
|
||||
);
|
||||
return {
|
||||
status: 200,
|
||||
message: `Preview deployment limit (${previewLimit}) reached`,
|
||||
};
|
||||
}
|
||||
|
||||
const previewDeployment = await createPreviewDeployment({
|
||||
applicationId: application.applicationId,
|
||||
branch: pullRequest?.head?.ref,
|
||||
pullRequestId: `${pullRequestId}`,
|
||||
pullRequestNumber: `${prNumber}`,
|
||||
pullRequestTitle: pullRequest?.title,
|
||||
pullRequestURL: pullRequest?.html_url,
|
||||
});
|
||||
|
||||
previewDeploymentId = previewDeployment.previewDeploymentId;
|
||||
}
|
||||
|
||||
if (!previewDeploymentId) {
|
||||
return { status: 200, message: "No preview deployment to deploy" };
|
||||
}
|
||||
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: application.applicationId,
|
||||
titleLog: "Preview Deployment",
|
||||
descriptionLog: `Hash: ${pullRequest?.head?.sha ?? ""}`,
|
||||
type: "deploy",
|
||||
applicationType: "application-preview",
|
||||
server: !!application.serverId,
|
||||
previewDeploymentId,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && application.serverId) {
|
||||
jobData.serverId = application.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
return { status: 200, message: "Preview Deployment queued" };
|
||||
}
|
||||
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
|
||||
return { status: 200, message: "Preview Deployment queued" };
|
||||
};
|
||||
@ -38,6 +38,7 @@ export * from "./services/patch";
|
||||
export * from "./services/patch-repo";
|
||||
export * from "./services/port";
|
||||
export * from "./services/postgres";
|
||||
export * from "./services/preview-comment";
|
||||
export * from "./services/preview-deployment";
|
||||
export * from "./services/project";
|
||||
export * from "./services/proprietary/forward-auth";
|
||||
|
||||
@ -39,13 +39,13 @@ import {
|
||||
updateDeploymentStatus,
|
||||
} from "./deployment";
|
||||
import { type Domain, getDomainHost } from "./domain";
|
||||
import {
|
||||
createPreviewDeploymentComment,
|
||||
getIssueComment,
|
||||
issueCommentExists,
|
||||
updateIssueComment,
|
||||
} from "./github";
|
||||
import { getIssueComment } from "./github";
|
||||
import { generateApplyPatchesCommand } from "./patch";
|
||||
import {
|
||||
ensurePreviewComment,
|
||||
getPreviewCommentContext,
|
||||
updatePreviewComment,
|
||||
} from "./preview-comment";
|
||||
import {
|
||||
findPreviewDeploymentById,
|
||||
updatePreviewDeployment,
|
||||
@ -267,7 +267,7 @@ export const deployApplication = async ({
|
||||
projectName: application.environment.project.name,
|
||||
applicationName: application.name,
|
||||
applicationType: "application",
|
||||
// @ts-ignore
|
||||
// @ts-expect-error
|
||||
errorMessage: error?.message || "Error building",
|
||||
buildLink,
|
||||
organizationId: application.environment.project.organizationId,
|
||||
@ -359,6 +359,71 @@ export const rebuildApplication = async ({
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a writer that keeps the pull request comment of a preview deployment up
|
||||
* to date, no matter which git provider hosts the pull request. The comment can
|
||||
* be deleted by users, so it is recreated (and the new id persisted) on demand.
|
||||
*/
|
||||
const buildPreviewCommentWriter = ({
|
||||
application,
|
||||
previewDeployment,
|
||||
previewDeploymentId,
|
||||
previewDomain,
|
||||
}: {
|
||||
application: { name: string } & Parameters<
|
||||
typeof getPreviewCommentContext
|
||||
>[0];
|
||||
previewDeployment: {
|
||||
pullRequestNumber: string;
|
||||
pullRequestCommentId: string;
|
||||
};
|
||||
previewDeploymentId: string;
|
||||
previewDomain: string;
|
||||
}) => {
|
||||
let commentId = previewDeployment.pullRequestCommentId;
|
||||
const issueNumber = previewDeployment.pullRequestNumber;
|
||||
|
||||
return async (status: "running" | "success" | "error") => {
|
||||
const commentContext = getPreviewCommentContext(application);
|
||||
|
||||
if (!commentContext) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message:
|
||||
"Preview deployments require a GitHub or Gitea provider with a repository and owner configured",
|
||||
});
|
||||
}
|
||||
|
||||
const body = `### Dokploy Preview Deployment\n\n${getIssueComment(
|
||||
application.name,
|
||||
status,
|
||||
previewDomain,
|
||||
)}`;
|
||||
|
||||
const ensured = await ensurePreviewComment(commentContext, {
|
||||
issueNumber,
|
||||
commentId,
|
||||
body,
|
||||
});
|
||||
|
||||
if (ensured.created) {
|
||||
// The freshly created comment already carries `body`, only the new id
|
||||
// has to be remembered for the next status update.
|
||||
commentId = ensured.commentId;
|
||||
await updatePreviewDeployment(previewDeploymentId, {
|
||||
pullRequestCommentId: commentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updatePreviewComment(commentContext, {
|
||||
issueNumber,
|
||||
commentId,
|
||||
body,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export const deployPreviewApplication = async ({
|
||||
applicationId,
|
||||
titleLog = "Preview Deployment",
|
||||
@ -386,44 +451,15 @@ export const deployPreviewApplication = async ({
|
||||
});
|
||||
|
||||
const previewDomain = getDomainHost(previewDeployment?.domain as Domain);
|
||||
const issueParams = {
|
||||
owner: application?.owner || "",
|
||||
repository: application?.repository || "",
|
||||
issue_number: previewDeployment.pullRequestNumber,
|
||||
comment_id: Number.parseInt(previewDeployment.pullRequestCommentId),
|
||||
githubId: application?.githubId || "",
|
||||
};
|
||||
const writePreviewComment = buildPreviewCommentWriter({
|
||||
application,
|
||||
previewDeployment,
|
||||
previewDeploymentId,
|
||||
previewDomain,
|
||||
});
|
||||
|
||||
try {
|
||||
const commentExists = await issueCommentExists({
|
||||
...issueParams,
|
||||
});
|
||||
if (!commentExists) {
|
||||
const result = await createPreviewDeploymentComment({
|
||||
...issueParams,
|
||||
previewDomain,
|
||||
appName: previewDeployment.appName,
|
||||
githubId: application?.githubId || "",
|
||||
previewDeploymentId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Pull request comment not found",
|
||||
});
|
||||
}
|
||||
|
||||
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
|
||||
}
|
||||
const buildingComment = getIssueComment(
|
||||
application.name,
|
||||
"running",
|
||||
previewDomain,
|
||||
);
|
||||
await updateIssueComment({
|
||||
...issueParams,
|
||||
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
|
||||
});
|
||||
await writePreviewComment("running");
|
||||
application.appName = previewDeployment.appName;
|
||||
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||
application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||
@ -440,34 +476,41 @@ export const deployPreviewApplication = async ({
|
||||
appName: previewDeployment.appName,
|
||||
branch: previewDeployment.branch,
|
||||
});
|
||||
command += await getBuildCommand(application);
|
||||
|
||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (application.serverId) {
|
||||
await execAsyncRemote(application.serverId, commandWithLog);
|
||||
} else {
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
await mechanizeDockerContainer(application);
|
||||
} else if (application.sourceType === "gitea") {
|
||||
command += await cloneGiteaRepository({
|
||||
...application,
|
||||
appName: previewDeployment.appName,
|
||||
giteaBranch: previewDeployment.branch,
|
||||
});
|
||||
} else {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Preview deployments are not supported for the '${application.sourceType}' source type`,
|
||||
});
|
||||
}
|
||||
const successComment = getIssueComment(
|
||||
application.name,
|
||||
"success",
|
||||
previewDomain,
|
||||
);
|
||||
await updateIssueComment({
|
||||
...issueParams,
|
||||
body: `### Dokploy Preview Deployment\n\n${successComment}`,
|
||||
});
|
||||
|
||||
command += await getBuildCommand(application);
|
||||
|
||||
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
if (application.serverId) {
|
||||
await execAsyncRemote(application.serverId, commandWithLog);
|
||||
} else {
|
||||
await execAsync(commandWithLog);
|
||||
}
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
await writePreviewComment("success");
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
await updatePreviewDeployment(previewDeploymentId, {
|
||||
previewStatus: "done",
|
||||
});
|
||||
} catch (error) {
|
||||
const comment = getIssueComment(application.name, "error", previewDomain);
|
||||
await updateIssueComment({
|
||||
...issueParams,
|
||||
body: `### Dokploy Preview Deployment\n\n${comment}`,
|
||||
await writePreviewComment("error").catch((commentError) => {
|
||||
// Never let a failing status comment hide the actual build error.
|
||||
console.error(
|
||||
"Error reporting the preview deployment failure:",
|
||||
commentError,
|
||||
);
|
||||
});
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
await updatePreviewDeployment(previewDeploymentId, {
|
||||
@ -501,46 +544,15 @@ export const rebuildPreviewApplication = async ({
|
||||
});
|
||||
|
||||
const previewDomain = getDomainHost(previewDeployment?.domain as Domain);
|
||||
const issueParams = {
|
||||
owner: application?.owner || "",
|
||||
repository: application?.repository || "",
|
||||
issue_number: previewDeployment.pullRequestNumber,
|
||||
comment_id: Number.parseInt(previewDeployment.pullRequestCommentId),
|
||||
githubId: application?.githubId || "",
|
||||
};
|
||||
const writePreviewComment = buildPreviewCommentWriter({
|
||||
application,
|
||||
previewDeployment,
|
||||
previewDeploymentId,
|
||||
previewDomain,
|
||||
});
|
||||
|
||||
try {
|
||||
const commentExists = await issueCommentExists({
|
||||
...issueParams,
|
||||
});
|
||||
if (!commentExists) {
|
||||
const result = await createPreviewDeploymentComment({
|
||||
...issueParams,
|
||||
previewDomain,
|
||||
appName: previewDeployment.appName,
|
||||
githubId: application?.githubId || "",
|
||||
previewDeploymentId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Pull request comment not found",
|
||||
});
|
||||
}
|
||||
|
||||
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
|
||||
}
|
||||
|
||||
const buildingComment = getIssueComment(
|
||||
application.name,
|
||||
"running",
|
||||
previewDomain,
|
||||
);
|
||||
await updateIssueComment({
|
||||
...issueParams,
|
||||
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
|
||||
});
|
||||
await writePreviewComment("running");
|
||||
|
||||
// Set application properties for preview deployment
|
||||
application.appName = previewDeployment.appName;
|
||||
@ -564,15 +576,7 @@ export const rebuildPreviewApplication = async ({
|
||||
}
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
const successComment = getIssueComment(
|
||||
application.name,
|
||||
"success",
|
||||
previewDomain,
|
||||
);
|
||||
await updateIssueComment({
|
||||
...issueParams,
|
||||
body: `### Dokploy Preview Deployment\n\n${successComment}`,
|
||||
});
|
||||
await writePreviewComment("success");
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
await updatePreviewDeployment(previewDeploymentId, {
|
||||
previewStatus: "done",
|
||||
@ -595,10 +599,12 @@ export const rebuildPreviewApplication = async ({
|
||||
await execAsync(command);
|
||||
}
|
||||
|
||||
const comment = getIssueComment(application.name, "error", previewDomain);
|
||||
await updateIssueComment({
|
||||
...issueParams,
|
||||
body: `### Dokploy Preview Deployment\n\n${comment}`,
|
||||
await writePreviewComment("error").catch((commentError) => {
|
||||
// Never let a failing status comment hide the actual build error.
|
||||
console.error(
|
||||
"Error reporting the preview deployment failure:",
|
||||
commentError,
|
||||
);
|
||||
});
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
await updatePreviewDeployment(previewDeploymentId, {
|
||||
|
||||
@ -8,7 +8,6 @@ import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { authGithub } from "../utils/providers/github";
|
||||
import { updatePreviewDeployment } from "./preview-deployment";
|
||||
|
||||
export type Github = typeof github.$inferSelect;
|
||||
export const createGithub = async (
|
||||
@ -154,46 +153,45 @@ export const updateIssueComment = async ({
|
||||
});
|
||||
};
|
||||
|
||||
interface CommentCreate {
|
||||
appName: string;
|
||||
interface IssueCommentCreate {
|
||||
owner: string;
|
||||
repository: string;
|
||||
issue_number: string;
|
||||
previewDomain: string;
|
||||
body: string;
|
||||
githubId: string;
|
||||
previewDeploymentId: string;
|
||||
}
|
||||
|
||||
export const createPreviewDeploymentComment = async ({
|
||||
/**
|
||||
* Create a comment on a GitHub issue or pull request and return it, so callers
|
||||
* decide themselves what to do with the comment id.
|
||||
*/
|
||||
export const createIssueComment = async ({
|
||||
owner,
|
||||
repository,
|
||||
issue_number,
|
||||
previewDomain,
|
||||
appName,
|
||||
body,
|
||||
githubId,
|
||||
previewDeploymentId,
|
||||
}: CommentCreate) => {
|
||||
}: IssueCommentCreate) => {
|
||||
const github = await findGithubById(githubId);
|
||||
const octokit = authGithub(github);
|
||||
|
||||
const runningComment = getIssueComment(
|
||||
appName,
|
||||
"initializing",
|
||||
previewDomain,
|
||||
);
|
||||
|
||||
const issue = await octokit.rest.issues.createComment({
|
||||
owner: owner || "",
|
||||
repo: repository || "",
|
||||
issue_number: Number.parseInt(issue_number),
|
||||
body: `### Dokploy Preview Deployment\n\n${runningComment}`,
|
||||
body,
|
||||
});
|
||||
|
||||
return await updatePreviewDeployment(previewDeploymentId, {
|
||||
pullRequestCommentId: `${issue.data.id}`,
|
||||
}).then((response) => response[0]);
|
||||
return issue.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Marker used to detect an already-posted "deployment blocked" notice, so
|
||||
* subsequent pushes to the same pull request do not spam the thread.
|
||||
*/
|
||||
export const SECURITY_BLOCKED_COMMENT_MARKER =
|
||||
"🚨 Preview Deployment Blocked - Security Protection";
|
||||
|
||||
/**
|
||||
* Generate security notification message for blocked PR deployments
|
||||
*/
|
||||
@ -201,8 +199,13 @@ export const getSecurityBlockedMessage = (
|
||||
prAuthor: string,
|
||||
repositoryName: string,
|
||||
permission: string | null,
|
||||
/**
|
||||
* Access levels that would unblock the author. Gitea has no `maintain`
|
||||
* level, so providers pass their own list.
|
||||
*/
|
||||
requiredLevels = "`write`, `maintain`, or `admin`",
|
||||
) => {
|
||||
return `### 🚨 Preview Deployment Blocked - Security Protection
|
||||
return `### ${SECURITY_BLOCKED_COMMENT_MARKER}
|
||||
|
||||
**Your pull request was blocked from triggering preview deployments**
|
||||
|
||||
@ -210,7 +213,7 @@ export const getSecurityBlockedMessage = (
|
||||
- **User**: \`${prAuthor}\`
|
||||
- **Repository**: \`${repositoryName}\`
|
||||
- **Permission Level**: \`${permission || "none"}\`
|
||||
- **Required Level**: \`write\`, \`maintain\`, or \`admin\`
|
||||
- **Required Level**: ${requiredLevels}
|
||||
|
||||
#### How to resolve this:
|
||||
|
||||
@ -267,9 +270,7 @@ export const hasExistingSecurityComment = async ({
|
||||
|
||||
// Check if any comment contains our security notification marker
|
||||
const securityCommentExists = comments.some((comment) =>
|
||||
comment.body?.includes(
|
||||
"🚨 Preview Deployment Blocked - Security Protection",
|
||||
),
|
||||
comment.body?.includes(SECURITY_BLOCKED_COMMENT_MARKER),
|
||||
);
|
||||
|
||||
return securityCommentExists;
|
||||
|
||||
297
packages/server/src/services/preview-comment.ts
Normal file
297
packages/server/src/services/preview-comment.ts
Normal file
@ -0,0 +1,297 @@
|
||||
import {
|
||||
checkGiteaUserRepositoryPermissions,
|
||||
createGiteaIssueComment,
|
||||
GITEA_WRITE_PERMISSIONS,
|
||||
giteaIssueCommentExists,
|
||||
listGiteaIssueComments,
|
||||
updateGiteaIssueComment,
|
||||
} from "../utils/providers/gitea";
|
||||
import { checkUserRepositoryPermissions } from "../utils/providers/github";
|
||||
import {
|
||||
createIssueComment,
|
||||
createSecurityBlockedComment,
|
||||
findGithubById,
|
||||
getSecurityBlockedMessage,
|
||||
issueCommentExists,
|
||||
SECURITY_BLOCKED_COMMENT_MARKER,
|
||||
updateIssueComment,
|
||||
} from "./github";
|
||||
/**
|
||||
* Git providers that can host preview deployments. Preview deployments need to
|
||||
* read pull requests and write a status comment back to them, so a provider is
|
||||
* only usable once both capabilities exist.
|
||||
*/
|
||||
export type PreviewCommentProvider = "github" | "gitea";
|
||||
|
||||
export interface PreviewCommentContext {
|
||||
provider: PreviewCommentProvider;
|
||||
/** `githubId` or `giteaId` depending on `provider`. */
|
||||
providerId: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
}
|
||||
|
||||
interface PreviewCommentApplication {
|
||||
sourceType: string;
|
||||
githubId?: string | null;
|
||||
owner?: string | null;
|
||||
repository?: string | null;
|
||||
giteaId?: string | null;
|
||||
giteaOwner?: string | null;
|
||||
giteaRepository?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the provider coordinates needed to comment on the pull request that
|
||||
* backs a preview deployment. Returns `null` for source types that cannot host
|
||||
* preview deployments (docker, custom git, gitlab, bitbucket, ...).
|
||||
*/
|
||||
export const getPreviewCommentContext = (
|
||||
application: PreviewCommentApplication,
|
||||
): PreviewCommentContext | null => {
|
||||
if (
|
||||
application.sourceType === "github" &&
|
||||
application.githubId &&
|
||||
application.owner &&
|
||||
application.repository
|
||||
) {
|
||||
return {
|
||||
provider: "github",
|
||||
providerId: application.githubId,
|
||||
owner: application.owner,
|
||||
repository: application.repository,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
application.sourceType === "gitea" &&
|
||||
application.giteaId &&
|
||||
application.giteaOwner &&
|
||||
application.giteaRepository
|
||||
) {
|
||||
return {
|
||||
provider: "gitea",
|
||||
providerId: application.giteaId,
|
||||
owner: application.giteaOwner,
|
||||
repository: application.giteaRepository,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const previewCommentExists = async (
|
||||
context: PreviewCommentContext,
|
||||
commentId: string,
|
||||
) => {
|
||||
const parsedCommentId = Number.parseInt(commentId);
|
||||
if (!commentId || Number.isNaN(parsedCommentId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.provider === "gitea") {
|
||||
return await giteaIssueCommentExists({
|
||||
giteaId: context.providerId,
|
||||
owner: context.owner,
|
||||
repository: context.repository,
|
||||
commentId: parsedCommentId,
|
||||
});
|
||||
}
|
||||
|
||||
return await issueCommentExists({
|
||||
owner: context.owner,
|
||||
repository: context.repository,
|
||||
comment_id: parsedCommentId,
|
||||
githubId: context.providerId,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the preview deployment comment on the pull request and return the
|
||||
* provider comment id as a string, matching the `pullRequestCommentId` column.
|
||||
*/
|
||||
export const createPreviewComment = async (
|
||||
context: PreviewCommentContext,
|
||||
{ issueNumber, body }: { issueNumber: string; body: string },
|
||||
) => {
|
||||
if (context.provider === "gitea") {
|
||||
const comment = await createGiteaIssueComment({
|
||||
giteaId: context.providerId,
|
||||
owner: context.owner,
|
||||
repository: context.repository,
|
||||
index: issueNumber,
|
||||
body,
|
||||
});
|
||||
return `${comment.id}`;
|
||||
}
|
||||
|
||||
const comment = await createIssueComment({
|
||||
owner: context.owner,
|
||||
repository: context.repository,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
githubId: context.providerId,
|
||||
});
|
||||
return `${comment.id}`;
|
||||
};
|
||||
|
||||
export const updatePreviewComment = async (
|
||||
context: PreviewCommentContext,
|
||||
{
|
||||
issueNumber,
|
||||
commentId,
|
||||
body,
|
||||
}: { issueNumber: string; commentId: string; body: string },
|
||||
) => {
|
||||
if (context.provider === "gitea") {
|
||||
await updateGiteaIssueComment({
|
||||
giteaId: context.providerId,
|
||||
owner: context.owner,
|
||||
repository: context.repository,
|
||||
commentId,
|
||||
body,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateIssueComment({
|
||||
owner: context.owner,
|
||||
repository: context.repository,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
comment_id: Number.parseInt(commentId),
|
||||
githubId: context.providerId,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Make sure the preview deployment still has a comment to write status into:
|
||||
* users can delete it, in which case a fresh one is created. `created` tells
|
||||
* the caller whether `commentId` has to be persisted on the preview deployment.
|
||||
*/
|
||||
export const ensurePreviewComment = async (
|
||||
context: PreviewCommentContext,
|
||||
{
|
||||
issueNumber,
|
||||
commentId,
|
||||
body,
|
||||
}: { issueNumber: string; commentId: string; body: string },
|
||||
): Promise<{ commentId: string; created: boolean }> => {
|
||||
if (await previewCommentExists(context, commentId)) {
|
||||
return { commentId, created: false };
|
||||
}
|
||||
|
||||
return {
|
||||
commentId: await createPreviewComment(context, { issueNumber, body }),
|
||||
created: true,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether the "deployment blocked" notice is already on the pull request,
|
||||
* so pushing more commits does not spam the thread.
|
||||
*/
|
||||
const hasExistingGiteaSecurityComment = async (
|
||||
context: PreviewCommentContext,
|
||||
prNumber: number,
|
||||
) => {
|
||||
try {
|
||||
const comments = await listGiteaIssueComments({
|
||||
giteaId: context.providerId,
|
||||
owner: context.owner,
|
||||
repository: context.repository,
|
||||
index: prNumber,
|
||||
});
|
||||
return comments.some((comment) =>
|
||||
comment.body?.includes(SECURITY_BLOCKED_COMMENT_MARKER),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to check existing comments on PR #${prNumber}:`,
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const createPreviewSecurityBlockedComment = async (
|
||||
context: PreviewCommentContext,
|
||||
{
|
||||
prNumber,
|
||||
prAuthor,
|
||||
permission,
|
||||
}: { prNumber: number; prAuthor: string; permission: string | null },
|
||||
) => {
|
||||
if (context.provider === "gitea") {
|
||||
try {
|
||||
if (await hasExistingGiteaSecurityComment(context, prNumber)) {
|
||||
console.log(
|
||||
`ℹ️ Security notification comment already exists on PR #${prNumber}, skipping duplicate`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await createGiteaIssueComment({
|
||||
giteaId: context.providerId,
|
||||
owner: context.owner,
|
||||
repository: context.repository,
|
||||
index: prNumber,
|
||||
body: getSecurityBlockedMessage(
|
||||
prAuthor,
|
||||
context.repository,
|
||||
permission,
|
||||
GITEA_WRITE_PERMISSIONS.map((level) => `\`${level}\``).join(", "),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Failed to create security comment on PR #${prNumber}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return await createSecurityBlockedComment({
|
||||
owner: context.owner,
|
||||
repository: context.repository,
|
||||
prNumber,
|
||||
prAuthor,
|
||||
permission,
|
||||
githubId: context.providerId,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify that the pull request author is allowed to trigger a preview
|
||||
* deployment, i.e. that they have write access to the repository. Preview
|
||||
* deployments build and run pull request code on the Dokploy host, so an
|
||||
* unverifiable author is always treated as untrusted.
|
||||
*/
|
||||
export const checkPreviewAuthorPermissions = async (
|
||||
context: PreviewCommentContext,
|
||||
username: string,
|
||||
): Promise<{
|
||||
hasWriteAccess: boolean;
|
||||
permission: string | null;
|
||||
verified: boolean;
|
||||
}> => {
|
||||
if (context.provider === "gitea") {
|
||||
return await checkGiteaUserRepositoryPermissions(
|
||||
context.providerId,
|
||||
context.owner,
|
||||
context.repository,
|
||||
username,
|
||||
);
|
||||
}
|
||||
|
||||
const githubProvider = await findGithubById(context.providerId);
|
||||
const result = await checkUserRepositoryPermissions(
|
||||
githubProvider,
|
||||
context.owner,
|
||||
context.repository,
|
||||
username,
|
||||
);
|
||||
|
||||
return { ...result, verified: true };
|
||||
};
|
||||
@ -11,13 +11,16 @@ import type { z } from "zod";
|
||||
import { generatePassword } from "../templates";
|
||||
import { removeService } from "../utils/docker/utils";
|
||||
import { removeDirectoryCode } from "../utils/filesystem/directory";
|
||||
import { authGithub } from "../utils/providers/github";
|
||||
import { removeTraefikConfig } from "../utils/traefik/application";
|
||||
import { manageDomain } from "../utils/traefik/domain";
|
||||
import { findApplicationById } from "./application";
|
||||
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
|
||||
import { createDomain } from "./domain";
|
||||
import { findGithubById, getIssueComment } from "./github";
|
||||
import { getIssueComment } from "./github";
|
||||
import {
|
||||
createPreviewComment,
|
||||
getPreviewCommentContext,
|
||||
} from "./preview-comment";
|
||||
import { getWebServerSettings } from "./web-server-settings";
|
||||
|
||||
export type PreviewDeployment = typeof previewDeployments.$inferSelect;
|
||||
@ -142,28 +145,24 @@ export const createPreviewDeployment = async (
|
||||
org?.ownerId || "",
|
||||
);
|
||||
|
||||
if (!application.githubId) {
|
||||
const commentContext = getPreviewCommentContext(application);
|
||||
|
||||
if (!commentContext) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Github Account not configured correctly",
|
||||
message:
|
||||
"Preview deployments require a GitHub or Gitea provider with a repository and owner configured",
|
||||
});
|
||||
}
|
||||
|
||||
// `findApplicationById` redacts `githubPrivateKey` from the `github`
|
||||
// relation, so the provider must be refetched to authenticate.
|
||||
const githubProvider = await findGithubById(application.githubId);
|
||||
const octokit = authGithub(githubProvider);
|
||||
|
||||
const runningComment = getIssueComment(
|
||||
application.name,
|
||||
"initializing",
|
||||
`${application.previewHttps ? "https" : "http"}://${generateDomain}`,
|
||||
);
|
||||
|
||||
const issue = await octokit.rest.issues.createComment({
|
||||
owner: application?.owner || "",
|
||||
repo: application?.repository || "",
|
||||
issue_number: Number.parseInt(schema.pullRequestNumber),
|
||||
const pullRequestCommentId = await createPreviewComment(commentContext, {
|
||||
issueNumber: schema.pullRequestNumber,
|
||||
body: `### Dokploy Preview Deployment\n\n${runningComment}`,
|
||||
});
|
||||
|
||||
@ -172,7 +171,7 @@ export const createPreviewDeployment = async (
|
||||
.values({
|
||||
...schema,
|
||||
appName: appName,
|
||||
pullRequestCommentId: `${issue.data.id}`,
|
||||
pullRequestCommentId,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
@ -164,6 +164,22 @@ export const cloneGiteaRepository = async ({
|
||||
return command;
|
||||
}
|
||||
|
||||
if (!giteaProvider.accessToken) {
|
||||
command += `echo "❌ [ERROR] Gitea provider is not authorized, please re-authorize it in the Git provider settings"; exit 1;`;
|
||||
return command;
|
||||
}
|
||||
|
||||
const cloneRequirements = getErrorCloneRequirements({
|
||||
giteaRepository,
|
||||
giteaOwner,
|
||||
giteaBranch,
|
||||
});
|
||||
|
||||
if (cloneRequirements.length > 0) {
|
||||
command += `echo ${quote([`❌ [ERROR] Repository configuration is incomplete: ${cloneRequirements.join(" ")}`])}; exit 1;`;
|
||||
return command;
|
||||
}
|
||||
|
||||
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
|
||||
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
|
||||
command += `rm -rf ${outputPath};`;
|
||||
@ -392,3 +408,247 @@ export const getGiteaBranches = async (input: {
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the base URL used to talk to the Gitea/Forgejo REST API, preferring
|
||||
* the internal URL when Gitea runs on the same host as Dokploy.
|
||||
*/
|
||||
const getGiteaApiBaseUrl = (giteaProvider: {
|
||||
giteaUrl: string;
|
||||
giteaInternalUrl?: string | null;
|
||||
}) =>
|
||||
(giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl).replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
|
||||
interface GiteaApiRequestOptions {
|
||||
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
||||
body?: unknown;
|
||||
/** Status codes that should resolve to `null` instead of throwing. */
|
||||
allowedErrorStatuses?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an authenticated request against the Gitea/Forgejo REST API.
|
||||
*
|
||||
* The access token is always read through `findGiteaById` because
|
||||
* `findApplicationById` redacts `accessToken` from the `gitea` relation.
|
||||
*
|
||||
* Returns `null` when the response status is listed in `allowedErrorStatuses`.
|
||||
*/
|
||||
export const giteaApiRequest = async <T>(
|
||||
giteaId: string,
|
||||
path: string,
|
||||
{
|
||||
method = "GET",
|
||||
body,
|
||||
allowedErrorStatuses = [],
|
||||
}: GiteaApiRequestOptions = {},
|
||||
): Promise<T | null> => {
|
||||
await refreshGiteaToken(giteaId);
|
||||
const giteaProvider = await findGiteaById(giteaId);
|
||||
|
||||
if (!giteaProvider?.accessToken) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "No Gitea access token available. Please authorize with Gitea.",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${getGiteaApiBaseUrl(giteaProvider)}/api/v1${path}`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${giteaProvider.accessToken}`,
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (allowedErrorStatuses.includes(response.status)) {
|
||||
return null;
|
||||
}
|
||||
const details = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Gitea API ${method} ${path} failed: ${response.status} ${response.statusText}${
|
||||
details ? ` - ${details.slice(0, 300)}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
};
|
||||
|
||||
/** Gitea access levels that may trigger a preview deployment. */
|
||||
export const GITEA_WRITE_PERMISSIONS = ["write", "admin", "owner"];
|
||||
|
||||
export interface GiteaRepositoryPermission {
|
||||
hasWriteAccess: boolean;
|
||||
permission: string | null;
|
||||
/**
|
||||
* `false` when Gitea refused to answer, which is a Dokploy-side
|
||||
* misconfiguration rather than a statement about the user.
|
||||
*/
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea/Forgejo equivalent of GitHub's collaborator permission check.
|
||||
*
|
||||
* Possible `permission` values are `none`, `read`, `write`, `admin` and
|
||||
* `owner` - the last one is only ever returned by the API, so it has to be
|
||||
* allow-listed or repository owners get blocked from their own pull requests.
|
||||
* Gitea has no `maintain` level.
|
||||
*
|
||||
* Note that Gitea only answers this for site admins, repository admins and
|
||||
* users asking about themselves; everyone else gets a 403 (verified against
|
||||
* Gitea 1.24.3). The Gitea account connected to Dokploy therefore needs admin
|
||||
* access on the repository, and a 403 is reported as unverified rather than as
|
||||
* a statement about the pull request author.
|
||||
*
|
||||
* @link https://docs.gitea.com/api/1.24/#tag/repository/operation/repoGetRepoPermissions
|
||||
*/
|
||||
export const checkGiteaUserRepositoryPermissions = async (
|
||||
giteaId: string,
|
||||
owner: string,
|
||||
repository: string,
|
||||
username: string,
|
||||
): Promise<GiteaRepositoryPermission> => {
|
||||
try {
|
||||
const result = await giteaApiRequest<{ permission?: string }>(
|
||||
giteaId,
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
|
||||
repository,
|
||||
)}/collaborators/${encodeURIComponent(username)}/permission`,
|
||||
{ allowedErrorStatuses: [404] },
|
||||
);
|
||||
|
||||
if (!result?.permission) {
|
||||
// A 404 means Gitea would not name a permission at all; on public
|
||||
// repositories a non-collaborator is instead reported as `read`.
|
||||
return { hasWriteAccess: false, permission: null, verified: true };
|
||||
}
|
||||
|
||||
return {
|
||||
hasWriteAccess: GITEA_WRITE_PERMISSIONS.includes(result.permission),
|
||||
permission: result.permission,
|
||||
verified: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Unable to resolve Gitea permissions for ${username} on ${owner}/${repository}:`,
|
||||
error,
|
||||
);
|
||||
return { hasWriteAccess: false, permission: null, verified: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a comment on a Gitea/Forgejo issue or pull request.
|
||||
* Pull requests share the issue index, so `index` is the PR number.
|
||||
*/
|
||||
export const createGiteaIssueComment = async ({
|
||||
giteaId,
|
||||
owner,
|
||||
repository,
|
||||
index,
|
||||
body,
|
||||
}: {
|
||||
giteaId: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
index: string | number;
|
||||
body: string;
|
||||
}) => {
|
||||
const comment = await giteaApiRequest<{ id: number }>(
|
||||
giteaId,
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
|
||||
repository,
|
||||
)}/issues/${encodeURIComponent(String(index))}/comments`,
|
||||
{ method: "POST", body: { body } },
|
||||
);
|
||||
|
||||
if (!comment?.id) {
|
||||
throw new Error("Gitea did not return an id for the created comment");
|
||||
}
|
||||
|
||||
return comment;
|
||||
};
|
||||
|
||||
export const updateGiteaIssueComment = async ({
|
||||
giteaId,
|
||||
owner,
|
||||
repository,
|
||||
commentId,
|
||||
body,
|
||||
}: {
|
||||
giteaId: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
commentId: string | number;
|
||||
body: string;
|
||||
}) => {
|
||||
await giteaApiRequest(
|
||||
giteaId,
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
|
||||
repository,
|
||||
)}/issues/comments/${encodeURIComponent(String(commentId))}`,
|
||||
{ method: "PATCH", body: { body } },
|
||||
);
|
||||
};
|
||||
|
||||
export const giteaIssueCommentExists = async ({
|
||||
giteaId,
|
||||
owner,
|
||||
repository,
|
||||
commentId,
|
||||
}: {
|
||||
giteaId: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
commentId: string | number;
|
||||
}) => {
|
||||
try {
|
||||
const comment = await giteaApiRequest<{ id: number }>(
|
||||
giteaId,
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
|
||||
repository,
|
||||
)}/issues/comments/${encodeURIComponent(String(commentId))}`,
|
||||
{ allowedErrorStatuses: [404] },
|
||||
);
|
||||
return !!comment;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const listGiteaIssueComments = async ({
|
||||
giteaId,
|
||||
owner,
|
||||
repository,
|
||||
index,
|
||||
}: {
|
||||
giteaId: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
index: string | number;
|
||||
}) => {
|
||||
const comments = await giteaApiRequest<{ body?: string }[]>(
|
||||
giteaId,
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(
|
||||
repository,
|
||||
)}/issues/${encodeURIComponent(String(index))}/comments`,
|
||||
{ allowedErrorStatuses: [404] },
|
||||
);
|
||||
|
||||
return comments ?? [];
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user