1import {
2 trimOptionalString as trimmed,
3 toIsoUtc,
4} from "@t3tools/source-control-core/server/pullRequestUtils";
5import * as Cause from "effect/Cause";
6import * as Exit from "effect/Exit";
7import * as Result from "effect/Result";
8import * as Schema from "effect/Schema";
9import type {
10 PullRequestActor,
11 PullRequestCheck,
12 PullRequestCheckStatus,
13 PullRequestComment,
14 PullRequestCommit,
15 PullRequestMergeability,
16 PullRequestReviewThread,
17 PullRequestReviewerCandidate,
18 PullRequestState,
19} from "@t3tools/contracts";
20import { TrimmedNonEmptyString } from "@t3tools/contracts";
21import { decodeJsonResult } from "@t3tools/shared/schemaJson";
22
23import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
24
25/** Unknown enum values must not fail the whole payload. */
26const RawUserSchema = Schema.Struct({
27 /** Reviewer writes require UUIDs, not displayed handles. */
28 uuid: Schema.optional(Schema.NullOr(Schema.String)),
29 /** App accounts omit this; use `display_name`. */
30 nickname: Schema.optional(Schema.NullOr(Schema.String)),
31 display_name: Schema.optional(Schema.NullOr(Schema.String)),
32 links: Schema.optional(
33 Schema.NullOr(
34 Schema.Struct({
35 avatar: Schema.optional(
36 Schema.NullOr(Schema.Struct({ href: Schema.optional(Schema.String) })),
37 ),
38 }),
39 ),
40 ),
41});
42
43const RawBranchSchema = Schema.Struct({
44 branch: Schema.Struct({ name: TrimmedNonEmptyString }),
45 commit: Schema.optional(
46 Schema.NullOr(Schema.Struct({ hash: Schema.optional(Schema.NullOr(Schema.String)) })),
47 ),
48 repository: Schema.optional(Schema.NullOr(Schema.Struct({ full_name: TrimmedNonEmptyString }))),
49});
50
51const RawLinkSchema = Schema.Struct({ href: Schema.optional(Schema.String) });
52
53const RawPullRequestSchema = Schema.Struct({
54 id: Schema.Int,
55 title: Schema.String,
56 description: Schema.optional(Schema.NullOr(Schema.String)),
57 state: Schema.optional(Schema.NullOr(Schema.String)),
58 draft: Schema.optional(Schema.Boolean),
59 author: Schema.optional(Schema.NullOr(RawUserSchema)),
60 source: RawBranchSchema,
61 destination: RawBranchSchema,
62 created_on: Schema.String,
63 updated_on: Schema.String,
64 /** Only sent when asked for with `fields=+closed_on`; null while the request is open. */
65 closed_on: Schema.optional(Schema.NullOr(Schema.String)),
66 reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))),
67 participants: Schema.optional(
68 Schema.NullOr(
69 Schema.Array(
70 Schema.Struct({
71 user: Schema.optional(Schema.NullOr(RawUserSchema)),
72 role: Schema.optional(Schema.NullOr(Schema.String)),
73 approved: Schema.optional(Schema.Boolean),
74 state: Schema.optional(Schema.NullOr(Schema.String)),
75 participated_on: Schema.optional(Schema.NullOr(Schema.String)),
76 }),
77 ),
78 ),
79 ),
80 links: Schema.Struct({ html: Schema.Struct({ href: TrimmedNonEmptyString }) }),
81});
82
83const RawPageSchema = Schema.Struct({
84 values: Schema.Array(Schema.Unknown),
85 size: Schema.optional(Schema.NullOr(Schema.Int)),
86 next: Schema.optional(Schema.NullOr(Schema.String)),
87});
88
89const RawCommentSchema = Schema.Struct({
90 id: Schema.Int,
91 content: Schema.optional(Schema.NullOr(Schema.Struct({ raw: Schema.optional(Schema.String) }))),
92 user: Schema.optional(Schema.NullOr(RawUserSchema)),
93 created_on: Schema.String,
94 deleted: Schema.optional(Schema.Boolean),
95 pending: Schema.optional(Schema.Boolean),
96 parent: Schema.optional(Schema.NullOr(Schema.Struct({ id: Schema.Int }))),
97 inline: Schema.optional(
98 Schema.NullOr(
99 Schema.Struct({
100 path: Schema.optional(Schema.NullOr(Schema.String)),
101 from: Schema.optional(Schema.NullOr(Schema.Int)),
102 to: Schema.optional(Schema.NullOr(Schema.Int)),
103 outdated: Schema.optional(Schema.NullOr(Schema.Boolean)),
104 }),
105 ),
106 ),
107 resolution: Schema.optional(Schema.NullOr(Schema.Unknown)),
108 links: Schema.optional(
109 Schema.NullOr(Schema.Struct({ html: Schema.optional(Schema.NullOr(RawLinkSchema)) })),
110 ),
111});
112
113const RawCommitSchema = Schema.Struct({
114 hash: TrimmedNonEmptyString,
115 message: Schema.optional(Schema.NullOr(Schema.String)),
116 date: Schema.optional(Schema.NullOr(Schema.String)),
117 author: Schema.optional(
118 Schema.NullOr(
119 Schema.Struct({
120 raw: Schema.optional(Schema.NullOr(Schema.String)),
121 user: Schema.optional(Schema.NullOr(RawUserSchema)),
122 }),
123 ),
124 ),
125});
126
127const RawStatusSchema = Schema.Struct({
128 key: Schema.optional(Schema.NullOr(Schema.String)),
129 name: Schema.optional(Schema.NullOr(Schema.String)),
130 state: Schema.optional(Schema.NullOr(Schema.String)),
131 description: Schema.optional(Schema.NullOr(Schema.String)),
132 url: Schema.optional(Schema.NullOr(Schema.String)),
133});
134
135const RawDiffstatSchema = Schema.Struct({
136 lines_added: Schema.optional(Schema.NullOr(Schema.Int)),
137 lines_removed: Schema.optional(Schema.NullOr(Schema.Int)),
138});
139
140const RawMemberSchema = Schema.Struct({
141 user: Schema.optional(Schema.NullOr(RawUserSchema)),
142});
143
144const RawViewerSchema = Schema.Struct({
145 nickname: Schema.optional(Schema.NullOr(Schema.String)),
146 display_name: Schema.optional(Schema.NullOr(Schema.String)),
147});
148
149const RawRepositoryPermissionsSchema = Schema.Struct({
150 values: Schema.optional(
151 Schema.NullOr(
152 Schema.Array(Schema.Struct({ permission: Schema.optional(Schema.NullOr(Schema.String)) })),
153 ),
154 ),
155});
156
157export interface BitbucketPullRequest {
158 readonly number: number;
159 readonly title: string;
160 readonly url: string;
161 readonly author: PullRequestActor | null;
162 readonly headBranch: string;
163 readonly headSha: string | null;
164 readonly headRepositoryNameWithOwner: string | null;
165 readonly baseBranch: string;
166 readonly state: PullRequestState;
167 readonly isDraft: boolean;
168 /** Listings omit conflict state; detail reads use the conflicts endpoint. */
169 readonly mergeability: PullRequestMergeability;
170 readonly createdAt: string;
171 readonly updatedAt: string;
172 /** Merge/decline time; later comments move `updatedAt`. */
173 readonly closedAt: string | null;
174 readonly body: string;
175 readonly reviewRequestLogins: ReadonlyArray<string>;
176 readonly reviewers: ReadonlyArray<PullRequestActor>;
177 readonly reviewerIds: ReadonlyArray<string>;
178 readonly reviews: ReadonlyArray<PullRequestComment>;
179}
180
181function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) {
182 const login = trimmed(raw?.nickname) ?? trimmed(raw?.display_name);
183 return login === null
184 ? null
185 : {
186 login,
187 name: trimmed(raw?.display_name),
188 avatarUrl: trimmed(raw?.links?.avatar?.href),
189 };
190}
191
192function toState(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): PullRequestState {
193 switch (raw.state?.trim().toUpperCase()) {
194 case "MERGED":
195 return "merged";
196 case "DECLINED":
197 case "SUPERSEDED":
198 return "closed";
199 default:
200 return "open";
201 }
202}
203
204function toBuildStatus(value: string | null | undefined): PullRequestCheckStatus {
205 switch (value?.trim().toUpperCase()) {
206 case "SUCCESSFUL":
207 return "success";
208 case "FAILED":
209 return "failure";
210 case "STOPPED":
211 return "cancelled";
212 case "INPROGRESS":
213 return "pending";
214 default:
215 return "neutral";
216 }
217}
218
219function toReviews(
220 raw: Schema.Schema.Type<typeof RawPullRequestSchema>,
221): ReadonlyArray<PullRequestComment> {
222 return (raw.participants ?? []).flatMap((participant): ReadonlyArray<PullRequestComment> => {
223 const author = toActor(participant.user);
224 const votedAt = trimmed(participant.participated_on);
225 const reviewState =
226 trimmed(participant.state) ?? (participant.approved === true ? "approved" : null);
227 if (author === null || votedAt === null || reviewState === null) return [];
228 return [
229 {
230 id: `${raw.id}:${author.login}`,
231 kind: "review",
232 author,
233 body: "",
234 createdAt: toIsoUtc(votedAt),
235 url: null,
236 path: null,
237 reviewState,
238 },
239 ];
240 });
241}
242
243function toPullRequest(raw: Schema.Schema.Type<typeof RawPullRequestSchema>): BitbucketPullRequest {
244 const reviewers = (raw.reviewers ?? []).flatMap((reviewer) => {
245 const actor = toActor(reviewer);
246 return actor === null ? [] : [actor];
247 });
248 return {
249 number: raw.id,
250 title: raw.title,
251 url: raw.links.html.href,
252 author: toActor(raw.author),
253 headBranch: raw.source.branch.name,
254 headSha: trimmed(raw.source.commit?.hash),
255 headRepositoryNameWithOwner: raw.source.repository?.full_name ?? null,
256 baseBranch: raw.destination.branch.name,
257 state: toState(raw),
258 isDraft: raw.draft ?? false,
259 mergeability: "unknown",
260 createdAt: toIsoUtc(raw.created_on),
261 updatedAt: toIsoUtc(raw.updated_on),
262 closedAt: raw.closed_on == null ? null : toIsoUtc(raw.closed_on),
263 body: raw.description ?? "",
264 reviewRequestLogins: reviewers.map((reviewer) => reviewer.login),
265 reviewers,
266 reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => trimmed(reviewer.uuid) ?? []),
267 reviews: toReviews(raw),
268 };
269}
270
271const decodePage = decodeJsonResult(RawPageSchema);
272const decodePullRequestEntry = Schema.decodeUnknownExit(RawPullRequestSchema);
273const decodePullRequest = decodeJsonResult(RawPullRequestSchema);
274const decodeCommentEntry = Schema.decodeUnknownExit(RawCommentSchema);
275const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);
276const decodeStatusEntry = Schema.decodeUnknownExit(RawStatusSchema);
277const decodeDiffstatEntry = Schema.decodeUnknownExit(RawDiffstatSchema);
278const decodeMemberEntry = Schema.decodeUnknownExit(RawMemberSchema);
279const decodeViewer = decodeJsonResult(RawViewerSchema);
280const decodeConflicts = decodeJsonResult(RawPageSchema);
281const decodeRepositoryPermissions = decodeJsonResult(RawRepositoryPermissionsSchema);
282
283type DecodeFailure = Cause.Cause<Schema.SchemaError>;
284
285export interface BitbucketPage<A> {
286 readonly items: ReadonlyArray<A>;
287 readonly next: string | null;
288}
289
290export function decodePullRequestPageJson(
291 raw: string,
292): Result.Result<BitbucketPage<BitbucketPullRequest>, DecodeFailure> {
293 const decoded = decodePage(raw);
294 if (!Result.isSuccess(decoded)) {
295 return Result.fail(decoded.failure);
296 }
297 const items: BitbucketPullRequest[] = [];
298 for (const entry of decoded.success.values) {
299 const item = decodePullRequestEntry(entry);
300 if (Exit.isSuccess(item)) {
301 items.push(toPullRequest(item.value));
302 }
303 }
304 return Result.succeed({ items, next: trimmed(decoded.success.next) });
305}
306
307export function decodePullRequestJson(
308 raw: string,
309): Result.Result<BitbucketPullRequest, DecodeFailure> {
310 const decoded = decodePullRequest(raw);
311 return Result.isSuccess(decoded)
312 ? Result.succeed(toPullRequest(decoded.success))
313 : Result.fail(decoded.failure);
314}
315
316export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> {
317 const decoded = decodeViewer(raw);
318 return Result.isSuccess(decoded)
319 ? Result.succeed(trimmed(decoded.success.nickname) ?? trimmed(decoded.success.display_name))
320 : Result.fail(decoded.failure);
321}
322
323export function decodeRepositoryPermissionJson(raw: string): Result.Result<boolean, DecodeFailure> {
324 const decoded = decodeRepositoryPermissions(raw);
325 if (!Result.isSuccess(decoded)) {
326 return Result.fail(decoded.failure);
327 }
328 const permission = trimmed(decoded.success.values?.[0]?.permission)?.toLowerCase() ?? null;
329 return Result.succeed(permission === null || permission === "admin" || permission === "write");
330}
331
332/** Repository membership requires admin access; reviews can target any workspace member. */
333export function decodeWorkspaceMembersJson(
334 raw: string,
335): Result.Result<BitbucketPage<PullRequestReviewerCandidate>, DecodeFailure> {
336 const decoded = decodePage(raw);
337 if (!Result.isSuccess(decoded)) {
338 return Result.fail(decoded.failure);
339 }
340 const items: PullRequestReviewerCandidate[] = [];
341 for (const entry of decoded.success.values) {
342 const member = decodeMemberEntry(entry);
343 if (Exit.isFailure(member)) continue;
344 const uuid = trimmed(member.value.user?.uuid);
345 const actor = toActor(member.value.user);
346 if (uuid === null || actor === null) continue;
347 items.push({ ...actor, id: uuid, kind: "user", isRequested: false });
348 }
349 return Result.succeed({ items, next: trimmed(decoded.success.next) });
350}
351
352export type BitbucketRawComment = Schema.Schema.Type<typeof RawCommentSchema>;
353
354export interface BitbucketComments {
355 readonly comments: ReadonlyArray<PullRequestComment>;
356 /** Raw entries for thread assembly across page boundaries. */
357 readonly entries: ReadonlyArray<BitbucketRawComment>;
358 readonly next: string | null;
359}
360
361export function buildReviewThreads(
362 comments: ReadonlyArray<BitbucketRawComment>,
363): ReadonlyArray<PullRequestReviewThread> {
364 const byId = new Map(comments.map((comment) => [comment.id, comment]));
365 const rootOf = (comment: Schema.Schema.Type<typeof RawCommentSchema>) => {
366 let current = comment;
367 for (let step = 0; step < byId.size; step += 1) {
368 const parent = current.parent === null ? undefined : byId.get(current.parent?.id ?? -1);
369 if (parent === undefined) return current;
370 current = parent;
371 }
372 return current;
373 };
374
375 const threads = new Map<number, PullRequestReviewThread>();
376 const replies = new Map<number, Array<Schema.Schema.Type<typeof RawCommentSchema>>>();
377 for (const comment of comments) {
378 const root = rootOf(comment);
379 const inline = root.inline;
380 const path = trimmed(inline?.path);
381 if (path === null) continue;
382 if (root.id === comment.id) {
383 const side = inline?.to === null || inline?.to === undefined ? "left" : "right";
384 const line = side === "left" ? inline?.from : inline?.to;
385 threads.set(root.id, {
386 id: String(root.id),
387 path,
388 line: typeof line === "number" && line > 0 ? line : null,
389 side,
390 isResolved: root.resolution !== null && root.resolution !== undefined,
391 isOutdated: inline?.outdated === true,
392 comments: [],
393 });
394 }
395 const bucket = replies.get(root.id);
396 if (bucket === undefined) replies.set(root.id, [comment]);
397 else bucket.push(comment);
398 }
399
400 return [...threads.values()].flatMap((thread) => {
401 const entries = (replies.get(Number(thread.id)) ?? [])
402 .toSorted((left, right) => left.created_on.localeCompare(right.created_on))
403 .map((comment) => ({
404 id: String(comment.id),
405 author: toActor(comment.user),
406 body: comment.content?.raw ?? "",
407 createdAt: toIsoUtc(comment.created_on),
408 url: trimmed(comment.links?.html?.href),
409 }));
410 return entries.length === 0 ? [] : [{ ...thread, comments: entries }];
411 });
412}
413
414export function decodeCommentsJson(raw: string): Result.Result<BitbucketComments, DecodeFailure> {
415 const decoded = decodePage(raw);
416 if (!Result.isSuccess(decoded)) {
417 return Result.fail(decoded.failure);
418 }
419 const comments: PullRequestComment[] = [];
420 const kept: Array<BitbucketRawComment> = [];
421 for (const entry of decoded.success.values) {
422 const decodedComment = decodeCommentEntry(entry);
423 if (Exit.isFailure(decodedComment)) continue;
424 const comment = decodedComment.value;
425 if (comment.deleted === true || comment.pending === true) continue;
426 const body = comment.content?.raw ?? "";
427 if (body.trim().length === 0) continue;
428 kept.push(comment);
429 const path = trimmed(comment.inline?.path);
430 comments.push({
431 id: String(comment.id),
432 kind: path === null ? "issue-comment" : "review-comment",
433 author: toActor(comment.user),
434 body,
435 createdAt: toIsoUtc(comment.created_on),
436 url: trimmed(comment.links?.html?.href),
437 path,
438 reviewState: null,
439 });
440 }
441 return Result.succeed({ comments, entries: kept, next: trimmed(decoded.success.next) });
442}
443
444export function decodeCommitsJson(
445 raw: string,
446): Result.Result<BitbucketPage<PullRequestCommit>, DecodeFailure> {
447 const decoded = decodePage(raw);
448 if (!Result.isSuccess(decoded)) {
449 return Result.fail(decoded.failure);
450 }
451 const commits: PullRequestCommit[] = [];
452 for (const entry of decoded.success.values) {
453 const decodedCommit = decodeCommitEntry(entry);
454 if (Exit.isFailure(decodedCommit)) continue;
455 const commit = decodedCommit.value;
456 const committedDate = trimmed(commit.date);
457 if (committedDate === null) continue;
458 const linkedAuthor = toActor(commit.author?.user);
459 const rawAuthor = trimmed(commit.author?.raw);
460 commits.push({
461 oid: commit.hash,
462 messageHeadline: (commit.message ?? "").split("\n")[0] ?? "",
463 committedDate: toIsoUtc(committedDate),
464 authors:
465 linkedAuthor !== null
466 ? [linkedAuthor]
467 : rawAuthor === null
468 ? []
469 : [{ login: rawAuthor, name: rawAuthor, avatarUrl: null }],
470 });
471 }
472 // Bitbucket lists a pull request's commits newest first; the timeline reads oldest first.
473 return Result.succeed({ items: commits.toReversed(), next: trimmed(decoded.success.next) });
474}
475
476export function decodeStatusesJson(
477 raw: string,
478): Result.Result<BitbucketPage<PullRequestCheck>, DecodeFailure> {
479 const decoded = decodePage(raw);
480 if (!Result.isSuccess(decoded)) {
481 return Result.fail(decoded.failure);
482 }
483 const checks: Array<{
484 readonly check: PullRequestCheck;
485 readonly workflowName: string | null;
486 readonly at: string | null;
487 }> = [];
488 for (const entry of decoded.success.values) {
489 const decodedStatus = decodeStatusEntry(entry);
490 if (Exit.isFailure(decodedStatus)) continue;
491 const status = decodedStatus.value;
492 const name = trimmed(status.name) ?? trimmed(status.key);
493 if (name === null) continue;
494 // Reruns reuse keys; later entries win. Keys distinguish same-named pipelines.
495 checks.push({
496 check: {
497 name,
498 status: toBuildStatus(status.state),
499 description: trimmed(status.description),
500 url: trimmed(status.url),
501 },
502 workflowName: trimmed(status.key),
503 at: null,
504 });
505 }
506 return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) });
507}
508
509export interface BitbucketDiffStat {
510 readonly additions: number;
511 readonly deletions: number;
512 readonly changedFiles: number;
513}
514
515export interface BitbucketDiffStatPage extends BitbucketDiffStat {
516 readonly next: string | null;
517}
518
519export function decodeDiffstatJson(
520 raw: string,
521): Result.Result<BitbucketDiffStatPage, DecodeFailure> {
522 const decoded = decodePage(raw);
523 if (!Result.isSuccess(decoded)) {
524 return Result.fail(decoded.failure);
525 }
526 let additions = 0;
527 let deletions = 0;
528 let changedFiles = 0;
529 for (const entry of decoded.success.values) {
530 const decodedStat = decodeDiffstatEntry(entry);
531 if (Exit.isFailure(decodedStat)) continue;
532 additions += decodedStat.value.lines_added ?? 0;
533 deletions += decodedStat.value.lines_removed ?? 0;
534 changedFiles += 1;
535 }
536 return Result.succeed({
537 additions,
538 deletions,
539 changedFiles,
540 next: trimmed(decoded.success.next),
541 });
542}
543
544export function decodeConflictsJson(
545 raw: string,
546): Result.Result<PullRequestMergeability, DecodeFailure> {
547 const decoded = decodeConflicts(raw);
548 return Result.isSuccess(decoded)
549 ? Result.succeed(decoded.success.values.length === 0 ? "mergeable" : "conflicting")
550 : Result.fail(decoded.failure);
551}