| 1 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | import * as Result from "effect/Result"; |
| 5 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; |
| 6 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 7 | import * as ForgejoCli from "./ForgejoCli.ts"; |
| 8 | import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions"; |
| 9 | import { |
| 10 | PullRequestProviderError, |
| 11 | type ProviderChangeRequestDetail, |
| 12 | type ProviderRepositoryRef, |
| 13 | type PullRequestProviderApi, |
| 14 | } from "@t3tools/source-control-core/server/PullRequestProvider"; |
| 15 | import { |
| 16 | ForgejoPullRequest, |
| 17 | ForgejoRepository, |
| 18 | ForgejoUser, |
| 19 | ForgejoComment, |
| 20 | ForgejoReview, |
| 21 | ForgejoReviewComment, |
| 22 | ForgejoCommit, |
| 23 | ForgejoStatus, |
| 24 | ForgejoLabel, |
| 25 | ForgejoReaction, |
| 26 | FORGEJO_REACTIONS, |
| 27 | forgejoChangeRequest, |
| 28 | forgejoActor, |
| 29 | forgejoComment, |
| 30 | forgejoReview, |
| 31 | forgejoReviewThread, |
| 32 | forgejoCommit, |
| 33 | forgejoChecks, |
| 34 | forgejoReactions, |
| 35 | } from "./forgejoPullRequestJson.ts"; |
| 36 | import { definition } from "../client/definition.ts"; |
| 37 | |
| 38 | const CAPABILITIES: PullRequestCapabilities = { |
| 39 | diff: true, |
| 40 | viewedFiles: "environment", |
| 41 | comment: true, |
| 42 | actions: [...definition.changeRequestActions], |
| 43 | mergeMethods: ["merge", "squash", "rebase"], |
| 44 | updateMethods: ["merge", "rebase"], |
| 45 | search: false, |
| 46 | reactions: true, |
| 47 | labels: true, |
| 48 | // Forgejo's public API has no thread replies/resolution or draft conversion endpoint. |
| 49 | review: { |
| 50 | inlineComment: true, |
| 51 | reply: false, |
| 52 | resolve: false, |
| 53 | verdicts: ["comment", "approve", "request-changes"], |
| 54 | }, |
| 55 | reviewers: { request: true, listCandidates: true }, |
| 56 | edit: { changeRequest: true, comment: true }, |
| 57 | }; |
| 58 | const repoPath = (input: ProviderRepositoryRef) => |
| 59 | `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; |
| 60 | const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) => |
| 61 | `${repoPath(input)}/pulls/${input.number}`; |
| 62 | const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) => |
| 63 | `${repoPath(input)}/issues/${input.number}`; |
| 64 | // Review IDs differ from the issue-comment IDs used by Forgejo's reactions API. |
| 65 | const reviewCommentId = (review: typeof ForgejoReview.Type) => |
| 66 | /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1]; |
| 67 | |
| 68 | export const make = Effect.gen(function* () { |
| 69 | const cli = yield* ForgejoCli.ForgejoCli; |
| 70 | const failure = (operation: string, detail: string, cause?: unknown) => |
| 71 | new PullRequestProviderError({ |
| 72 | provider: SourceControlProviderKind.make("forgejo"), |
| 73 | operation, |
| 74 | reason: "failed", |
| 75 | detail, |
| 76 | ...(cause === undefined ? {} : { cause }), |
| 77 | }); |
| 78 | const request = (input: ForgejoCli.ForgejoApiInput) => |
| 79 | cli.api(input).pipe( |
| 80 | Effect.mapError( |
| 81 | (error) => |
| 82 | new PullRequestProviderError({ |
| 83 | provider: SourceControlProviderKind.make("forgejo"), |
| 84 | operation: input.path, |
| 85 | detail: error.detail, |
| 86 | cause: error, |
| 87 | reason: |
| 88 | error.reason === "missing-cli" |
| 89 | ? "missing-tool" |
| 90 | : error.reason === "authentication" |
| 91 | ? "unauthenticated" |
| 92 | : error.reason === "rate-limit" |
| 93 | ? "rate-limited" |
| 94 | : error.reason === "not-found" |
| 95 | ? "not-found" |
| 96 | : "failed", |
| 97 | }), |
| 98 | ), |
| 99 | ); |
| 100 | const read = Effect.fn("ForgejoPullRequestProvider.read")(function* <A>( |
| 101 | input: ForgejoCli.ForgejoApiInput, |
| 102 | schema: Schema.Codec<A, unknown, never, never>, |
| 103 | ) { |
| 104 | const result = yield* request(input); |
| 105 | if (result.stdoutTruncated) |
| 106 | return yield* failure(input.path, "Forgejo response exceeded the output limit."); |
| 107 | const decoded = decodeJsonResult(schema)(result.stdout); |
| 108 | return Result.isSuccess(decoded) |
| 109 | ? decoded.success |
| 110 | : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); |
| 111 | }); |
| 112 | const readArray = <A>( |
| 113 | input: ForgejoCli.ForgejoApiInput, |
| 114 | schema: Schema.Codec<A, unknown, never, never>, |
| 115 | ) => read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? [])); |
| 116 | const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* <A>( |
| 117 | input: ForgejoCli.ForgejoApiInput, |
| 118 | schema: Schema.Codec<A, unknown, never, never>, |
| 119 | index: number, |
| 120 | ) { |
| 121 | const result = yield* request({ |
| 122 | ...input, |
| 123 | path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`, |
| 124 | }); |
| 125 | if (result.stdoutTruncated) |
| 126 | return yield* failure(input.path, "Forgejo response exceeded the output limit."); |
| 127 | const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout); |
| 128 | if (Result.isFailure(decoded)) |
| 129 | return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); |
| 130 | const rows = decoded.success ?? []; |
| 131 | const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1]; |
| 132 | return { |
| 133 | rows, |
| 134 | more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)), |
| 135 | }; |
| 136 | }); |
| 137 | // Use a stable, small page size that also works with Forgejo's default maximum of 50. |
| 138 | const page = Effect.fn("ForgejoPullRequestProvider.page")(function* <A>( |
| 139 | input: ForgejoCli.ForgejoApiInput, |
| 140 | schema: Schema.Codec<A, unknown, never, never>, |
| 141 | limit = 500, |
| 142 | ) { |
| 143 | const items: A[] = []; |
| 144 | for (let index = 1; items.length < limit; index++) { |
| 145 | const { rows, more } = yield* readPage(input, schema, index); |
| 146 | items.push(...rows); |
| 147 | if (!more) return { items, truncated: false }; |
| 148 | } |
| 149 | return { items, truncated: true }; |
| 150 | }); |
| 151 | const write = (input: ForgejoCli.ForgejoApiInput) => request(input).pipe(Effect.asVoid); |
| 152 | const getPull = (input: ProviderRepositoryRef & { readonly number: number }) => |
| 153 | read( |
| 154 | { |
| 155 | cwd: input.cwd, |
| 156 | repository: input.repository, |
| 157 | host: input.host, |
| 158 | path: pullPath(input), |
| 159 | }, |
| 160 | ForgejoPullRequest, |
| 161 | ); |
| 162 | const getRepo = (input: ProviderRepositoryRef) => |
| 163 | read({ ...input, path: repoPath(input) }, ForgejoRepository); |
| 164 | const getViewer = (input: { |
| 165 | readonly cwd: string; |
| 166 | readonly repository?: string; |
| 167 | readonly host?: string; |
| 168 | }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login)); |
| 169 | const permissions = ( |
| 170 | repo: typeof ForgejoRepository.Type, |
| 171 | pr: typeof ForgejoPullRequest.Type, |
| 172 | viewer: string, |
| 173 | ): PullRequestViewerPermissions => { |
| 174 | const canWrite = repo.permissions?.push ?? false; |
| 175 | const canEdit = canWrite || pr.user?.login === viewer; |
| 176 | const active = !repo.archived; |
| 177 | return { |
| 178 | actions: active |
| 179 | ? CAPABILITIES.actions.filter((action) => |
| 180 | action === "merge" || action === "update-branch" ? canWrite : canEdit, |
| 181 | ) |
| 182 | : [], |
| 183 | comment: active && (!pr.is_locked || canWrite), |
| 184 | resolve: false, |
| 185 | verdicts: active |
| 186 | ? pr.user?.login === viewer |
| 187 | ? ["comment"] |
| 188 | : CAPABILITIES.review.verdicts |
| 189 | : [], |
| 190 | requestReviewers: active && canEdit, |
| 191 | labels: active && canWrite, |
| 192 | updateMethods: |
| 193 | active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [], |
| 194 | }; |
| 195 | }; |
| 196 | const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* ( |
| 197 | input: ProviderRepositoryRef & { readonly number: number }, |
| 198 | ) { |
| 199 | const [repo, pr, viewer] = yield* Effect.all( |
| 200 | [getRepo(input), getPull(input), getViewer(input)], |
| 201 | { concurrency: 3 }, |
| 202 | ); |
| 203 | return permissions(repo, pr, viewer); |
| 204 | }); |
| 205 | const unsupported = (operation: string) => |
| 206 | Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`)); |
| 207 | const provider: PullRequestProviderApi = { |
| 208 | kind: SourceControlProviderKind.make("forgejo"), |
| 209 | capabilities: CAPABILITIES, |
| 210 | getViewer, |
| 211 | listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( |
| 212 | function* (input) { |
| 213 | const offset = input.cursor?.delivered ?? 0; |
| 214 | const items: ReturnType<typeof forgejoChangeRequest>[] = []; |
| 215 | const state = input.state === "merged" ? "closed" : input.state; |
| 216 | const query = { |
| 217 | ...input, |
| 218 | path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`, |
| 219 | }; |
| 220 | // Self-hosted servers may cap pages below 50. Establish their actual page size before |
| 221 | // translating the service's row offset into an API page number. |
| 222 | const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1); |
| 223 | const pageSize = first.rows.length || 50; |
| 224 | const firstIndex = Math.floor(offset / pageSize) + 1; |
| 225 | let consumed = 0; |
| 226 | let more = true; |
| 227 | for (let index = firstIndex; more && consumed < input.limit; index++) { |
| 228 | const result = |
| 229 | index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index); |
| 230 | const rows = result.rows; |
| 231 | const start = index === firstIndex ? offset % pageSize : 0; |
| 232 | const countBefore = consumed; |
| 233 | for (const row of rows.slice(start)) { |
| 234 | if (consumed >= input.limit) break; |
| 235 | consumed++; |
| 236 | if (row) items.push(forgejoChangeRequest(row)); |
| 237 | } |
| 238 | more = result.more || rows.length - start > consumed - countBefore; |
| 239 | } |
| 240 | return { items, truncated: more, continues: true, cursorAdvance: consumed }; |
| 241 | }, |
| 242 | ), |
| 243 | getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)), |
| 244 | getChangeRequestChecks: Effect.fn("ForgejoPullRequestProvider.getChangeRequestChecks")( |
| 245 | function* (input) { |
| 246 | const pr = yield* getPull(input); |
| 247 | const statuses = yield* page( |
| 248 | { |
| 249 | ...input, |
| 250 | path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, |
| 251 | }, |
| 252 | ForgejoStatus, |
| 253 | ); |
| 254 | return { state: forgejoChangeRequest(pr).state, checks: forgejoChecks(statuses.items) }; |
| 255 | }, |
| 256 | ), |
| 257 | getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) { |
| 258 | const [pr, repo, viewer] = yield* Effect.all( |
| 259 | [getPull(input), getRepo(input), getViewer(input)], |
| 260 | { concurrency: 3 }, |
| 261 | ); |
| 262 | const statuses = yield* page( |
| 263 | { |
| 264 | ...input, |
| 265 | path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, |
| 266 | }, |
| 267 | ForgejoStatus, |
| 268 | ); |
| 269 | return { |
| 270 | ...forgejoChangeRequest(pr), |
| 271 | headSha: pr.head.sha, |
| 272 | body: pr.body ?? "", |
| 273 | changedFiles: pr.changed_files ?? 0, |
| 274 | reviewers: (pr.requested_reviewers ?? []).flatMap((user) => { |
| 275 | const actor = forgejoActor(user); |
| 276 | return actor ? [actor] : []; |
| 277 | }), |
| 278 | checks: forgejoChecks(statuses.items), |
| 279 | baseComparison: !pr.merge_base |
| 280 | ? "unknown" |
| 281 | : pr.merge_base === pr.base.sha |
| 282 | ? "up-to-date" |
| 283 | : "behind", |
| 284 | viewerPermissions: permissions(repo, pr, viewer), |
| 285 | mergeCapabilities: { |
| 286 | merge: repo.allow_merge_commits ?? true, |
| 287 | squash: repo.allow_squash_merge ?? true, |
| 288 | rebase: repo.allow_rebase ?? true, |
| 289 | }, |
| 290 | } satisfies ProviderChangeRequestDetail; |
| 291 | }), |
| 292 | getViewerPermissions: getPermissions, |
| 293 | getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")( |
| 294 | function* (input) { |
| 295 | const [comments, reviews, commits, reactions, viewer] = yield* Effect.all( |
| 296 | [ |
| 297 | // Issue comments ignore page/limit; fetch this unpaginated endpoint once. |
| 298 | readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe( |
| 299 | Effect.map((items) => ({ |
| 300 | items: items.slice(0, 500), |
| 301 | truncated: items.length > 500, |
| 302 | })), |
| 303 | ), |
| 304 | page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview), |
| 305 | page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit), |
| 306 | page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction), |
| 307 | getViewer(input), |
| 308 | ], |
| 309 | { concurrency: 5 }, |
| 310 | ); |
| 311 | const reviewComments = yield* Effect.forEach( |
| 312 | reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"), |
| 313 | (review) => |
| 314 | readArray( |
| 315 | { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` }, |
| 316 | ForgejoReviewComment, |
| 317 | ), |
| 318 | { concurrency: 4 }, |
| 319 | ); |
| 320 | const allInline = reviewComments.flat(); |
| 321 | const inline = allInline.slice(0, 500); |
| 322 | const entries = [ |
| 323 | ...comments.items.map((comment) => ({ |
| 324 | comment: forgejoComment(comment), |
| 325 | reactionId: String(comment.id), |
| 326 | })), |
| 327 | ...reviews.items |
| 328 | .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW") |
| 329 | .map((review) => ({ |
| 330 | comment: forgejoReview(review), |
| 331 | reactionId: reviewCommentId(review), |
| 332 | })), |
| 333 | ...inline.map((comment) => ({ |
| 334 | comment: { |
| 335 | ...forgejoComment(comment), |
| 336 | kind: "review-comment" as const, |
| 337 | path: comment.path, |
| 338 | }, |
| 339 | reactionId: String(comment.id), |
| 340 | })), |
| 341 | ]; |
| 342 | const enriched = yield* Effect.forEach( |
| 343 | entries, |
| 344 | ({ comment, reactionId }) => |
| 345 | (reactionId === undefined |
| 346 | ? Effect.succeed([]) |
| 347 | : readArray( |
| 348 | { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` }, |
| 349 | ForgejoReaction, |
| 350 | ) |
| 351 | ).pipe( |
| 352 | Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })), |
| 353 | ), |
| 354 | { concurrency: 4 }, |
| 355 | ); |
| 356 | const byId = new Map(enriched.map((comment) => [comment.id, comment])); |
| 357 | const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); |
| 358 | return { |
| 359 | comments: timeline, |
| 360 | commentCount: timeline.length, |
| 361 | commentsTruncated: |
| 362 | comments.truncated || reviews.truncated || allInline.length > inline.length, |
| 363 | reviewThreads: inline.map((comment) => ({ |
| 364 | ...forgejoReviewThread(comment), |
| 365 | comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)], |
| 366 | })), |
| 367 | commits: commits.items.map(forgejoCommit), |
| 368 | reactions: forgejoReactions(reactions.items, viewer), |
| 369 | }; |
| 370 | }, |
| 371 | ), |
| 372 | getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) { |
| 373 | const result = yield* request({ ...input, path: `${pullPath(input)}.diff` }); |
| 374 | if (result.stdoutTruncated) { |
| 375 | return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit."); |
| 376 | } |
| 377 | const revisions = new Map(parseDiffFileRevisions(result.stdout)); |
| 378 | for (const path of input.paths) { |
| 379 | if (!revisions.has(path)) revisions.set(path, ""); |
| 380 | } |
| 381 | return { revisions, complete: true }; |
| 382 | }), |
| 383 | getDiff: (input) => |
| 384 | request({ |
| 385 | ...input, |
| 386 | path: input.commit |
| 387 | ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff` |
| 388 | : `${pullPath(input)}.diff`, |
| 389 | }).pipe( |
| 390 | Effect.map((result) => ({ |
| 391 | patch: result.stdout, |
| 392 | truncated: result.stdoutTruncated, |
| 393 | nextCursor: null, |
| 394 | })), |
| 395 | ), |
| 396 | getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")( |
| 397 | function* (input) { |
| 398 | const pr = yield* getPull(input); |
| 399 | const commit = input.commit |
| 400 | ? yield* read( |
| 401 | { |
| 402 | ...input, |
| 403 | path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`, |
| 404 | }, |
| 405 | ForgejoCommit, |
| 406 | ) |
| 407 | : null; |
| 408 | const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha; |
| 409 | const newRef = input.commit ?? pr.head.sha; |
| 410 | const content = (repository: string, ref: string, path: string) => |
| 411 | read( |
| 412 | { |
| 413 | ...input, |
| 414 | repository, |
| 415 | path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`, |
| 416 | }, |
| 417 | Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }), |
| 418 | ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8"))); |
| 419 | const [oldContents, newContents] = yield* Effect.all( |
| 420 | [ |
| 421 | input.changeType === "new" || !oldRef |
| 422 | ? Effect.succeed("") |
| 423 | : content(input.repository, oldRef, input.oldPath), |
| 424 | input.changeType === "deleted" |
| 425 | ? Effect.succeed("") |
| 426 | : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath), |
| 427 | ], |
| 428 | { concurrency: 2 }, |
| 429 | ); |
| 430 | return { oldContents, newContents }; |
| 431 | }, |
| 432 | ), |
| 433 | runAction: (input) => { |
| 434 | switch (input.action) { |
| 435 | case "merge": |
| 436 | return write({ |
| 437 | ...input, |
| 438 | path: `${pullPath(input)}/merge`, |
| 439 | method: "POST", |
| 440 | body: { Do: input.mergeMethod ?? "merge" }, |
| 441 | }); |
| 442 | case "close": |
| 443 | case "reopen": |
| 444 | return write({ |
| 445 | ...input, |
| 446 | path: pullPath(input), |
| 447 | method: "PATCH", |
| 448 | body: { state: input.action === "close" ? "closed" : "open" }, |
| 449 | }); |
| 450 | case "update-branch": |
| 451 | return write({ |
| 452 | ...input, |
| 453 | path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`, |
| 454 | method: "POST", |
| 455 | }); |
| 456 | default: |
| 457 | return unsupported(input.action); |
| 458 | } |
| 459 | }, |
| 460 | updateChangeRequest: (input) => |
| 461 | write({ |
| 462 | ...input, |
| 463 | path: pullPath(input), |
| 464 | method: "PATCH", |
| 465 | body: { |
| 466 | ...(input.title === undefined ? {} : { title: input.title }), |
| 467 | ...(input.body === undefined ? {} : { body: input.body }), |
| 468 | }, |
| 469 | }), |
| 470 | comment: (input) => |
| 471 | write({ |
| 472 | ...input, |
| 473 | path: `${issuePath(input)}/comments`, |
| 474 | method: "POST", |
| 475 | body: { body: input.body }, |
| 476 | }), |
| 477 | updateComment: (input) => |
| 478 | write({ |
| 479 | ...input, |
| 480 | path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`, |
| 481 | method: "PATCH", |
| 482 | body: { body: input.body }, |
| 483 | }), |
| 484 | submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) { |
| 485 | const pr = yield* getPull(input); |
| 486 | yield* write({ |
| 487 | ...input, |
| 488 | path: `${pullPath(input)}/reviews`, |
| 489 | method: "POST", |
| 490 | body: { |
| 491 | event: |
| 492 | input.verdict === "approve" |
| 493 | ? "APPROVED" |
| 494 | : input.verdict === "request-changes" |
| 495 | ? "REQUEST_CHANGES" |
| 496 | : "COMMENT", |
| 497 | body: input.body, |
| 498 | commit_id: pr.head.sha, |
| 499 | comments: input.comments.map((comment) => { |
| 500 | const position = comment.position; |
| 501 | const old = |
| 502 | position.kind === "deleted" || |
| 503 | (position.kind === "context" && position.side === "left"); |
| 504 | return { |
| 505 | path: old ? (comment.oldPath ?? comment.path) : comment.path, |
| 506 | body: comment.body, |
| 507 | old_position: old ? position.oldLine : 0, |
| 508 | new_position: old ? 0 : position.newLine, |
| 509 | }; |
| 510 | }), |
| 511 | }, |
| 512 | }); |
| 513 | }), |
| 514 | listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")( |
| 515 | function* (input) { |
| 516 | const [pr, users] = yield* Effect.all( |
| 517 | [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)], |
| 518 | { concurrency: 2 }, |
| 519 | ); |
| 520 | return { |
| 521 | candidates: users.items |
| 522 | .filter((user) => user.login !== pr.user?.login) |
| 523 | .flatMap((user) => { |
| 524 | const actor = forgejoActor(user); |
| 525 | return actor |
| 526 | ? [ |
| 527 | { |
| 528 | ...actor, |
| 529 | id: user.login, |
| 530 | kind: "user" as const, |
| 531 | isRequested: |
| 532 | pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ?? |
| 533 | false, |
| 534 | }, |
| 535 | ] |
| 536 | : []; |
| 537 | }), |
| 538 | truncated: users.truncated, |
| 539 | }; |
| 540 | }, |
| 541 | ), |
| 542 | setReviewerRequest: (input) => |
| 543 | write({ |
| 544 | ...input, |
| 545 | path: `${pullPath(input)}/requested_reviewers`, |
| 546 | method: input.requested ? "POST" : "DELETE", |
| 547 | body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) }, |
| 548 | }), |
| 549 | listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")( |
| 550 | function* (input) { |
| 551 | const [pr, labels] = yield* Effect.all( |
| 552 | [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)], |
| 553 | { concurrency: 2 }, |
| 554 | ); |
| 555 | return { |
| 556 | candidates: labels.items.map((label) => ({ |
| 557 | name: label.name, |
| 558 | color: label.color ?? null, |
| 559 | description: label.description ?? null, |
| 560 | isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false, |
| 561 | })), |
| 562 | truncated: labels.truncated, |
| 563 | }; |
| 564 | }, |
| 565 | ), |
| 566 | setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) { |
| 567 | const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel); |
| 568 | const selected = labels.items.filter((label) => input.labels.includes(label.name)); |
| 569 | if (selected.length !== input.labels.length) |
| 570 | return yield* failure("setLabels", "One or more requested labels could not be found."); |
| 571 | if (input.applied) |
| 572 | yield* write({ |
| 573 | ...input, |
| 574 | path: `${issuePath(input)}/labels`, |
| 575 | method: "POST", |
| 576 | body: { labels: selected.map((label) => label.id) }, |
| 577 | }); |
| 578 | else |
| 579 | yield* Effect.forEach( |
| 580 | selected, |
| 581 | (label) => |
| 582 | write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }), |
| 583 | { concurrency: 1 }, |
| 584 | ); |
| 585 | }), |
| 586 | setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) { |
| 587 | let commentId = input.subjectId; |
| 588 | if (commentId?.startsWith("review:")) { |
| 589 | const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1]; |
| 590 | if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID."); |
| 591 | const review = yield* read( |
| 592 | { ...input, path: `${pullPath(input)}/reviews/${reviewId}` }, |
| 593 | ForgejoReview, |
| 594 | ); |
| 595 | commentId = reviewCommentId(review); |
| 596 | if (!commentId) |
| 597 | return yield* failure( |
| 598 | "setReaction", |
| 599 | "Forgejo did not return a comment ID for this review.", |
| 600 | ); |
| 601 | } |
| 602 | if (commentId && !/^[1-9]\d*$/.test(commentId)) |
| 603 | return yield* failure("setReaction", "Invalid Forgejo comment ID."); |
| 604 | yield* write({ |
| 605 | ...input, |
| 606 | path: commentId |
| 607 | ? `${repoPath(input)}/issues/comments/${commentId}/reactions` |
| 608 | : `${issuePath(input)}/reactions`, |
| 609 | method: input.reacted ? "POST" : "DELETE", |
| 610 | body: { content: FORGEJO_REACTIONS[input.content] }, |
| 611 | }); |
| 612 | }), |
| 613 | replyToThread: () => unsupported("thread replies"), |
| 614 | setThreadResolution: () => unsupported("thread resolution"), |
| 615 | }; |
| 616 | return provider; |
| 617 | }); |