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