1import * as Cause from "effect/Cause";
2import * as DateTime from "effect/DateTime";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Result from "effect/Result";
6import * as Schema from "effect/Schema";
7import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
8import { decodeJsonResult } from "@t3tools/shared/schemaJson";
9
10export interface NormalizedGitLabMergeRequestRecord {
11 readonly number: number;
12 readonly title: string;
13 readonly url: string;
14 readonly baseRefName: string;
15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;
18 readonly closedAt?: string | null;
19 readonly mergedAt?: string | null;
20 readonly updatedAt: Option.Option<DateTime.Utc>;
21 readonly isCrossRepository?: boolean;
22 readonly headRepositoryNameWithOwner?: string | null;
23 readonly headRepositoryOwnerLogin?: string | null;
24}
25
26const GitLabProjectReferenceSchema = Schema.Struct({
27 path_with_namespace: Schema.optional(Schema.String),
28 pathWithNamespace: Schema.optional(Schema.String),
29 namespace: Schema.optional(
30 Schema.NullOr(
31 Schema.Struct({
32 path: Schema.optional(Schema.String),
33 full_path: Schema.optional(Schema.String),
34 fullPath: Schema.optional(Schema.String),
35 }),
36 ),
37 ),
38});
39
40const GitLabMergeRequestSchema = Schema.Struct({
41 iid: PositiveInt,
42 title: TrimmedNonEmptyString,
43 web_url: TrimmedNonEmptyString,
44 source_branch: TrimmedNonEmptyString,
45 target_branch: TrimmedNonEmptyString,
46 state: Schema.optional(Schema.NullOr(Schema.String)),
47 draft: Schema.optional(Schema.Boolean),
48 work_in_progress: Schema.optional(Schema.Boolean),
49 closed_at: Schema.optional(Schema.NullOr(Schema.String)),
50 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
51 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
52 source_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
53 target_project_id: Schema.optional(Schema.NullOr(Schema.Number)),
54 source_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
55 target_project: Schema.optional(Schema.NullOr(GitLabProjectReferenceSchema)),
56});
57
58function trimOptionalString(value: string | null | undefined): string | null {
59 const trimmed = value?.trim() ?? "";
60 return trimmed.length > 0 ? trimmed : null;
61}
62
63function normalizeGitLabMergeRequestState(
64 state: string | null | undefined,
65): "open" | "closed" | "merged" {
66 const normalized = state?.trim().toLowerCase();
67 if (normalized === "merged") {
68 return "merged";
69 }
70 if (normalized === "closed") {
71 return "closed";
72 }
73 return "open";
74}
75
76function projectPathWithNamespace(
77 project: Schema.Schema.Type<typeof GitLabProjectReferenceSchema> | null | undefined,
78): string | null {
79 const explicit =
80 trimOptionalString(project?.path_with_namespace) ??
81 trimOptionalString(project?.pathWithNamespace);
82 if (explicit) {
83 return explicit;
84 }
85
86 const namespacePath =
87 trimOptionalString(project?.namespace?.full_path) ??
88 trimOptionalString(project?.namespace?.fullPath) ??
89 trimOptionalString(project?.namespace?.path);
90 return namespacePath;
91}
92
93function ownerLoginFromPathWithNamespace(pathWithNamespace: string | null): string | null {
94 const [owner] = pathWithNamespace?.split("/") ?? [];
95 return trimOptionalString(owner);
96}
97
98function normalizeGitLabMergeRequestRecord(
99 raw: Schema.Schema.Type<typeof GitLabMergeRequestSchema>,
100): NormalizedGitLabMergeRequestRecord {
101 const sourceProjectPath = projectPathWithNamespace(raw.source_project);
102 const targetProjectPath = projectPathWithNamespace(raw.target_project);
103 const isCrossRepository =
104 typeof raw.source_project_id === "number" && typeof raw.target_project_id === "number"
105 ? raw.source_project_id !== raw.target_project_id
106 : sourceProjectPath !== null && targetProjectPath !== null
107 ? sourceProjectPath.toLowerCase() !== targetProjectPath.toLowerCase()
108 : undefined;
109 const headRepositoryOwnerLogin = ownerLoginFromPathWithNamespace(sourceProjectPath);
110
111 return {
112 number: raw.iid,
113 title: raw.title,
114 url: raw.web_url,
115 baseRefName: raw.target_branch,
116 headRefName: raw.source_branch,
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}