diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts new file mode 100644 index 000000000000..188f35c90f55 --- /dev/null +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -0,0 +1,884 @@ +import { afterEach, assert, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as GiteaApi from "../sourceControl/GiteaApi.ts"; +import * as GiteaPullRequestApi from "./GiteaPullRequestApi.ts"; + +const mockedRequest = vi.fn(); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const layer = it.layer( + GiteaPullRequestApi.layer.pipe( + Layer.provide( + Layer.succeed( + GiteaApi.GiteaApi, + GiteaApi.GiteaApi.of({ + baseUrl: Option.some("https://forge.example.test/gitea"), + request: mockedRequest, + probeAuth: Effect.die("not used"), + }), + ), + ), + ), +); + +function response(value: unknown, headers: Readonly> = {}) { + return { body: JSON.stringify(value), truncated: false, headers }; +} + +function rawPullRequest(number: number, overrides: Record = {}) { + return { + number, + title: `Pull request ${number}`, + body: "Body", + state: "open", + merged: false, + mergeable: true, + draft: false, + html_url: `https://forge.example.test/gitea/acme/web/pulls/${number}`, + created_at: "2026-09-01T10:00:00Z", + updated_at: `2026-09-02T10:${String(number % 60).padStart(2, "0")}:00Z`, + additions: 4, + deletions: 2, + changed_files: 1, + comments: 1, + review_comments: 2, + merge_base: "merge-base-sha", + user: { + id: 1, + login: "author", + full_name: "Author", + avatar_url: "https://a.test/1", + }, + base: { ref: "main", sha: "base-sha", repo: { full_name: "acme/web" } }, + head: { + ref: "feature", + sha: "head-sha", + repo: { full_name: "fork/web" }, + }, + requested_reviewers: [{ id: 2, login: "reviewer" }], + labels: [{ id: 3, name: "bug", color: "ff0000" }], + ...overrides, + }; +} + +function callAt(index: number) { + const call = mockedRequest.mock.calls[index]; + assert.isDefined(call); + return call[0]; +} + +afterEach(() => mockedRequest.mockReset()); + +layer("GiteaPullRequestApi", (it) => { + it.effect("validates the requested host before making an HTTP request", () => + Effect.gen(function* () { + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const error = yield* api + .getPullRequest({ + host: "elsewhere.test", + repository: "acme/web", + number: 7, + }) + .pipe(Effect.flip); + + assert.strictEqual(error.reason, "failed"); + expect(error.detail).toContain("does not serve elsewhere.test"); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + + it.effect("accepts an SSH port when the remote names the configured hostname", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7)))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const pullRequest = yield* api.getPullRequest({ + host: "forge.example.test:2222", + repository: "acme/web", + number: 7, + }); + + assert.strictEqual(pullRequest.number, 7); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("keeps merged and closed pull requests distinct and counts malformed rows", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response([ + { number: "broken" }, + rawPullRequest(2, { + state: "closed", + merged: true, + }), + rawPullRequest(3, { + state: "closed", + merged: false, + }), + rawPullRequest(4, { + state: "closed", + merged: false, + }), + ]), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "closed", + involvement: "all", + viewer: "reviewer", + limit: 2, + }); + + expect(page.items.map((item) => [item.number, item.state])).toEqual([ + [3, "closed"], + [4, "closed"], + ]); + assert.strictEqual(page.consumed, 4); + assert.isFalse(page.truncated); + expect(callAt(0).path).toContain("state=closed"); + expect(callAt(0).path).toContain("sort=recentupdate"); + }), + ); + + it.effect("walks later pages until involvement filtering fills the requested slice", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 50 }, (_, index) => + rawPullRequest(index + 1, { + requested_reviewers: [], + }), + ), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(response([rawPullRequest(51)]))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "Reviewer", + limit: 1, + }); + + expect(page.items.map((item) => item.number)).toEqual([51]); + assert.strictEqual(page.consumed, 51); + assert.isFalse(page.truncated); + expect(callAt(1).path).toContain("page=2"); + }), + ); + + it.effect("continues inside a fixed-size Gitea page", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response(Array.from({ length: 50 }, (_, index) => rawPullRequest(index + 1))), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reviewer", + limit: 2, + cursor: { + updatedBefore: "2026-09-02T10:10:00.000Z", + delivered: 10, + }, + }); + + expect(page.items.map((item) => item.number)).toEqual([11, 12]); + assert.strictEqual(page.consumed, 2); + assert.isTrue(page.truncated); + expect(callAt(0).path).toContain("page=1"); + }), + ); + + it.effect("rescans capped Gitea pages to apply a raw-row cursor without gaps", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 20 }, (_, index) => rawPullRequest(index + 1)), + { + link: '; rel="next"', + "x-total-count": "40", + }, + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 20 }, (_, index) => rawPullRequest(index + 21)), + { "x-total-count": "40" }, + ), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reviewer", + limit: 1, + cursor: { + updatedBefore: "2026-09-02T10:10:00.000Z", + delivered: 25, + }, + }); + + expect(page.items.map((item) => item.number)).toEqual([26]); + assert.strictEqual(page.consumed, 1); + assert.isTrue(page.truncated); + assert.strictEqual(mockedRequest.mock.calls.length, 2); + }), + ); + + it.effect("fails instead of returning an unadvanceable cursor at the page bound", () => + Effect.gen(function* () { + mockedRequest.mockImplementation(() => + Effect.succeed(response([rawPullRequest(1)], { "x-total-count": "101" })), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const error = yield* api + .listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reviewer", + limit: 1, + cursor: { + updatedBefore: "2026-09-02T10:10:00.000Z", + delivered: 100, + }, + }) + .pipe(Effect.flip); + + expect(error.detail).toContain("safe page limit"); + assert.strictEqual(mockedRequest.mock.calls.length, 100); + }), + ); + + it.effect("accepts nullable reviewer and label arrays from Gitea", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(7, { + requested_reviewers: null, + labels: null, + }), + ), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const pullRequest = yield* api.getPullRequest({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(pullRequest.reviewers).toEqual([]); + expect(pullRequest.labels).toEqual([]); + }), + ); + + it.effect("derives merge and branch-update choices from repository settings", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response({ + allow_merge_commits: true, + allow_squash_merge: false, + allow_rebase: true, + allow_merge_update: false, + allow_rebase_update: true, + permissions: { + pull: true, + push: true, + admin: false, + }, + }), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const access = yield* api.getRepositoryAccess({ + host: "forge.example.test", + repository: "acme/web", + }); + + expect(access).toEqual({ + canWrite: true, + mergeCapabilities: { + merge: true, + squash: false, + rebase: true, + }, + updateMethods: ["rebase"], + }); + }), + ); + + it.effect("does not turn omitted repository permissions into a denial", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const access = yield* api.getRepositoryAccess({ + host: "forge.example.test", + repository: "acme/web", + }); + + expect(access).toEqual({ + canWrite: true, + mergeCapabilities: { + merge: true, + squash: true, + rebase: true, + }, + updateMethods: ["merge", "rebase"], + }); + }), + ); + + it.effect("reads reviews and anchors resolved stale comments to the correct diff side", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response([ + { + id: 21, + body: "Please adjust this.", + state: "REQUEST_CHANGES", + submitted_at: "2026-09-03T11:00:00Z", + stale: true, + user: { + login: "reviewer", + full_name: "Reviewer", + }, + }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response([ + { + id: 31, + body: "Rename this.", + path: "src/old.ts", + original_position: 8, + position: 0, + created_at: "2026-09-03T11:01:00Z", + resolver: { login: "maintainer" }, + user: { login: "reviewer" }, + }, + { + id: 32, + body: "Agreed.", + path: "src/old.ts", + original_position: 8, + position: 0, + created_at: "2026-09-03T11:02:00Z", + user: { login: "author" }, + }, + ]), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const activity = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(activity.comments).toEqual([ + expect.objectContaining({ + id: "review:21", + reviewState: "request changes", + }), + expect.objectContaining({ + id: "review-comment:31", + path: "src/old.ts", + }), + expect.objectContaining({ + id: "review-comment:32", + path: "src/old.ts", + }), + ]); + expect(activity.threads).toEqual([ + expect.objectContaining({ + id: "31", + path: "src/old.ts", + line: 8, + side: "left", + isResolved: true, + isOutdated: true, + comments: [ + expect.objectContaining({ + id: "review-comment:31", + }), + expect.objectContaining({ id: "review-comment:32" }), + ], + }), + ]); + expect(callAt(1).path).toBe("/repos/acme/web/pulls/7/reviews/21/comments"); + }), + ); + + it.effect("follows pagination links when Gitea caps comment pages below the limit", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response( + [ + { + id: 1, + body: "First", + created_at: "2026-09-03T11:00:00Z", + }, + ], + { + link: '; rel="next"', + }, + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response([ + { + id: 2, + body: "Second", + created_at: "2026-09-03T11:01:00Z", + }, + ]), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const result = yield* api.listComments({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(result.comments.map((comment) => comment.id)).toEqual(["issue:1", "issue:2"]); + assert.isFalse(result.truncated); + assert.strictEqual(mockedRequest.mock.calls.length, 2); + }), + ); + + it.effect("continues past an empty filtered review page when total rows remain", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response([], { "x-total-count": "2" }))) + .mockReturnValueOnce( + Effect.succeed( + response( + [ + { + id: 22, + body: "Visible review", + state: "COMMENT", + submitted_at: "2026-09-03T12:00:00Z", + user: { login: "reviewer" }, + }, + ], + { "x-total-count": "1" }, + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(response([]))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(result.comments).toEqual([ + expect.objectContaining({ id: "review:22", body: "Visible review" }), + ]); + expect(callAt(1).path).toContain("page=2"); + assert.isFalse(result.truncated); + }), + ); + + it.effect("encodes an inline review with native old and new positions", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + yield* api.submitReview({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + verdict: "request-changes", + body: "Two comments", + comments: [ + { + body: "Old line", + path: "src/a.ts", + position: { kind: "deleted", oldLine: 4 }, + }, + { + body: "New line", + path: "src/b.ts", + position: { kind: "added", newLine: 9 }, + }, + ], + }); + + expect(decodeJson(callAt(0).body ?? "{}")).toEqual({ + event: "REQUEST_CHANGES", + body: "Two comments", + comments: [ + { body: "Old line", path: "src/a.ts", old_position: 4 }, + { body: "New line", path: "src/b.ts", new_position: 9 }, + ], + }); + }), + ); + + it.effect("reads every capped page of commit statuses and keeps the newest context", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response({ + total_count: 2, + statuses: [ + { + context: "build", + status: "pending", + updated_at: "2026-09-03T11:00:00Z", + }, + ], + }), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response({ + total_count: 2, + statuses: [ + { + context: "build", + status: "success", + updated_at: "2026-09-03T11:01:00Z", + }, + ], + }), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const checks = yield* api.listChecks({ + host: "forge.example.test", + repository: "acme/web", + sha: "head-sha", + }); + + expect(checks).toEqual([ + expect.objectContaining({ + name: "build", + status: "success", + }), + ]); + expect(callAt(1).path).toContain("page=2"); + }), + ); + + it.effect("rejects repository and file traversal before any HTTP request", () => + Effect.gen(function* () { + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const repositoryError = yield* api + .getPullRequest({ + host: "forge.example.test", + repository: "../private", + number: 7, + }) + .pipe(Effect.flip); + const fileError = yield* api + .getDiffFileContents({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + oldPath: "src/file.ts", + newPath: "../../../other/repo/contents/private.txt", + changeType: "change", + }) + .pipe(Effect.flip); + + expect(repositoryError.detail).toContain("owner/name"); + expect(fileError.detail).toContain("without traversal"); + assert.strictEqual(mockedRequest.mock.calls.length, 0); + }), + ); + + it.effect("loads full files from the exact base and head revisions", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((request) => { + if (request.path.endsWith("/pulls/7")) { + return Effect.succeed(response(rawPullRequest(7))); + } + if (request.path.includes("ref=merge-base-sha")) { + return Effect.succeed( + response({ + type: "file", + encoding: "base64", + content: "YmVmb3JlXG4=", + }), + ); + } + return Effect.succeed( + response({ + type: "file", + encoding: "base64", + content: "YWZ0ZXJcblxu", + }), + ); + }); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const files = yield* api.getDiffFileContents({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + oldPath: "src/old name.ts", + newPath: "src/new name.ts", + changeType: "rename-changed", + }); + + expect(files).toEqual({ + oldContents: "before\\n", + newContents: "after\\n\\n", + }); + expect(mockedRequest.mock.calls.map(([request]) => request.path)).toEqual( + expect.arrayContaining([ + "/repos/acme/web/contents/src/old%20name.ts?ref=merge-base-sha", + "/repos/acme/web/contents/src/new%20name.ts?ref=head-sha", + ]), + ); + }), + ); + + it.effect("expands a commit diff from that commit and its first parent", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((request) => { + if (request.path.endsWith("/pulls/7")) { + return Effect.succeed(response(rawPullRequest(7))); + } + if (request.path.includes("/git/commits/commit-sha")) { + return Effect.succeed( + response({ + sha: "commit-sha", + parents: [{ sha: "parent-sha" }], + }), + ); + } + if (request.path.includes("ref=parent-sha")) { + return Effect.succeed( + response({ + type: "file", + encoding: "base64", + content: "b2xk", + }), + ); + } + return Effect.succeed( + response({ + type: "file", + encoding: "base64", + content: "bmV3", + }), + ); + }); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const files = yield* api.getDiffFileContents({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + commit: "commit-sha", + oldPath: "src/file.ts", + newPath: "src/file.ts", + changeType: "change", + }); + + expect(files).toEqual({ + oldContents: "old", + newContents: "new", + }); + expect(mockedRequest.mock.calls.map(([request]) => request.path)).toEqual( + expect.arrayContaining([ + "/repos/acme/web/contents/src/file.ts?ref=parent-sha", + "/repos/acme/web/contents/src/file.ts?ref=commit-sha", + ]), + ); + }), + ); + + it.effect("does not fall back to a mutable branch when a required revision is absent", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed(response(rawPullRequest(7, { merge_base: "" }))), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const error = yield* api + .getDiffFileContents({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + oldPath: "src/file.ts", + newPath: "src/file.ts", + changeType: "change", + }) + .pipe(Effect.flip); + + expect(error.detail).toContain("immutable revision before"); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); + + it.effect("rejects a blank commit revision before reading file contents", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7)))) + .mockReturnValueOnce( + Effect.succeed( + response({ + sha: " ", + parents: [{ sha: "parent-sha" }], + }), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const error = yield* api + .getDiffFileContents({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + commit: "commit-sha", + oldPath: "src/file.ts", + newPath: "src/file.ts", + changeType: "change", + }) + .pipe(Effect.flip); + + expect(error.detail).toContain("immutable revision after"); + assert.strictEqual(mockedRequest.mock.calls.length, 2); + }), + ); + + it.effect("posts a general pull request comment to its issue conversation", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + yield* api.comment({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + body: "Looks good overall.", + }); + + expect(callAt(0)).toMatchObject({ + method: "POST", + path: "/repos/acme/web/issues/7/comments", + }); + expect(decodeJson(callAt(0).body ?? "{}")).toEqual({ + body: "Looks good overall.", + }); + }), + ); + + it.effect("preserves existing labels when adding another", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7)))) + .mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + yield* api.setLabels({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + labels: ["ready"], + applied: true, + }); + + expect(callAt(1)).toMatchObject({ + method: "PUT", + path: "/repos/acme/web/issues/7/labels", + }); + expect(decodeJson(callAt(1).body ?? "{}")).toEqual({ + labels: ["bug", "ready"], + }); + }), + ); + + it.effect("protects a merge with the freshly read head commit", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7)))) + .mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + yield* api.runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "merge", + mergeMethod: "squash", + }); + + expect(callAt(1)).toMatchObject({ + method: "POST", + path: "/repos/acme/web/pulls/7/merge", + }); + expect(decodeJson(callAt(1).body ?? "{}")).toEqual({ + do: "squash", + head_commit_id: "head-sha", + }); + }), + ); + + it.effect("uses Gitea's native update style and refuses unverified draft transitions", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response({}))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + yield* api.runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "update-branch", + updateMethod: "rebase", + }); + const error = yield* api + .runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "draft", + }) + .pipe(Effect.flip); + + expect(callAt(0).path).toBe("/repos/acme/web/pulls/7/update?style=rebase"); + expect(error.detail).toContain("does not expose a reliable draft operation"); + assert.strictEqual(mockedRequest.mock.calls.length, 1); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts new file mode 100644 index 000000000000..a560ac41f1d9 --- /dev/null +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -0,0 +1,1572 @@ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type { + PullRequestAction, + PullRequestActor, + PullRequestCheck, + PullRequestComment, + PullRequestCommit, + PullRequestInvolvement, + PullRequestLabel, + PullRequestLabelCandidateList, + PullRequestListState, + PullRequestMergeCapabilities, + PullRequestMergeMethod, + PullRequestMergeability, + PullRequestReviewCommentDraft, + PullRequestReviewThread, + PullRequestReviewVerdict, + PullRequestReviewerCandidateList, + PullRequestUpdateMethod, +} from "@t3tools/contracts"; + +import * as GiteaApi from "../sourceControl/GiteaApi.ts"; +import type { ProviderListCursor } from "./PullRequestProvider.ts"; +import { dedupeChecks } from "./pullRequestChecks.ts"; + +const PAGE_SIZE = 50; +const CONVERSATION_PAGES = 4; +const MAX_PAGINATION_PAGES = 100; +const DIFF_MAX_BYTES = 8 * 1024 * 1024; + +const RawUser = Schema.Struct({ + id: Schema.optional(Schema.Int), + login: Schema.optional(Schema.String), + full_name: Schema.optional(Schema.NullOr(Schema.String)), + avatar_url: Schema.optional(Schema.NullOr(Schema.String)), +}); +const RawRepository = Schema.Struct({ + full_name: Schema.optional(Schema.String), + allow_merge_commits: Schema.optional(Schema.Boolean), + allow_squash_merge: Schema.optional(Schema.Boolean), + allow_rebase: Schema.optional(Schema.Boolean), + allow_merge_update: Schema.optional(Schema.Boolean), + allow_rebase_update: Schema.optional(Schema.Boolean), + permissions: Schema.optional( + Schema.NullOr( + Schema.Struct({ + admin: Schema.optional(Schema.Boolean), + push: Schema.optional(Schema.Boolean), + pull: Schema.optional(Schema.Boolean), + }), + ), + ), +}); +const RawBranch = Schema.Struct({ + ref: Schema.optional(Schema.String), + sha: Schema.optional(Schema.String), + repo: Schema.optional(Schema.NullOr(RawRepository)), +}); +const RawLabel = Schema.Struct({ + id: Schema.optional(Schema.Int), + name: Schema.optional(Schema.String), + color: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), +}); +const RawPullRequest = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + body: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.String, + merged: Schema.optional(Schema.Boolean), + mergeable: Schema.optional(Schema.NullOr(Schema.Boolean)), + draft: Schema.optional(Schema.Boolean), + html_url: Schema.String, + created_at: Schema.String, + updated_at: Schema.String, + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + additions: Schema.optional(Schema.Int), + deletions: Schema.optional(Schema.Int), + changed_files: Schema.optional(Schema.Int), + comments: Schema.optional(Schema.Int), + review_comments: Schema.optional(Schema.Int), + user: Schema.optional(Schema.NullOr(RawUser)), + base: RawBranch, + head: RawBranch, + requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUser))), + labels: Schema.optional(Schema.NullOr(Schema.Array(RawLabel))), + merge_base: Schema.optional(Schema.String), +}); +const RawComment = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + created_at: Schema.String, + html_url: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional(Schema.NullOr(RawUser)), +}); +const RawReview = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.String), + submitted_at: Schema.optional(Schema.NullOr(Schema.String)), + updated_at: Schema.optional(Schema.NullOr(Schema.String)), + html_url: Schema.optional(Schema.NullOr(Schema.String)), + stale: Schema.optional(Schema.Boolean), + dismissed: Schema.optional(Schema.Boolean), + user: Schema.optional(Schema.NullOr(RawUser)), +}); +const RawReviewComment = Schema.Struct({ + id: Schema.Int, + body: Schema.optional(Schema.NullOr(Schema.String)), + path: Schema.String, + position: Schema.optional(Schema.Int), + original_position: Schema.optional(Schema.Int), + created_at: Schema.String, + updated_at: Schema.optional(Schema.String), + html_url: Schema.optional(Schema.NullOr(Schema.String)), + resolver: Schema.optional(Schema.NullOr(RawUser)), + user: Schema.optional(Schema.NullOr(RawUser)), +}); +const RawCommit = Schema.Struct({ + sha: Schema.String, + parents: Schema.optional( + Schema.Array( + Schema.Struct({ + sha: Schema.optional(Schema.String), + }), + ), + ), + created: Schema.optional(Schema.String), + author: Schema.optional(Schema.NullOr(RawUser)), + committer: Schema.optional(Schema.NullOr(RawUser)), + commit: Schema.optional( + Schema.Struct({ + message: Schema.optional(Schema.String), + author: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + date: Schema.optional(Schema.String), + }), + ), + committer: Schema.optional( + Schema.Struct({ + name: Schema.optional(Schema.String), + date: Schema.optional(Schema.String), + }), + ), + }), + ), +}); +const RawCombinedStatus = Schema.Struct({ + statuses: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + context: Schema.optional(Schema.String), + description: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.String), + target_url: Schema.optional(Schema.NullOr(Schema.String)), + updated_at: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + ), + total_count: Schema.optional(Schema.Int), +}); +const RawContents = Schema.Struct({ + content: Schema.optional(Schema.NullOr(Schema.String)), + encoding: Schema.optional(Schema.NullOr(Schema.String)), + type: Schema.optional(Schema.String), +}); + +type RawPullRequest = typeof RawPullRequest.Type; +type RawReviewComment = typeof RawReviewComment.Type; +type RawCommitStatus = NonNullable<(typeof RawCombinedStatus.Type)["statuses"]>[number]; + +const decodeRow = Schema.decodeUnknownOption(RawPullRequest); +const decodeUser = Schema.decodeUnknownOption(RawUser); +const decodeComment = Schema.decodeUnknownOption(RawComment); +const decodeReview = Schema.decodeUnknownOption(RawReview); +const decodeReviewComment = Schema.decodeUnknownOption(RawReviewComment); +const decodeCommit = Schema.decodeUnknownOption(RawCommit); +const decodeLabel = Schema.decodeUnknownOption(RawLabel); +const encodeObject = Schema.encodeSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + +export interface GiteaPullRequest { + readonly number: number; + readonly title: string; + readonly body: string; + readonly url: string; + readonly author: PullRequestActor | null; + readonly headBranch: string; + readonly headSha: string; + readonly headRepositoryNameWithOwner: string | null; + readonly baseBranch: string; + readonly baseSha: string; + readonly mergeBaseSha: string; + readonly state: "open" | "closed" | "merged"; + readonly isDraft: boolean; + readonly mergeability: PullRequestMergeability; + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; + readonly createdAt: string; + readonly updatedAt: string; + readonly mergedAt: string | null; + readonly closedAt: string | null; + readonly reviewRequestLogins: ReadonlyArray; + readonly reviewers: ReadonlyArray; + readonly labels: ReadonlyArray; + readonly commentCount: number; +} + +export interface GiteaRepositoryAccess { + readonly canWrite: boolean; + readonly mergeCapabilities: PullRequestMergeCapabilities; + readonly updateMethods: ReadonlyArray; +} + +export class GiteaPullRequestApiError extends Schema.TaggedErrorClass()( + "GiteaPullRequestApiError", + { + operation: Schema.String, + reason: Schema.Literals(["unconfigured", "unauthenticated", "rate-limited", "failed"]), + detail: Schema.String, + retryAt: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Gitea failed in ${this.operation}: ${this.detail}`; + } +} + +function iso(value: string | null | undefined): string | null { + if (value === null || value === undefined || value.trim() === "") return null; + return Option.match(DateTime.make(value), { + onNone: () => null, + onSome: DateTime.formatIso, + }); +} + +function actor(value: typeof RawUser.Type | null | undefined): PullRequestActor | null { + const login = value?.login?.trim(); + if (!login) return null; + return { + login, + name: value?.full_name?.trim() || null, + avatarUrl: value?.avatar_url?.trim() || null, + }; +} + +function pullRequest(value: RawPullRequest): GiteaPullRequest | null { + const title = value.title.trim(); + const headBranch = value.head.ref?.trim(); + const baseBranch = value.base.ref?.trim(); + const createdAt = iso(value.created_at); + const updatedAt = iso(value.updated_at); + if (value.number < 1 || !title || !headBranch || !baseBranch || !createdAt || !updatedAt) + return null; + const reviewers = (value.requested_reviewers ?? []).flatMap((user) => { + const mapped = actor(user); + return mapped === null ? [] : [mapped]; + }); + return { + number: value.number, + title, + body: value.body ?? "", + url: value.html_url, + author: actor(value.user), + headBranch, + headSha: value.head.sha?.trim() ?? "", + headRepositoryNameWithOwner: value.head.repo?.full_name?.trim() || null, + baseBranch, + baseSha: value.base.sha?.trim() ?? "", + mergeBaseSha: value.merge_base?.trim() ?? "", + state: value.merged === true ? "merged" : value.state === "closed" ? "closed" : "open", + isDraft: value.draft ?? false, + mergeability: + value.mergeable === true + ? "mergeable" + : value.mergeable === false + ? "conflicting" + : "unknown", + additions: Math.max(0, value.additions ?? 0), + deletions: Math.max(0, value.deletions ?? 0), + changedFiles: Math.max(0, value.changed_files ?? 0), + createdAt, + updatedAt, + mergedAt: iso(value.merged_at), + closedAt: iso(value.closed_at), + reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), + reviewers, + labels: (value.labels ?? []).flatMap((label) => { + const name = label.name?.trim(); + return name ? [{ name, color: label.color?.trim() || null }] : []; + }), + commentCount: Math.max(0, value.comments ?? 0) + Math.max(0, value.review_comments ?? 0), + }; +} + +function query( + path: string, + params: Readonly>, +): string { + const search = new URLSearchParams(); + for (const [name, value] of Object.entries(params)) + if (value !== undefined) search.set(name, String(value)); + const suffix = search.toString(); + return suffix === "" ? path : `${path}?${suffix}`; +} + +function repositoryPath(repository: string): string | null { + const parts = repository.trim().split("/"); + if ( + parts.length !== 2 || + parts.some( + (part) => + part.trim() === "" || + part === "." || + part === ".." || + part.includes("\\") || + part.includes("\0"), + ) + ) { + return null; + } + return `/repos/${parts.map(encodeURIComponent).join("/")}`; +} + +function encodedFilePath(path: string): string | null { + const parts = path.split("/"); + if ( + parts.length === 0 || + parts.some( + (part) => + part === "" || part === "." || part === ".." || part.includes("\\") || part.includes("\0"), + ) + ) { + return null; + } + return parts.map(encodeURIComponent).join("/"); +} + +interface UnknownPage { + readonly rows: ReadonlyArray; + readonly headers: Readonly>; +} + +function headerValue(headers: Readonly>, name: string): string | undefined { + const wanted = name.toLowerCase(); + return Object.entries(headers).find(([key]) => key.toLowerCase() === wanted)?.[1]; +} + +function totalCount(headers: Readonly>): number | null { + const value = headerValue(headers, "x-total-count"); + if (value === undefined) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; +} + +function nextLink(headers: Readonly>): string | null { + const link = headerValue(headers, "link"); + if (link === undefined) return null; + for (const part of link.split(",")) { + const match = /<([^>]+)>\s*;(?:[^,;]+;)*\s*rel\s*=\s*"?next"?/iu.exec(part); + if (match?.[1]) return match[1]; + } + return null; +} + +function pathAtPage(path: string, page: number): string { + const dummyOrigin = "https://gitea-pagination.invalid"; + const url = new URL(path, dummyOrigin); + url.searchParams.set("page", String(page)); + return url.origin === dummyOrigin ? `${url.pathname}${url.search}` : url.toString(); +} + +function nextPagePath(input: { + readonly path: string; + readonly page: number; + readonly pageRows: number; + readonly rowsSeen: number; + readonly headers: Readonly>; + readonly bodyTotalCount?: number; +}): string | null { + const linked = nextLink(input.headers); + if (linked !== null) return linked; + const total = input.bodyTotalCount ?? totalCount(input.headers); + if (total !== null && total !== undefined) { + return input.rowsSeen < total ? pathAtPage(input.path, input.page + 1) : null; + } + return input.pageRows >= PAGE_SIZE ? pathAtPage(input.path, input.page + 1) : null; +} + +export class GiteaPullRequestApi extends Context.Service< + GiteaPullRequestApi, + { + readonly getViewer: () => Effect.Effect; + readonly listPullRequests: (input: { + readonly host: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly cursor?: ProviderListCursor; + }) => Effect.Effect< + { + items: ReadonlyArray; + truncated: boolean; + consumed: number; + }, + GiteaPullRequestApiError + >; + readonly getPullRequest: (input: { + host: string; + repository: string; + number: number; + }) => Effect.Effect; + readonly getRepositoryAccess: (input: { + host: string; + repository: string; + }) => Effect.Effect; + readonly listComments: (input: { + host: string; + repository: string; + number: number; + }) => Effect.Effect< + { comments: ReadonlyArray; truncated: boolean }, + GiteaPullRequestApiError + >; + readonly listReviews: (input: { + host: string; + repository: string; + number: number; + }) => Effect.Effect< + { + comments: ReadonlyArray; + threads: ReadonlyArray; + truncated: boolean; + }, + GiteaPullRequestApiError + >; + readonly listCommits: (input: { + host: string; + repository: string; + number: number; + }) => Effect.Effect, GiteaPullRequestApiError>; + readonly listChecks: (input: { + host: string; + repository: string; + sha: string; + }) => Effect.Effect, GiteaPullRequestApiError>; + readonly getDiff: (input: { + host: string; + repository: string; + number: number; + commit?: string; + }) => Effect.Effect<{ patch: string; truncated: boolean }, GiteaPullRequestApiError>; + readonly getDiffFileContents: (input: { + host: string; + repository: string; + number: number; + commit?: string; + oldPath: string; + newPath: string; + changeType: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + }) => Effect.Effect<{ oldContents: string; newContents: string }, GiteaPullRequestApiError>; + readonly runAction: (input: { + host: string; + repository: string; + number: number; + action: PullRequestAction; + mergeMethod?: PullRequestMergeMethod; + updateMethod?: PullRequestUpdateMethod; + }) => Effect.Effect; + readonly updatePullRequest: (input: { + host: string; + repository: string; + number: number; + title?: string; + body?: string; + }) => Effect.Effect; + readonly comment: (input: { + host: string; + repository: string; + number: number; + body: string; + }) => Effect.Effect; + readonly updateComment: (input: { + host: string; + repository: string; + commentId: string; + body: string; + }) => Effect.Effect; + readonly submitReview: (input: { + host: string; + repository: string; + number: number; + verdict: PullRequestReviewVerdict; + body: string; + comments: ReadonlyArray; + }) => Effect.Effect; + readonly listReviewerCandidates: (input: { + host: string; + repository: string; + number: number; + }) => Effect.Effect; + readonly setReviewerRequest: (input: { + host: string; + repository: string; + number: number; + reviewers: ReadonlyArray<{ id: string; kind: "user" | "team" }>; + requested: boolean; + }) => Effect.Effect; + readonly listLabelCandidates: (input: { + host: string; + repository: string; + number: number; + }) => Effect.Effect; + readonly setLabels: (input: { + host: string; + repository: string; + number: number; + labels: ReadonlyArray; + applied: boolean; + }) => Effect.Effect; + readonly replyToThread: (input: { + host: string; + repository: string; + number: number; + threadId: string; + body: string; + }) => Effect.Effect; + readonly setThreadResolution: (input: { + host: string; + repository: string; + threadId: string; + resolved: boolean; + }) => Effect.Effect; + readonly setReaction: (input: { + host: string; + repository: string; + number: number; + subjectId?: string; + content: string; + reacted: boolean; + }) => Effect.Effect; + } +>()("t3/pullRequest/GiteaPullRequestApi") {} + +export const make = Effect.gen(function* () { + const gitea = yield* GiteaApi.GiteaApi; + + const failure = (operation: string, error: GiteaApi.GiteaApiError) => + new GiteaPullRequestApiError({ + operation, + reason: error.reason, + detail: error.detail, + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + cause: error, + }); + + const validateHost = Effect.fn("GiteaPullRequestApi.validateHost")(function* (host: string) { + const configured = Option.getOrUndefined(gitea.baseUrl); + if (configured === undefined) { + return yield* new GiteaPullRequestApiError({ + operation: "validateHost", + reason: "unconfigured", + detail: GiteaApi.GITEA_SETUP_HINT, + }); + } + const expected = new URL(configured).hostname.toLowerCase(); + const actual = yield* Effect.try({ + try: () => new URL(`https://${host.trim()}`).hostname.toLowerCase(), + catch: () => + new GiteaPullRequestApiError({ + operation: "validateHost", + reason: "failed", + detail: "The pull request host is invalid.", + }), + }); + if (actual !== expected) { + return yield* new GiteaPullRequestApiError({ + operation: "validateHost", + reason: "failed", + detail: `The configured Gitea server does not serve ${host}.`, + }); + } + }); + + const request = Effect.fn("GiteaPullRequestApi.request")(function* (input: { + operation: string; + host: string; + repository?: string; + method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; + path: string; + body?: string; + maxBytes?: number; + }) { + yield* validateHost(input.host); + if (input.repository !== undefined && repositoryPath(input.repository) === null) { + return yield* new GiteaPullRequestApiError({ + operation: input.operation, + reason: "failed", + detail: "Gitea repositories must be written as owner/name.", + }); + } + return yield* gitea + .request(input) + .pipe(Effect.mapError((error) => failure(input.operation, error))); + }); + + const decode = ( + operation: string, + schema: S, + response: GiteaApi.GiteaResponse, + ) => + GiteaApi.decodeGiteaResponse(operation, schema, response).pipe( + Effect.mapError((error) => failure(operation, error)), + ); + + const basePath = (repository: string) => repositoryPath(repository)!; + + const getPullRequest = Effect.fn("GiteaPullRequestApi.getPullRequest")(function* (input: { + host: string; + repository: string; + number: number; + }) { + const operation = "getPullRequest"; + const response = yield* request({ + operation, + host: input.host, + repository: input.repository, + method: "GET", + path: `${basePath(input.repository)}/pulls/${input.number}`, + }); + const raw = yield* decode(operation, RawPullRequest, response); + const mapped = pullRequest(raw); + if (mapped === null) + return yield* new GiteaPullRequestApiError({ + operation, + reason: "failed", + detail: "Gitea returned an incomplete pull request.", + }); + return mapped; + }); + + const readUnknownPage = Effect.fn("GiteaPullRequestApi.readUnknownPage")(function* (input: { + operation: string; + host: string; + repository: string; + path: string; + }) { + const response = yield* request({ ...input, method: "GET" }); + const rows = yield* decode(input.operation, Schema.Array(Schema.Unknown), response); + return { rows, headers: response.headers } satisfies UnknownPage; + }); + + const readUnknownArray = Effect.fn("GiteaPullRequestApi.readUnknownArray")( + (input: { operation: string; host: string; repository: string; path: string }) => + readUnknownPage(input).pipe(Effect.map((page) => page.rows)), + ); + + const readUnknownSlice = Effect.fn("GiteaPullRequestApi.readUnknownSlice")(function* (input: { + operation: string; + host: string; + repository: string; + path: string; + limit: number; + }) { + const rows: Array = []; + let path = input.path; + let rowsSeen = 0; + for (let page = 1; page <= MAX_PAGINATION_PAGES; page += 1) { + const result = yield* readUnknownPage({ + operation: input.operation, + host: input.host, + repository: input.repository, + path, + }); + rowsSeen += result.rows.length; + const remaining = Math.max(0, input.limit - rows.length); + rows.push(...result.rows.slice(0, remaining)); + const next = nextPagePath({ + path, + page, + pageRows: result.rows.length, + rowsSeen, + headers: result.headers, + }); + if (result.rows.length > remaining || rows.length >= input.limit) { + return { + rows, + truncated: result.rows.length > remaining || next !== null, + }; + } + if (next === null) return { rows, truncated: false }; + path = next; + } + return { rows, truncated: true }; + }); + + const listPullRequests: GiteaPullRequestApi["Service"]["listPullRequests"] = Effect.fn( + "GiteaPullRequestApi.listPullRequests", + )(function* (input) { + const wanted = Math.max(1, input.limit); + const delivered = input.cursor?.delivered ?? 0; + const endpointState = + input.state === "open" ? "open" : input.state === "all" ? "all" : "closed"; + let page = 1; + let path = query(`${basePath(input.repository)}/pulls`, { + state: endpointState, + sort: "recentupdate", + page, + limit: PAGE_SIZE, + ...(input.involvement === "authored" ? { poster: input.viewer } : {}), + }); + let rowsSeen = 0; + let rowsSkipped = 0; + let consumed = 0; + const collected: Array = []; + while (page <= MAX_PAGINATION_PAGES) { + const result = yield* readUnknownPage({ + operation: "listPullRequests", + host: input.host, + repository: input.repository, + path, + }); + rowsSeen += result.rows.length; + const toSkip = Math.min(Math.max(0, delivered - rowsSkipped), result.rows.length); + rowsSkipped += toSkip; + const pageRows = result.rows.slice(toSkip); + const next = nextPagePath({ + path, + page, + pageRows: result.rows.length, + rowsSeen, + headers: result.headers, + }); + for (const [index, row] of pageRows.entries()) { + consumed += 1; + const decoded = decodeRow(row); + if (Option.isNone(decoded)) continue; + const pr = pullRequest(decoded.value); + if (pr === null) continue; + if (input.state !== "all" && pr.state !== input.state) continue; + if ( + input.involvement === "authored" && + pr.author?.login.toLowerCase() !== input.viewer.toLowerCase() + ) + continue; + if ( + input.involvement === "reviewing" && + !pr.reviewRequestLogins.some( + (login) => login.toLowerCase() === input.viewer.toLowerCase(), + ) + ) + continue; + collected.push(pr); + if (collected.length === wanted) { + if (page === MAX_PAGINATION_PAGES && next !== null) { + return yield* new GiteaPullRequestApiError({ + operation: "listPullRequests", + reason: "failed", + detail: "Gitea pull request pagination exceeded the safe page limit.", + }); + } + return { + items: collected, + truncated: index < pageRows.length - 1 || next !== null, + consumed, + }; + } + } + if (next === null) break; + path = next; + page += 1; + } + if (page > MAX_PAGINATION_PAGES) { + return yield* new GiteaPullRequestApiError({ + operation: "listPullRequests", + reason: "failed", + detail: "Gitea pull request pagination exceeded the safe page limit.", + }); + } + return { + items: collected, + truncated: false, + consumed, + }; + }); + + const getRepositoryAccess = Effect.fn("GiteaPullRequestApi.getRepositoryAccess")( + function* (input: { host: string; repository: string }) { + const operation = "getRepositoryAccess"; + const response = yield* request({ + operation, + ...input, + method: "GET", + path: basePath(input.repository), + }); + const repo = yield* decode(operation, RawRepository, response); + return { + // An omitted permission block is unknown rather than a denial. Gitea will still enforce + // the write, while hiding it here would leave an entitled viewer with no route to try. + canWrite: + repo.permissions == null || + repo.permissions.push === true || + repo.permissions.admin === true, + mergeCapabilities: { + merge: repo.allow_merge_commits ?? true, + squash: repo.allow_squash_merge ?? true, + rebase: repo.allow_rebase ?? true, + }, + updateMethods: [ + ...(repo.allow_merge_update !== false ? (["merge"] as const) : []), + ...(repo.allow_rebase_update !== false ? (["rebase"] as const) : []), + ], + }; + }, + ); + + const listComments = Effect.fn("GiteaPullRequestApi.listComments")(function* (input: { + host: string; + repository: string; + number: number; + }) { + const all: Array = []; + let path = query(`${basePath(input.repository)}/issues/${input.number}/comments`, { + page: 1, + limit: PAGE_SIZE, + }); + let rowsSeen = 0; + for (let page = 1; page <= CONVERSATION_PAGES; page += 1) { + const result = yield* readUnknownPage({ + operation: "listComments", + ...input, + path, + }); + rowsSeen += result.rows.length; + for (const row of result.rows) { + const decoded = decodeComment(row); + if (Option.isNone(decoded)) continue; + const body = decoded.value.body ?? ""; + const createdAt = iso(decoded.value.created_at); + if (!createdAt || !body.trim()) continue; + all.push({ + id: `issue:${decoded.value.id}`, + kind: "issue-comment", + author: actor(decoded.value.user), + body, + createdAt, + url: decoded.value.html_url ?? null, + path: null, + reviewState: null, + }); + } + const next = nextPagePath({ + path, + page, + pageRows: result.rows.length, + rowsSeen, + headers: result.headers, + }); + if (next === null) return { comments: all, truncated: false }; + if (page === CONVERSATION_PAGES) return { comments: all, truncated: true }; + path = next; + } + return { comments: all, truncated: false }; + }); + + const listReviews = Effect.fn("GiteaPullRequestApi.listReviews")(function* (input: { + host: string; + repository: string; + number: number; + }) { + const reviewRows: Array = []; + let path = query(`${basePath(input.repository)}/pulls/${input.number}/reviews`, { + page: 1, + limit: PAGE_SIZE, + }); + let rowsSeen = 0; + let reviewsTruncated = false; + for (let page = 1; page <= CONVERSATION_PAGES; page += 1) { + const result = yield* readUnknownPage({ + operation: "listReviews", + ...input, + path, + }); + reviewRows.push(...result.rows); + rowsSeen += result.rows.length; + const next = nextPagePath({ + path, + page, + pageRows: result.rows.length, + rowsSeen, + headers: result.headers, + }); + if (next === null) break; + if (page === CONVERSATION_PAGES) { + reviewsTruncated = true; + break; + } + path = next; + } + const comments: Array = []; + const threads: Array = []; + const commentsTruncated = reviewsTruncated; + for (const row of reviewRows) { + const review = decodeReview(row); + if (Option.isNone(review)) continue; + const reviewAt = iso(review.value.submitted_at ?? review.value.updated_at); + if (reviewAt && (review.value.body ?? "").trim()) { + comments.push({ + id: `review:${review.value.id}`, + kind: "review", + author: actor(review.value.user), + body: review.value.body ?? "", + createdAt: reviewAt, + url: review.value.html_url ?? null, + path: null, + reviewState: review.value.state?.toLowerCase().replaceAll("_", " ") ?? null, + }); + } + const codeRows = yield* readUnknownArray({ + operation: "listReviewComments", + ...input, + path: `${basePath(input.repository)}/pulls/${input.number}/reviews/${review.value.id}/comments`, + }); + const grouped = new Map< + string, + Array<{ + readonly rawId: number; + readonly path: string; + readonly line: number | null; + readonly side: "left" | "right"; + readonly resolved: boolean; + readonly comment: PullRequestReviewThread["comments"][number]; + }> + >(); + for (const codeRow of codeRows) { + const decoded = decodeReviewComment(codeRow); + if (Option.isNone(decoded)) continue; + const mapped = decoded.value; + const createdAt = iso(mapped.created_at); + if (!createdAt || !(mapped.body ?? "").trim()) continue; + comments.push({ + id: `review-comment:${mapped.id}`, + kind: "review-comment", + author: actor(mapped.user), + body: mapped.body ?? "", + createdAt, + url: mapped.html_url ?? null, + path: mapped.path, + reviewState: null, + }); + const line = + mapped.position && mapped.position > 0 + ? mapped.position + : mapped.original_position && mapped.original_position > 0 + ? mapped.original_position + : null; + const side = mapped.position && mapped.position > 0 ? "right" : "left"; + const key = `${review.value.id}\0${mapped.path}\0${side}:${line ?? 0}`; + const entries = grouped.get(key) ?? []; + entries.push({ + rawId: mapped.id, + path: mapped.path, + line, + side, + resolved: mapped.resolver != null, + comment: { + id: `review-comment:${mapped.id}`, + author: actor(mapped.user), + body: mapped.body ?? "", + createdAt, + url: mapped.html_url ?? null, + }, + }); + grouped.set(key, entries); + } + for (const entries of grouped.values()) { + entries.sort( + (left, right) => + left.comment.createdAt.localeCompare(right.comment.createdAt) || + left.rawId - right.rawId, + ); + const first = entries[0]; + if (first === undefined) continue; + threads.push({ + id: String(first.rawId), + path: first.path, + line: first.line, + side: first.side, + isResolved: entries.some((entry) => entry.resolved), + isOutdated: review.value.stale ?? false, + comments: entries.map((entry) => entry.comment), + }); + } + } + return { comments, threads, truncated: commentsTruncated }; + }); + + const listCommits = Effect.fn("GiteaPullRequestApi.listCommits")(function* (input: { + host: string; + repository: string; + number: number; + }) { + const rows: Array = []; + let path = query(`${basePath(input.repository)}/pulls/${input.number}/commits`, { + page: 1, + limit: PAGE_SIZE, + }); + let rowsSeen = 0; + for (let page = 1; page <= MAX_PAGINATION_PAGES; page += 1) { + const result = yield* readUnknownPage({ + operation: "listCommits", + ...input, + path, + }); + rows.push(...result.rows); + rowsSeen += result.rows.length; + const next = nextPagePath({ + path, + page, + pageRows: result.rows.length, + rowsSeen, + headers: result.headers, + }); + if (next === null) break; + path = next; + } + return rows.flatMap((row): ReadonlyArray => { + const decoded = decodeCommit(row); + if (Option.isNone(decoded) || !decoded.value.sha.trim()) return []; + const value = decoded.value; + const committedDate = iso( + value.commit?.committer?.date ?? value.commit?.author?.date ?? value.created, + ); + if (!committedDate) return []; + const author = actor(value.author) ?? actor(value.committer); + return [ + { + oid: value.sha.trim(), + messageHeadline: value.commit?.message?.split("\n", 1)[0] ?? "", + committedDate, + ...(author === null ? {} : { authors: [author] }), + }, + ]; + }); + }); + + const listChecks = Effect.fn("GiteaPullRequestApi.listChecks")(function* (input: { + host: string; + repository: string; + sha: string; + }) { + const operation = "listChecks"; + const statuses: Array = []; + let path = query( + `${basePath(input.repository)}/commits/${encodeURIComponent(input.sha)}/status`, + { page: 1, limit: PAGE_SIZE }, + ); + let rowsSeen = 0; + for (let page = 1; page <= MAX_PAGINATION_PAGES; page += 1) { + const response = yield* request({ + operation, + host: input.host, + repository: input.repository, + method: "GET", + path, + }); + const combined = yield* decode(operation, RawCombinedStatus, response); + const pageStatuses = combined.statuses ?? []; + statuses.push(...pageStatuses); + rowsSeen += pageStatuses.length; + const next = nextPagePath({ + path, + page, + pageRows: pageStatuses.length, + rowsSeen, + headers: response.headers, + ...(combined.total_count === undefined ? {} : { bodyTotalCount: combined.total_count }), + }); + if (next === null) break; + path = next; + } + return dedupeChecks( + statuses.flatMap((status) => { + const name = status.context?.trim(); + if (!name) return []; + const state = status.status; + return [ + { + workflowName: null, + at: iso(status.updated_at), + check: { + name, + status: + state === "success" + ? "success" + : state === "pending" + ? "pending" + : state === "failure" || state === "error" + ? "failure" + : state === "skipped" + ? "skipped" + : "neutral", + description: status.description?.trim() || null, + url: status.target_url?.trim() || null, + }, + }, + ]; + }), + ); + }); + + const fileContents = Effect.fn("GiteaPullRequestApi.fileContents")(function* (input: { + host: string; + repository: string; + path: string; + ref: string; + }) { + const operation = "getDiffFileContents"; + const path = encodedFilePath(input.path); + if (path === null) { + return yield* new GiteaPullRequestApiError({ + operation, + reason: "failed", + detail: "Gitea file paths must be repository-relative paths without traversal segments.", + }); + } + const response = yield* request({ + operation, + host: input.host, + repository: input.repository, + method: "GET", + path: query(`${basePath(input.repository)}/contents/${path}`, { + ref: input.ref, + }), + }); + const contents = yield* decode(operation, RawContents, response); + if (contents.type !== "file" || contents.encoding !== "base64" || contents.content == null) + return yield* new GiteaPullRequestApiError({ + operation, + reason: "failed", + detail: "Gitea did not return base64 file contents.", + }); + return Buffer.from(contents.content.replaceAll("\n", ""), "base64").toString("utf8"); + }); + + const write = (input: { + operation: string; + host: string; + repository: string; + method: "POST" | "PATCH" | "PUT" | "DELETE"; + path: string; + body?: Readonly>; + }) => + request({ + operation: input.operation, + host: input.host, + repository: input.repository, + method: input.method, + path: input.path, + ...(input.body === undefined ? {} : { body: encodeObject(input.body) }), + }).pipe(Effect.asVoid); + + const unsupportedAction = (action: string) => + new GiteaPullRequestApiError({ + operation: "runAction", + reason: "failed", + detail: `Gitea does not expose a reliable ${action} operation through this API.`, + }); + + return GiteaPullRequestApi.of({ + getViewer: Effect.fn("GiteaPullRequestApi.getViewer")(function* () { + const response = yield* gitea + .request({ + operation: "getViewer", + method: "GET", + path: "/user", + }) + .pipe(Effect.mapError((error) => failure("getViewer", error))); + const user = yield* decode("getViewer", RawUser, response); + const login = user.login?.trim(); + if (!login) + return yield* new GiteaPullRequestApiError({ + operation: "getViewer", + reason: "failed", + detail: "Gitea did not identify the signed-in account.", + }); + return login; + }), + listPullRequests, + getPullRequest, + getRepositoryAccess, + listComments, + listReviews, + listCommits, + listChecks, + getDiff: (input) => + request({ + operation: "getDiff", + host: input.host, + repository: input.repository, + method: "GET", + path: + input.commit === undefined + ? `${basePath(input.repository)}/pulls/${input.number}.diff` + : `${basePath(input.repository)}/git/commits/${encodeURIComponent(input.commit)}.diff`, + maxBytes: DIFF_MAX_BYTES, + }).pipe( + Effect.map((response) => ({ + patch: response.body, + truncated: response.truncated, + })), + ), + getDiffFileContents: (input) => + Effect.gen(function* () { + if (encodedFilePath(input.oldPath) === null || encodedFilePath(input.newPath) === null) { + return yield* new GiteaPullRequestApiError({ + operation: "getDiffFileContents", + reason: "failed", + detail: + "Gitea file paths must be repository-relative paths without traversal segments.", + }); + } + const pr = yield* getPullRequest(input); + let oldRef = pr.mergeBaseSha; + let newRef = pr.headSha; + if (input.commit !== undefined) { + const operation = "getDiffFileContents"; + const response = yield* request({ + operation, + host: input.host, + repository: input.repository, + method: "GET", + path: query( + `${basePath(input.repository)}/git/commits/${encodeURIComponent(input.commit)}`, + { + stat: "false", + verification: "false", + files: "false", + }, + ), + }); + const commit = yield* decode(operation, RawCommit, response); + newRef = commit.sha.trim(); + oldRef = commit.parents?.[0]?.sha?.trim() ?? ""; + } + if (input.changeType !== "new" && oldRef === "") { + return yield* new GiteaPullRequestApiError({ + operation: "getDiffFileContents", + reason: "failed", + detail: "Gitea did not report the immutable revision before this change.", + }); + } + if (input.changeType !== "deleted" && newRef === "") { + return yield* new GiteaPullRequestApiError({ + operation: "getDiffFileContents", + reason: "failed", + detail: "Gitea did not report the immutable revision after this change.", + }); + } + return yield* Effect.all( + { + oldContents: + input.changeType === "new" + ? Effect.succeed("") + : fileContents({ + ...input, + path: input.oldPath, + ref: oldRef, + }), + newContents: + input.changeType === "deleted" + ? Effect.succeed("") + : fileContents({ + ...input, + path: input.newPath, + ref: newRef, + }), + }, + { concurrency: 2 }, + ); + }), + runAction: (input) => { + const path = `${basePath(input.repository)}/pulls/${input.number}`; + switch (input.action) { + case "merge": + case "enable-auto-merge": + return getPullRequest(input).pipe( + Effect.flatMap((pr) => { + if (pr.headSha === "") { + return Effect.fail( + new GiteaPullRequestApiError({ + operation: "runAction", + reason: "failed", + detail: "Gitea did not report the pull request head commit.", + }), + ); + } + return write({ + operation: "runAction", + host: input.host, + repository: input.repository, + method: "POST", + path: `${path}/merge`, + body: { + do: input.mergeMethod ?? "merge", + head_commit_id: pr.headSha, + ...(input.action === "enable-auto-merge" + ? { merge_when_checks_succeed: true } + : {}), + }, + }); + }), + ); + case "disable-auto-merge": + return write({ + operation: "runAction", + host: input.host, + repository: input.repository, + method: "DELETE", + path: `${path}/merge`, + }); + case "close": + return write({ + operation: "runAction", + host: input.host, + repository: input.repository, + method: "PATCH", + path, + body: { state: "closed" }, + }); + case "reopen": + return write({ + operation: "runAction", + host: input.host, + repository: input.repository, + method: "PATCH", + path, + body: { state: "open" }, + }); + case "update-branch": + return write({ + operation: "runAction", + host: input.host, + repository: input.repository, + method: "POST", + path: query(`${path}/update`, { + style: input.updateMethod, + }), + }); + case "ready": + case "draft": + case "revert": + case "approve-workflows": + return Effect.fail(unsupportedAction(input.action)); + } + }, + updatePullRequest: (input) => + write({ + operation: "updatePullRequest", + host: input.host, + repository: input.repository, + method: "PATCH", + path: `${basePath(input.repository)}/pulls/${input.number}`, + body: { + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }, + }), + comment: (input) => + write({ + operation: "comment", + host: input.host, + repository: input.repository, + method: "POST", + path: `${basePath(input.repository)}/issues/${input.number}/comments`, + body: { body: input.body }, + }), + updateComment: (input) => { + const [kind, id] = input.commentId.split(":", 2); + if (kind !== "issue" || !id) { + return Effect.fail( + new GiteaPullRequestApiError({ + operation: "updateComment", + reason: "failed", + detail: "Gitea cannot edit pull request review comments through this API.", + }), + ); + } + return write({ + operation: "updateComment", + host: input.host, + repository: input.repository, + method: "PATCH", + path: `${basePath(input.repository)}/issues/comments/${id}`, + body: { body: input.body }, + }); + }, + submitReview: (input) => + write({ + operation: "submitReview", + host: input.host, + repository: input.repository, + method: "POST", + path: `${basePath(input.repository)}/pulls/${input.number}/reviews`, + body: { + event: + input.verdict === "approve" + ? "APPROVED" + : input.verdict === "request-changes" + ? "REQUEST_CHANGES" + : "COMMENT", + body: input.body, + comments: input.comments.map((comment) => ({ + body: comment.body, + path: comment.path, + ...(comment.position.kind === "deleted" + ? { old_position: comment.position.oldLine } + : { new_position: comment.position.newLine }), + })), + }, + }), + listReviewerCandidates: (input) => + Effect.all( + [ + getPullRequest(input), + readUnknownSlice({ + operation: "listReviewerCandidates", + ...input, + path: query(`${basePath(input.repository)}/reviewers`, { + page: 1, + limit: PAGE_SIZE, + }), + limit: PAGE_SIZE, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([pr, result]) => { + const requested = new Set(pr.reviewRequestLogins.map((login) => login.toLowerCase())); + return { + candidates: result.rows.flatMap((row) => { + const raw = decodeUser(row); + if (Option.isNone(raw)) return []; + const mapped = actor(raw.value); + if (mapped === null || mapped.login === pr.author?.login) return []; + return [ + { + ...mapped, + id: mapped.login, + kind: "user" as const, + isRequested: requested.has(mapped.login.toLowerCase()), + }, + ]; + }), + truncated: result.truncated, + }; + }), + ), + setReviewerRequest: (input) => + write({ + operation: "setReviewerRequest", + host: input.host, + repository: input.repository, + method: input.requested ? "POST" : "DELETE", + path: `${basePath(input.repository)}/pulls/${input.number}/requested_reviewers`, + body: { + reviewers: input.reviewers + .filter((reviewer) => reviewer.kind === "user") + .map((reviewer) => reviewer.id), + team_reviewers: input.reviewers + .filter((reviewer) => reviewer.kind === "team") + .map((reviewer) => reviewer.id), + }, + }), + listLabelCandidates: (input) => + Effect.all( + [ + getPullRequest(input), + readUnknownSlice({ + operation: "listLabelCandidates", + ...input, + path: query(`${basePath(input.repository)}/labels`, { + page: 1, + limit: PAGE_SIZE, + }), + limit: PAGE_SIZE, + }), + ], + { concurrency: 2 }, + ).pipe( + Effect.map(([pr, result]) => { + const applied = new Set(pr.labels.map((label) => label.name.toLowerCase())); + return { + candidates: result.rows.flatMap((row) => { + const raw = decodeLabel(row); + const name = Option.isSome(raw) ? raw.value.name?.trim() : undefined; + return Option.isSome(raw) && name + ? [ + { + name, + color: raw.value.color?.trim() || null, + description: raw.value.description ?? null, + isApplied: applied.has(name.toLowerCase()), + }, + ] + : []; + }), + truncated: result.truncated, + }; + }), + ), + setLabels: (input) => + getPullRequest(input).pipe( + Effect.flatMap((pr) => { + const labels = new Set(pr.labels.map((label) => label.name)); + for (const label of input.labels) { + if (input.applied) labels.add(label); + else labels.delete(label); + } + return write({ + operation: "setLabels", + host: input.host, + repository: input.repository, + method: "PUT", + path: `${basePath(input.repository)}/issues/${input.number}/labels`, + body: { labels: [...labels] }, + }); + }), + ), + replyToThread: (input) => + write({ + operation: "replyToThread", + host: input.host, + repository: input.repository, + method: "POST", + path: `${basePath(input.repository)}/pulls/${input.number}/comments/${encodeURIComponent(input.threadId)}/replies`, + body: { body: input.body }, + }), + setThreadResolution: (input) => + write({ + operation: "setThreadResolution", + host: input.host, + repository: input.repository, + method: "POST", + path: `${basePath(input.repository)}/pulls/comments/${encodeURIComponent(input.threadId)}/${input.resolved ? "resolve" : "unresolve"}`, + }), + setReaction: () => + Effect.fail( + new GiteaPullRequestApiError({ + operation: "setReaction", + reason: "failed", + detail: "Gitea cannot apply reactions consistently to pull request review comments.", + }), + ), + }); +}); + +export const layer = Layer.effect(GiteaPullRequestApi, make); diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts new file mode 100644 index 000000000000..e035329c1b77 --- /dev/null +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { giteaProviderFailure, giteaViewerPermissions } from "./GiteaPullRequestProvider.ts"; +import { GiteaPullRequestApiError } from "./GiteaPullRequestApi.ts"; + +describe("giteaViewerPermissions", () => { + it("offers repository writes and only the configured branch update strategies", () => { + expect( + giteaViewerPermissions({ + canWrite: true, + ownsPullRequest: false, + updateMethods: ["rebase"], + }), + ).toEqual({ + actions: ["merge", "close", "reopen", "update-branch"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + updateMethods: ["rebase"], + labels: true, + }); + }); + + it("lets an author close and reopen without granting repository writes", () => { + expect( + giteaViewerPermissions({ + canWrite: false, + ownsPullRequest: true, + updateMethods: ["merge", "rebase"], + }), + ).toEqual({ + actions: ["close", "reopen"], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + updateMethods: [], + labels: false, + }); + }); + + it("keeps write actions from a read-only non-author", () => { + expect( + giteaViewerPermissions({ + canWrite: false, + ownsPullRequest: false, + updateMethods: [], + }).actions, + ).toEqual([]); + }); +}); + +describe("giteaProviderFailure", () => { + it("maps missing configuration to unauthenticated", () => { + expect( + giteaProviderFailure( + new GiteaPullRequestApiError({ + operation: "list", + reason: "unconfigured", + detail: "configure it", + }), + ), + ).toEqual({ reason: "unauthenticated" }); + }); + + it("keeps a rate-limit deadline", () => { + expect( + giteaProviderFailure( + new GiteaPullRequestApiError({ + operation: "list", + reason: "rate-limited", + detail: "later", + retryAt: 1234, + }), + ), + ).toEqual({ reason: "rate-limited", retryAt: 1234 }); + }); +}); diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts new file mode 100644 index 000000000000..a7063e63f9a3 --- /dev/null +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -0,0 +1,306 @@ +import * as Effect from "effect/Effect"; +import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; + +import * as GiteaPullRequestApi from "./GiteaPullRequestApi.ts"; +import { + PullRequestProviderError, + type PullRequestProviderApi, + type PullRequestProviderFailure, + type ProviderChangeRequest, + type ProviderChangeRequestActivity, + type ProviderChangeRequestDetail, +} from "./PullRequestProvider.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: ["merge", "close", "reopen", "update-branch"], + mergeMethods: ["merge", "squash", "rebase"], + updateMethods: ["merge", "rebase"], + // Gitea's repository pull listing has no text parameter. Returning an unfiltered page keeps + // narrowing correct at the service boundary without claiming host-side search. + search: false, + // Issue reactions exist, but review-comment reactions do not have a corresponding route in + // the target API. The one capability covers every displayed remark, so partial support stays off. + reactions: false, + review: { + inlineComment: true, + reply: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + // Gitea can edit the pull request and ordinary issue comments. It has no edit route for a + // review comment, and this capability applies to every conversation remark. + edit: { changeRequest: true, comment: false }, + labels: true, +}; + +export function giteaProviderFailure( + error: GiteaPullRequestApi.GiteaPullRequestApiError, +): PullRequestProviderFailure { + return { + reason: + error.reason === "unconfigured" || error.reason === "unauthenticated" + ? "unauthenticated" + : error.reason, + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + }; +} + +export function giteaViewerPermissions(input: { + readonly canWrite: boolean; + readonly ownsPullRequest: boolean; + readonly updateMethods: ReadonlyArray<"merge" | "rebase">; +}): PullRequestViewerPermissions { + return { + actions: CAPABILITIES.actions.filter((action) => { + if (action === "close" || action === "reopen") return input.canWrite || input.ownsPullRequest; + return input.canWrite; + }), + comment: true, + resolve: input.canWrite, + verdicts: CAPABILITIES.review.verdicts, + requestReviewers: input.canWrite, + updateMethods: input.canWrite ? input.updateMethods : [], + labels: input.canWrite, + }; +} + +function toChangeRequest(pullRequest: GiteaPullRequestApi.GiteaPullRequest): ProviderChangeRequest { + return { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + author: pullRequest.author, + headBranch: pullRequest.headBranch, + headRepositoryNameWithOwner: pullRequest.headRepositoryNameWithOwner, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + mergeability: pullRequest.mergeability, + additions: pullRequest.additions, + deletions: pullRequest.deletions, + createdAt: pullRequest.createdAt, + updatedAt: pullRequest.updatedAt, + reviewRequestLogins: pullRequest.reviewRequestLogins, + labels: pullRequest.labels, + }; +} + +export const make = Effect.gen(function* () { + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + + const fail = (operation: string) => (error: GiteaPullRequestApi.GiteaPullRequestApiError) => + new PullRequestProviderError({ + provider: "gitea", + operation, + ...giteaProviderFailure(error), + detail: error.detail, + cause: error, + }); + + const permissions = (input: { + readonly access: GiteaPullRequestApi.GiteaRepositoryAccess; + readonly viewer: string; + readonly author: string | undefined; + }) => + giteaViewerPermissions({ + canWrite: input.access.canWrite, + ownsPullRequest: + input.author !== undefined && input.author.toLowerCase() === input.viewer.toLowerCase(), + updateMethods: input.access.updateMethods, + }); + + const provider: PullRequestProviderApi = { + kind: "gitea", + capabilities: CAPABILITIES, + + getViewer: () => api.getViewer().pipe(Effect.mapError(fail("getViewer"))), + + listChangeRequests: (input) => + api + .listPullRequests({ + host: input.host, + repository: input.repository, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + limit: input.limit, + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + }) + .pipe( + Effect.mapError(fail("listChangeRequests")), + Effect.map((page) => ({ + items: page.items.map(toChangeRequest), + truncated: page.truncated, + cursorAdvance: page.consumed, + continues: true, + })), + ), + + getChangeRequest: (input) => + Effect.all([api.getPullRequest(input), api.getRepositoryAccess(input), api.getViewer()], { + concurrency: 3, + }).pipe( + Effect.flatMap(([pullRequest, access, viewer]) => + api + .listChecks({ ...input, sha: pullRequest.headSha }) + .pipe(Effect.orElseSucceed(() => [])) + .pipe( + Effect.map((checks): ProviderChangeRequestDetail => ({ + ...toChangeRequest(pullRequest), + body: pullRequest.body, + changedFiles: pullRequest.changedFiles, + mergedAt: pullRequest.mergedAt, + closedAt: pullRequest.closedAt, + reviewers: pullRequest.reviewers, + checks, + mergeCapabilities: access.mergeCapabilities, + viewerPermissions: permissions({ + access, + viewer, + author: pullRequest.author?.login, + }), + })), + ), + ), + Effect.mapError(fail("getChangeRequest")), + ), + + getChangeRequestSummary: (input) => + api.getPullRequest(input).pipe( + Effect.mapError(fail("getChangeRequestSummary")), + Effect.map((pullRequest) => ({ + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + headBranch: pullRequest.headBranch, + baseBranch: pullRequest.baseBranch, + state: pullRequest.state, + isDraft: pullRequest.isDraft, + updatedAt: pullRequest.updatedAt, + })), + ), + + getChangeRequestActivity: (input) => + Effect.all( + [ + api.getPullRequest(input), + api + .listComments(input) + .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), + api + .listReviews(input) + .pipe(Effect.orElseSucceed(() => ({ comments: [], threads: [], truncated: true }))), + api.listCommits(input).pipe(Effect.orElseSucceed(() => [])), + ], + { concurrency: 4 }, + ).pipe( + Effect.mapError(fail("getChangeRequestActivity")), + Effect.map( + ([pullRequest, issueComments, reviews, commits]): ProviderChangeRequestActivity => ({ + author: pullRequest.author, + reviewers: pullRequest.reviewers, + comments: [...issueComments.comments, ...reviews.comments].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ), + commentCount: Math.max( + pullRequest.commentCount, + issueComments.comments.length + reviews.comments.length, + ), + commentsTruncated: issueComments.truncated || reviews.truncated, + reviewThreads: reviews.threads, + commits, + }), + ), + ), + + getViewerPermissions: (input) => + Effect.all([api.getPullRequest(input), api.getRepositoryAccess(input), api.getViewer()], { + concurrency: 3, + }).pipe( + Effect.mapError(fail("getViewerPermissions")), + Effect.map(([pullRequest, access, viewer]) => + permissions({ access, viewer, author: pullRequest.author?.login }), + ), + ), + + getDiff: (input) => + api + .getDiff({ + host: input.host, + repository: input.repository, + number: input.number, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe( + Effect.mapError(fail("getDiff")), + Effect.map((diff) => ({ ...diff, nextCursor: null })), + ), + + getDiffFileContents: (input) => + api + .getDiffFileContents({ + host: input.host, + repository: input.repository, + number: input.number, + oldPath: input.oldPath, + newPath: input.newPath, + changeType: input.changeType, + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe(Effect.mapError(fail("getDiffFileContents"))), + + runAction: (input) => api.runAction(input).pipe(Effect.mapError(fail("runAction"))), + + updateChangeRequest: (input) => + api + .updatePullRequest({ + host: input.host, + repository: input.repository, + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + + comment: (input) => api.comment(input).pipe(Effect.mapError(fail("comment"))), + + // Never called: Gitea cannot edit every kind of remark, and the capability stays false. + updateComment: (input) => api.updateComment(input).pipe(Effect.mapError(fail("updateComment"))), + + submitReview: (input) => api.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), + + listReviewerCandidates: (input) => + api.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))), + + setReviewerRequest: (input) => + api.setReviewerRequest(input).pipe(Effect.mapError(fail("setReviewerRequest"))), + + listLabelCandidates: (input) => + api.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), + + setLabels: (input) => api.setLabels(input).pipe(Effect.mapError(fail("setLabels"))), + + replyToThread: (input) => api.replyToThread(input).pipe(Effect.mapError(fail("replyToThread"))), + + setThreadResolution: (input) => + api.setThreadResolution(input).pipe(Effect.mapError(fail("setThreadResolution"))), + + // Never called: the target API cannot cover reactions on review comments. + setReaction: (input) => + api + .setReaction({ + host: input.host, + repository: input.repository, + number: input.number, + content: input.content, + reacted: input.reacted, + ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), + }) + .pipe(Effect.mapError(fail("setReaction"))), + }; + + return provider; +}); diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index d2caf3ff35bd..e8eb091d77f9 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -8,6 +8,7 @@ import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as GiteaApi from "../sourceControl/GiteaApi.ts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; @@ -16,6 +17,8 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; +import * as GiteaPullRequestApi from "./GiteaPullRequestApi.ts"; +import * as GiteaPullRequestProvider from "./GiteaPullRequestProvider.ts"; import type { PullRequestProviderApi } from "./PullRequestProvider.ts"; export class PullRequestProviderRegistry extends Context.Service< @@ -48,6 +51,7 @@ export const make = Effect.map( GitLabPullRequestProvider.make, BitbucketPullRequestProvider.make, AzureDevOpsPullRequestProvider.make, + GiteaPullRequestProvider.make, ]), fromProviders, ); @@ -62,4 +66,5 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), + Layer.provide(GiteaPullRequestApi.layer.pipe(Layer.provide(GiteaApi.layer))), ); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 6d6b9cc31338..6deb88d53496 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -55,8 +55,8 @@ export T3CODE_GITEA_TOKEN="your-access-token" ``` Use the web root, including a proxy subpath if your server uses one. The token needs user read -access for account discovery and repository access for the repositories you want to review. -Grant write access for actions such as comments, reviews, creating repositories, and merging. +access for account discovery, repository access for pull requests, and issue access for ordinary +comments and labels. Grant write access to repositories and issues for review and lifecycle actions. Restart the server after setting these variables, then choose **Settings → Source Control → Rescan**. One Gitea server can be configured per T3 environment. HTTPS remotes must use that web root; @@ -101,6 +101,11 @@ supports approving waiting fork workflows and opening a revert pull request for For Azure DevOps, use the host website to view diffs or change comments. Bitbucket does not support reopening a declined pull request. +Gitea supports PR tracking, comments, reviews, diffs, reviewer and label updates, merge methods, +branch updates, and close/reopen. Draft status is shown when Gitea reports it, but draft/ready +changes, auto-merge controls, reactions, comment editing, workflow approval, and revert PRs are +not currently available in T3. Use your Gitea website for those tasks. + ## Troubleshooting - **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket or Gitea,