1import * as Schema from "effect/Schema";
2import * as DateTime from "effect/DateTime";
3import * as Option from "effect/Option";
4import type {
5 PullRequestActor,
6 PullRequestCheck,
7 PullRequestComment,
8 PullRequestCommit,
9 PullRequestReaction,
10 PullRequestReactionContent,
11 PullRequestReviewThread,
12} from "@t3tools/contracts";
13import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider";
14import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
15
16export const ForgejoUser = Schema.Struct({
17 login: Schema.String,
18 full_name: Schema.optional(Schema.NullOr(Schema.String)),
19 avatar_url: Schema.optional(Schema.NullOr(Schema.String)),
20});
21export const ForgejoLabel = Schema.Struct({
22 id: Schema.Int,
23 name: Schema.String,
24 color: Schema.optional(Schema.NullOr(Schema.String)),
25 description: Schema.optional(Schema.NullOr(Schema.String)),
26});
27export const ForgejoRepository = Schema.Struct({
28 full_name: Schema.String,
29 permissions: Schema.optional(Schema.Struct({ push: Schema.Boolean, admin: Schema.Boolean })),
30 archived: Schema.optional(Schema.Boolean),
31 allow_merge_commits: Schema.optional(Schema.Boolean),
32 allow_squash_merge: Schema.optional(Schema.Boolean),
33 allow_rebase: Schema.optional(Schema.Boolean),
34 allow_rebase_update: Schema.optional(Schema.Boolean),
35});
36const Branch = Schema.Struct({
37 ref: Schema.String,
38 sha: Schema.String,
39 repo: Schema.NullOr(ForgejoRepository),
40});
41export const ForgejoPullRequest = Schema.Struct({
42 number: Schema.Int,
43 title: Schema.String,
44 body: Schema.NullOr(Schema.String),
45 html_url: Schema.String,
46 user: Schema.NullOr(ForgejoUser),
47 state: Schema.String,
48 draft: Schema.optional(Schema.Boolean),
49 merged: Schema.Boolean,
50 mergeable: Schema.optional(Schema.Boolean),
51 is_locked: Schema.optional(Schema.Boolean),
52 head: Branch,
53 base: Branch,
54 merge_base: Schema.optional(Schema.String),
55 created_at: Schema.String,
56 updated_at: Schema.String,
57 closed_at: Schema.NullOr(Schema.String),
58 merged_at: Schema.NullOr(Schema.String),
59 additions: Schema.optional(Schema.NullOr(Schema.Int)),
60 deletions: Schema.optional(Schema.NullOr(Schema.Int)),
61 changed_files: Schema.optional(Schema.NullOr(Schema.Int)),
62 comments: Schema.optional(Schema.Int),
63 labels: Schema.NullOr(Schema.Array(ForgejoLabel)),
64 requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))),
65});
66export const ForgejoComment = Schema.Struct({
67 id: Schema.Int,
68 body: Schema.String,
69 user: Schema.NullOr(ForgejoUser),
70 created_at: Schema.String,
71 html_url: Schema.optional(Schema.String),
72});
73export const ForgejoReview = Schema.Struct({
74 id: Schema.Int,
75 body: Schema.String,
76 user: Schema.NullOr(ForgejoUser),
77 state: Schema.String,
78 submitted_at: Schema.String,
79 html_url: Schema.optional(Schema.String),
80 comments_count: Schema.Int,
81});
82export const ForgejoReviewComment = Schema.Struct({
83 ...ForgejoComment.fields,
84 path: Schema.String,
85 position: Schema.Int,
86 original_position: Schema.Int,
87 commit_id: Schema.String,
88 original_commit_id: Schema.String,
89 resolver: Schema.NullOr(ForgejoUser),
90});
91export const ForgejoCommit = Schema.Struct({
92 sha: Schema.String,
93 author: Schema.NullOr(ForgejoUser),
94 commit: Schema.Struct({
95 message: Schema.String,
96 committer: Schema.Struct({ date: Schema.String }),
97 }),
98 parents: Schema.Array(Schema.Struct({ sha: Schema.String })),
99 stats: Schema.optional(
100 Schema.NullOr(Schema.Struct({ additions: Schema.Int, deletions: Schema.Int })),
101 ),
102});
103export const ForgejoStatus = Schema.Struct({
104 context: Schema.String,
105 status: Schema.String,
106 description: Schema.NullOr(Schema.String),
107 target_url: Schema.NullOr(Schema.String),
108 updated_at: Schema.String,
109});
110export const ForgejoReaction = Schema.Struct({
111 content: Schema.String,
112 user: Schema.NullOr(ForgejoUser),
113});
114
115export function forgejoActor(
116 user: typeof ForgejoUser.Type | null | undefined,
117): PullRequestActor | null {
118 return user?.login
119 ? { login: user.login, name: user.full_name || null, avatarUrl: user.avatar_url || null }
120 : null;
121}
122
123function toIsoUtc(value: string): string {
124 return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso });
125}
126
127export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) {
128 return {
129 number: pr.number,
130 title: pr.title,
131 url: pr.html_url,
132 author: forgejoActor(pr.user),
133 headBranch: pr.head.ref,
134 baseBranch: pr.base.ref,
135 headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null,
136 state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open",
137 isDraft: pr.draft ?? /^(?:\[WIP\]|WIP:)/i.test(pr.title),
138 // A false value also covers drafts and unfinished or failed conflict checks.
139 mergeability: pr.mergeable === true ? "mergeable" : "unknown",
140 additions: pr.additions ?? 0,
141 deletions: pr.deletions ?? 0,
142 createdAt: toIsoUtc(pr.created_at),
143 updatedAt: toIsoUtc(pr.updated_at),
144 closedAt: pr.closed_at === null ? null : toIsoUtc(pr.closed_at),
145 mergedAt: pr.merged_at === null ? null : toIsoUtc(pr.merged_at),
146 reviewRequestLogins: (pr.requested_reviewers ?? []).map((user) => user.login),
147 labels: (pr.labels ?? []).map((label) => ({ name: label.name, color: label.color ?? null })),
148 } satisfies ProviderChangeRequest;
149}
150
151export function forgejoComment(comment: typeof ForgejoComment.Type): PullRequestComment {
152 return {
153 id: String(comment.id),
154 kind: "issue-comment",
155 author: forgejoActor(comment.user),
156 body: comment.body,
157 createdAt: toIsoUtc(comment.created_at),
158 url: comment.html_url || null,
159 path: null,
160 reviewState: null,
161 };
162}
163
164export function forgejoReview(review: typeof ForgejoReview.Type): PullRequestComment {
165 return {
166 id: `review:${review.id}`,
167 kind: "review",
168 author: forgejoActor(review.user),
169 body: review.body,
170 createdAt: toIsoUtc(review.submitted_at),
171 url: review.html_url || null,
172 path: null,
173 reviewState:
174 review.state === "REQUEST_CHANGES"
175 ? "CHANGES_REQUESTED"
176 : review.state === "COMMENT"
177 ? "COMMENTED"
178 : review.state,
179 };
180}
181
182export function forgejoReviewThread(
183 comment: typeof ForgejoReviewComment.Type,
184): PullRequestReviewThread {
185 const oldSide = comment.position === 0 && comment.original_position > 0;
186 const line = oldSide ? comment.original_position : comment.position;
187 return {
188 id: String(comment.id),
189 path: comment.path,
190 line: line > 0 ? line : null,
191 side: oldSide ? "left" : "right",
192 isResolved: comment.resolver !== null,
193 isOutdated: false,
194 comments: [forgejoComment(comment)],
195 };
196}
197
198export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCommit {
199 const author = forgejoActor(commit.author);
200 return {
201 oid: commit.sha,
202 messageHeadline: commit.commit.message.split("\n")[0] ?? "",
203 committedDate: toIsoUtc(commit.commit.committer.date),
204 authors: author ? [author] : [],
205 ...(commit.stats
206 ? { additions: commit.stats.additions, deletions: commit.stats.deletions }
207 : {}),
208 };
209}
210
211export function forgejoChecks(
212 statuses: ReadonlyArray<typeof ForgejoStatus.Type>,
213): ReadonlyArray<PullRequestCheck> {
214 return dedupeChecks(
215 statuses.map((status) => ({
216 workflowName: null,
217 at: status.updated_at,
218 check: {
219 name: status.context || "check",
220 description: status.description || null,
221 url: status.target_url || null,
222 status:
223 status.status === "success"
224 ? "success"
225 : status.status === "failure" || status.status === "error"
226 ? "failure"
227 : "pending",
228 },
229 })),
230 );
231}
232
233export const FORGEJO_REACTIONS: Record<PullRequestReactionContent, string> = {
234 "thumbs-up": "+1",
235 "thumbs-down": "-1",
236 laugh: "laugh",
237 hooray: "hooray",
238 confused: "confused",
239 heart: "heart",
240 rocket: "rocket",
241 eyes: "eyes",
242};
243export function forgejoReactions(
244 reactions: ReadonlyArray<typeof ForgejoReaction.Type>,
245 viewer: string,
246): ReadonlyArray<PullRequestReaction> {
247 return Object.entries(FORGEJO_REACTIONS).flatMap(([content, emoji]) => {
248 const matching = reactions.filter((reaction) => reaction.content === emoji);
249 return matching.length === 0
250 ? []
251 : [
252 {
253 content: content as PullRequestReactionContent,
254 count: matching.length,
255 actors: matching.flatMap((reaction) =>
256 reaction.user?.login && reaction.user.login !== viewer ? [reaction.user.login] : [],
257 ),
258 viewerHasReacted: matching.some((reaction) => reaction.user?.login === viewer),
259 },
260 ];
261 });
262}