diff --git a/apps/dokploy/__test__/deploy/fixtures/gitea-pull-request-deliveries.json b/apps/dokploy/__test__/deploy/fixtures/gitea-pull-request-deliveries.json new file mode 100644 index 000000000..f37a53bf0 --- /dev/null +++ b/apps/dokploy/__test__/deploy/fixtures/gitea-pull-request-deliveries.json @@ -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" + } + } + } + } + } + } +] diff --git a/apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts b/apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts new file mode 100644 index 000000000..4e3a270a0 --- /dev/null +++ b/apps/dokploy/__test__/deploy/gitea-webhook-live-payloads.test.ts @@ -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; + 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; 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(); + }); +}); diff --git a/apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts b/apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts new file mode 100644 index 000000000..0aadeedc6 --- /dev/null +++ b/apps/dokploy/__test__/deploy/gitea-webhook-preview.test.ts @@ -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 = {}) => ({ + 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 = {}) => ({ + 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(); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts b/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts new file mode 100644 index 000000000..2fbc1470c --- /dev/null +++ b/apps/dokploy/__test__/git-provider/gitea-preview-api.test.ts @@ -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; 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; 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", + ); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx index 49a9e50b4..85064080c 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-deployments.tsx @@ -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. + {isGitea && ( + + Gitea / Forgejo: 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 Custom Events and enable{" "} + Pull Request and{" "} + Pull Request Synchronized - without the latter, + previews are created but never updated when new commits are + pushed. Enable Pull Request Label as well if + you use the preview labels filter. + + )} {isLoadingPreviewDeployments ? (
@@ -173,7 +188,11 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => { window.open(deployment.pullRequestURL, "_blank") } > - + {isGitea ? ( + + ) : ( + + )} Pull Request { 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"; diff --git a/apps/dokploy/server/utils/gitea-preview.ts b/apps/dokploy/server/utils/gitea-preview.ts new file mode 100644 index 000000000..f58767acc --- /dev/null +++ b/apps/dokploy/server/utils/gitea-preview.ts @@ -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 => { + 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" }; +}; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index cd7bef41f..ff3fcec3b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -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"; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 0f2e5a4fc..9266b840d 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -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, { diff --git a/packages/server/src/services/github.ts b/packages/server/src/services/github.ts index 07ce8cf4a..ec544934b 100644 --- a/packages/server/src/services/github.ts +++ b/packages/server/src/services/github.ts @@ -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; diff --git a/packages/server/src/services/preview-comment.ts b/packages/server/src/services/preview-comment.ts new file mode 100644 index 000000000..fbcfb7a1f --- /dev/null +++ b/packages/server/src/services/preview-comment.ts @@ -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 }; +}; diff --git a/packages/server/src/services/preview-deployment.ts b/packages/server/src/services/preview-deployment.ts index cc3d3dc02..7d6fa5a81 100644 --- a/packages/server/src/services/preview-deployment.ts +++ b/packages/server/src/services/preview-deployment.ts @@ -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]); diff --git a/packages/server/src/utils/providers/gitea.ts b/packages/server/src/utils/providers/gitea.ts index 1c6ae3a83..ab175a5de 100644 --- a/packages/server/src/utils/providers/gitea.ts +++ b/packages/server/src/utils/providers/gitea.ts @@ -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 ( + giteaId: string, + path: string, + { + method = "GET", + body, + allowedErrorStatuses = [], + }: GiteaApiRequestOptions = {}, +): Promise => { + 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 => { + 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 ?? []; +};