1import { trimOptionalString } from "@t3tools/source-control-core/server/pullRequestUtils";
2import * as Cause from "effect/Cause";
3import * as DateTime from "effect/DateTime";
4import * as Exit from "effect/Exit";
5import * as Option from "effect/Option";
6import * as Result from "effect/Result";
7import * as Schema from "effect/Schema";
8import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
9import { decodeJsonResult } from "@t3tools/shared/schemaJson";
10
11export interface NormalizedGitLabMergeRequestRecord {
12 readonly number: number;
13 readonly title: string;
14 readonly url: string;
15 readonly baseRefName: string;
16 readonly headRefName: string;
17 readonly headSha?: string;
18 readonly state: "open" | "closed" | "merged";
19 readonly isDraft?: boolean;
20 readonly closedAt?: string | null;
21 readonly mergedAt?: string | null;
22 readonly updatedAt: Option.Option<DateTime.Utc>;
23 readonly isCrossRepository?: boolean;
24 readonly headRepositoryNameWithOwner?: string | null;
25 readonly headRepositoryOwnerLogin?: string | null;
26}
27
28const GitLabProjectReferenceSchema = Schema.Struct({
29 path_with_namespace: Schema.optional(Schema.String),
30 pathWithNamespace: Schema.optional(Schema.String),
31 namespace: Schema.optional(
32 Schema.NullOr(
33 Schema.Struct({
34 path: Schema.optional(Schema.String),
35 full_path: Schema.optional(Schema.String),
36 fullPath: Schema.optional(Schema.String),
37 }),
38 ),
39 ),
40});
41
42const GitLabMergeRequestSchema = Schema.Struct({
43 iid: PositiveInt,
44 title: TrimmedNonEmptyString,
45 web_url: TrimmedNonEmptyString,
46 source_branch: TrimmedNonEmptyString,
47 target_branch: TrimmedNonEmptyString,
48 sha: Schema.optional(Schema.NullOr(Schema.String)),
49 state: Schema.optional(Schema.NullOr(Schema.String)),
50 draft: Schema.optional(Schema.Boolean),
51 work_in_progress: Schema.optional(Schema.Boolean),
52 closed_at: Schema.optional(Schema.NullOr(Schema.String)),
53 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
54 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
55 source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
56 target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
57 source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
58 target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
59});
60
61function normalizeGitLabMergeRequestState(
62 state: string | null | undefined,
63): "open" | "closed" | "merged" {
64 const normalized = state?.trim().toLowerCase();
65 if (normalized === "merged") {
66 return "merged";
67 }
68 if (normalized === "closed") {
69 return "closed";
70 }
71 return "open";
72}
73
74function projectPathWithNamespace(
75 project: Schema.Schema.Type<typeof GitLabProjectReferenceSchema> | null | undefined,
76): string | null {
77 const explicit =
78 trimOptionalString(project?.path_with_namespace) ??
79 trimOptionalString(project?.pathWithNamespace);
80 if (explicit) {
81 return explicit;
82 }
83
84 const namespacePath =
85 trimOptionalString(project?.namespace?.full_path) ??
86 trimOptionalString(project?.namespace?.fullPath) ??
87 trimOptionalString(project?.namespace?.path);
88 return namespacePath;
89}
90
91function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null {
92 const [owner] = pathWithNamespace?.split("/") ?? [];
93 return trimOptionalString(owner);
94}
95
96function normalizeGitLabMergeRequestRecord(
97 raw: Schema.Schema.Type<typeof GitLabMergeRequestSchema>,
98): NormalizedGitLabMergeRequestRecord {
99 const sourceProjectPath = projectPathWithNamespace(raw.source_project);
100 const targetProjectPath = projectPathWithNamespace(raw.target_project);
101 const isCrossRepository =
102 typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number"
103 ? raw.source_project_id !== raw.target_project_id
104 : sourceProjectPath !== null && targetProjectPath !== null
105 ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase()
106 : undefined;
107 const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath);
108 const headSha = trimOptionalString(raw.sha);
109
110 return {
111 number: raw.iid,
112 title: raw.title,
113 url: raw.web_url,
114 baseRefName: raw.target_branch,
115 headRefName: raw.source_branch,
116 ...(headSha ? { headSha } : {}),
117 state: normalizeGitLabMergeRequestState(raw.state),
118 ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}),
119 closedAt: raw.closed_at ?? null,
120 mergedAt: raw.merged_at ?? null,
121 updatedAt: raw.updated_at ?? Option.none(),
122 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
123 ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}),
124 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
125 };
126}
127
128const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
129const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema);
130const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema);
131
132export function decodeGitLabMergeRequestListJson(
133 raw: string,
134): Result.Result<
135 ReadonlyArray<NormalizedGitLabMergeRequestRecord>,
136 Cause.Cause<Schema.SchemaError>
137> {
138 const result = decodeGitLabMergeRequestList(raw);
139 if (Result.isSuccess(result)) {
140 const mergeRequests: NormalizedGitLabMergeRequestRecord[] = [];
141 for (const entry of result.success) {
142 const decodedEntry = decodeGitLabMergeRequestEntry(entry);
143 if (Exit.isFailure(decodedEntry)) {
144 continue;
145 }
146 mergeRequests.push(normalizeGitLabMergeRequestRecord(decodedEntry.value));
147 }
148 return Result.succeed(mergeRequests);
149 }
150 return Result.fail(result.failure);
151}
152
153export function decodeGitLabMergeRequestJson(
154 raw: string,
155): Result.Result<NormalizedGitLabMergeRequestRecord, Cause.Cause<Schema.SchemaError>> {
156 const result = decodeGitLabMergeRequest(raw);
157 if (Result.isSuccess(result)) {
158 return Result.succeed(normalizeGitLabMergeRequestRecord(result.success));
159 }
160 return Result.fail(result.failure);
161}