import * as Cause from "effect/Cause";import * as Exit from "effect/Exit";import * as Result from "effect/Result";import * as Schema from "effect/Schema";import type { PullRequestActor, PullRequestCheck, PullRequestCheckStatus, PullRequestComment, PullRequestCommit, PullRequestLabel, PullRequestMergeability, PullRequestMergeCapabilities, PullRequestReaction, PullRequestReactionContent, PullRequestReviewThread, PullRequestReviewerCandidate, PullRequestState,} from "@t3tools/contracts";import { TrimmedNonEmptyString } from "@t3tools/contracts";import { decodeJsonResult } from "@t3tools/shared/schemaJson"; /** * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that * adds a pipeline status or a merge status must not fail the whole payload. */const RawUserSchema = Schema.Struct({ /** * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the * id is carried alongside the handle rather than looked up again when a review is asked for. */ id: Schema.optional(Schema.Int), username: Schema.String, name: Schema.optional(Schema.NullOr(Schema.String)), avatar_url: Schema.optional(Schema.NullOr(Schema.String)),}); const RawPipelineSchema = Schema.Struct({ status: Schema.optional(Schema.NullOr(Schema.String)), web_url: Schema.optional(Schema.NullOr(Schema.String)), source: Schema.optional(Schema.NullOr(Schema.String)),}); const RawMergeRequestSchema = Schema.Struct({ iid: Schema.Int, title: Schema.String, web_url: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawUserSchema)), source_branch: Schema.String, target_branch: Schema.String, state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), work_in_progress: Schema.optional(Schema.Boolean), merge_status: Schema.optional(Schema.NullOr(Schema.String)), has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), created_at: Schema.String, updated_at: Schema.String, merged_at: Schema.optional(Schema.NullOr(Schema.String)), closed_at: Schema.optional(Schema.NullOr(Schema.String)), reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. changes_count: Schema.optional(Schema.NullOr(Schema.String)), head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), /** * What the requesting account may do, which only the single-merge-request endpoint carries. * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts * for the role, the approval rules and a protected target branch — none of which a project's * access level on its own would tell apart. */ user: Schema.optional( Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), ), /** * Whether GitLab is holding this merge request to merge it once its pipeline goes green. * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so * either one saying yes is a yes. */ merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), /** * How far the target branch has moved on since this one left it, which is the same number * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a * single merge request — a list never carries it, however it is asked for. */ diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)),}); const RawNoteSchema = Schema.Struct({ id: Schema.Int, body: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawUserSchema)), created_at: Schema.String, /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ system: Schema.optional(Schema.Boolean), type: Schema.optional(Schema.NullOr(Schema.String)), position: Schema.optional( Schema.NullOr( Schema.Struct({ new_path: Schema.optional(Schema.NullOr(Schema.String)), old_path: Schema.optional(Schema.NullOr(Schema.String)), }), ), ),}); /** * A discussion note carrying its place in the diff, which is the shape the whole thread view * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a * discussion resolved once every resolvable note in it is. */const RawDiscussionNoteSchema = Schema.Struct({ id: Schema.Int, body: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawUserSchema)), created_at: Schema.String, system: Schema.optional(Schema.Boolean), resolvable: Schema.optional(Schema.Boolean), resolved: Schema.optional(Schema.NullOr(Schema.Boolean)), position: Schema.optional( Schema.NullOr( Schema.Struct({ position_type: Schema.optional(Schema.NullOr(Schema.String)), new_path: Schema.optional(Schema.NullOr(Schema.String)), old_path: Schema.optional(Schema.NullOr(Schema.String)), new_line: Schema.optional(Schema.NullOr(Schema.Int)), old_line: Schema.optional(Schema.NullOr(Schema.Int)), }), ), ),}); const RawDiscussionSchema = Schema.Struct({ id: Schema.String, notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))),}); const RawDiffRefsSchema = Schema.Struct({ diff_refs: Schema.optional( Schema.NullOr( Schema.Struct({ base_sha: Schema.String, head_sha: Schema.String, start_sha: Schema.String, }), ), ),}); const RawCommitSchema = Schema.Struct({ id: TrimmedNonEmptyString, title: Schema.optional(Schema.NullOr(Schema.String)), committed_date: Schema.optional(Schema.NullOr(Schema.String)), created_at: Schema.optional(Schema.NullOr(Schema.String)), parent_ids: Schema.optional(Schema.Array(Schema.String)), author_name: Schema.optional(Schema.NullOr(Schema.String)), author_email: Schema.optional(Schema.NullOr(Schema.String)), stats: Schema.optional( Schema.NullOr( Schema.Struct({ additions: Schema.optional(Schema.Int), deletions: Schema.optional(Schema.Int), }), ), ),}); const RawDiffSchema = Schema.Struct({ old_path: Schema.String, new_path: Schema.String, a_mode: Schema.optional(Schema.NullOr(Schema.String)), b_mode: Schema.optional(Schema.NullOr(Schema.String)), new_file: Schema.optional(Schema.Boolean), renamed_file: Schema.optional(Schema.Boolean), deleted_file: Schema.optional(Schema.Boolean), diff: Schema.optional(Schema.NullOr(Schema.String)), /** GitLab omits the hunks for a file it considers too large to inline. */ too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), /** And for one it collapsed, which withholds them the same way. */ collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)),}); const RawViewerSchema = Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)),}); /** A GitLab project settles on one merge strategy plus an optional squash. */const RawProjectMergeSettingsSchema = Schema.Struct({ merge_method: Schema.optional(Schema.NullOr(Schema.String)), squash_option: Schema.optional(Schema.NullOr(Schema.String)),}); export interface GitLabMergeRequestListItem { readonly number: number; readonly title: string; readonly url: string; readonly author: PullRequestActor | null; readonly headBranch: string; readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; /** * GitLab reports neither added nor removed lines on a merge request, so both stay zero and * the surface omits the stat. The Code tab counts them from the patch it already fetched. */ readonly additions: number; readonly deletions: number; readonly createdAt: string; readonly updatedAt: string; readonly reviewRequestLogins: ReadonlyArray<string>; readonly labels: ReadonlyArray<PullRequestLabel>;} export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { readonly body: string; readonly changedFiles: number; readonly mergedAt: string | null; readonly closedAt: string | null; readonly reviewers: ReadonlyArray<PullRequestActor>; readonly checks: ReadonlyArray<PullRequestCheck>; /** False only where GitLab said so; an answer without the field leaves merging permitted. */ readonly viewerCanMerge: boolean; /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ readonly reviewerIds: ReadonlyArray<number>; /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ readonly autoMergeEnabled?: boolean; /** * Absent where GitLab did not count, which is not the same as a branch that has nothing behind * it: an install too old to answer must not be read as saying the branch is current. */ readonly divergedCommits?: number;} function trimmed(value: string | null | undefined): string | null { const text = value?.trim() ?? ""; return text.length > 0 ? text : null;} function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) { const login = trimmed(raw?.username); return login === null ? null : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) };} function toState(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): PullRequestState { if (trimmed(raw.merged_at) !== null) return "merged"; switch (raw.state?.trim().toLowerCase()) { case "merged": return "merged"; case "closed": return "closed"; default: // `locked` is an open merge request whose discussion is locked. return "open"; }} function toMergeability( raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,): PullRequestMergeability { if (raw.has_conflicts === true) return "conflicting"; switch (raw.merge_status?.trim().toLowerCase()) { case "can_be_merged": return "mergeable"; case "cannot_be_merged": return "conflicting"; default: // `unchecked` and `checking` mean GitLab has not finished the merge check yet. return "unknown"; }} function toLabels(raw: ReadonlyArray<string> | null | undefined): ReadonlyArray<PullRequestLabel> { // GitLab returns label names only, so there is no colour to carry. return (raw ?? []).flatMap((label) => { const name = trimmed(label); return name === null ? [] : [{ name, color: null }]; });} /** * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is * the floor either way, which reads better than dropping an uncounted change set to nothing. */function toChangedFiles(value: string | null | undefined): number { const parsed = Number.parseInt(value?.trim() ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;} function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { switch (value?.trim().toLowerCase()) { case "success": return "success"; case "failed": return "failure"; case "canceled": case "cancelling": return "cancelled"; case "skipped": return "skipped"; // A pipeline waiting on a person is not progress, and it is not a failure either. case "manual": case "scheduled": return "neutral"; default: return "pending"; }} /** * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one * check. The jobs behind it stay one click away through the pipeline URL. */function toChecks( raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,): ReadonlyArray<PullRequestCheck> { const pipeline = raw.head_pipeline; if (!pipeline) return []; return [ { name: "Pipeline", status: toPipelineStatus(pipeline.status), description: trimmed(pipeline.source), url: trimmed(pipeline.web_url), }, ];} function toListItem( raw: Schema.Schema.Type<typeof RawMergeRequestSchema>,): GitLabMergeRequestListItem { return { number: raw.iid, title: raw.title, url: raw.web_url, author: toActor(raw.author), headBranch: raw.source_branch, baseBranch: raw.target_branch, state: toState(raw), isDraft: raw.draft ?? raw.work_in_progress ?? false, mergeability: toMergeability(raw), additions: 0, deletions: 0, createdAt: raw.created_at, updatedAt: raw.updated_at, reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { const login = trimmed(reviewer.username); return login === null ? [] : [login]; }), labels: toLabels(raw.labels), };} function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail { const listItem = toListItem(raw); const autoMerge = raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null ? undefined : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; return { ...listItem, body: raw.description ?? "", changedFiles: toChangedFiles(raw.changes_count), mergedAt: trimmed(raw.merged_at), closedAt: trimmed(raw.closed_at), // Built from the reviewers themselves rather than from their logins, so the avatars survive. reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { const actor = toActor(reviewer); return actor === null ? [] : [actor]; }), checks: toChecks(raw), viewerCanMerge: raw.user?.can_merge !== false, reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => reviewer.id === undefined ? [] : [reviewer.id], ), ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), };} const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown));const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema);const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema);const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema);const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema);const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema);const decodeCommit = decodeJsonResult(RawCommitSchema);const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema);const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema);const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema);const decodeViewer = decodeJsonResult(RawViewerSchema);const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); type DecodeFailure = Cause.Cause<Schema.SchemaError>; export interface GitLabProjectUsers { readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>; /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ readonly rawCount: number;} export interface GitLabMergeRequestListBatch { readonly items: ReadonlyArray<GitLabMergeRequestListItem>; /** Zero-based positions of the decoded items in GitLab's raw page. */ readonly rawIndexes: ReadonlyArray<number>; /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ readonly rawCount: number;} /** Malformed entries are skipped rather than failing the batch: one unexpected merge request * must not blank the whole list. */export function decodeMergeRequestListJson( raw: string,): Result.Result<GitLabMergeRequestListBatch, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const items: GitLabMergeRequestListItem[] = []; const rawIndexes: number[] = []; for (const [rawIndex, entry] of decoded.success.entries()) { const item = decodeMergeRequestEntry(entry); if (Exit.isSuccess(item)) { items.push(toListItem(item.value)); rawIndexes.push(rawIndex); } } return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length });} export function decodeMergeRequestDetailJson( raw: string,): Result.Result<GitLabMergeRequestDetail, DecodeFailure> { const decoded = decodeMergeRequest(raw); return Result.isSuccess(decoded) ? Result.succeed(toDetail(decoded.success)) : Result.fail(decoded.failure);} export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { const decoded = decodeViewer(raw); return Result.isSuccess(decoded) ? Result.succeed(trimmed(decoded.success.username)) : Result.fail(decoded.failure);} /** * The people with access to the project, which `GET /projects/:id/users` answers with — the same * list GitLab's own reviewer field is filled from, including the members a group above the project * lends it. A malformed row is skipped rather than failing the menu it belongs to. * * Nobody is marked requested here: who has been asked lives on the merge request, and only the * caller holds both. */export function decodeProjectUsersJson( raw: string,): Result.Result<GitLabProjectUsers, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const candidates: PullRequestReviewerCandidate[] = []; for (const entry of decoded.success) { const user = decodeUserEntry(entry); if (Exit.isFailure(user) || user.value.id === undefined) continue; const actor = toActor(user.value); if (actor === null) continue; candidates.push({ ...actor, id: String(user.value.id), kind: "user", isRequested: false, }); } return Result.succeed({ candidates, rawCount: decoded.success.length });} /** * GitLab settles the strategy per project rather than offering all three per merge request: * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a * separate switch. An unrecognized setting offers nothing rather than offering a strategy the * project forbids. */export function decodeProjectMergeCapabilitiesJson( raw: string,): Result.Result<PullRequestMergeCapabilities, DecodeFailure> { const decoded = decodeProjectMergeSettings(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); const squashOption = decoded.success.squash_option?.trim().toLowerCase(); return Result.succeed({ merge: mergeMethod === "merge", // Both semi-linear and fast-forward histories are reached by rebasing onto the target.Lines 1–500 of 940. Load more
import * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { PullRequestActor, PullRequestCheck, PullRequestCheckStatus, PullRequestComment, PullRequestCommit, PullRequestLabel, PullRequestMergeability, PullRequestMergeCapabilities, PullRequestReaction, PullRequestReactionContent, PullRequestReviewThread, PullRequestReviewerCandidate, PullRequestState, } from "@t3tools/contracts"; import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; /** * GitLab's REST enums are decoded as plain strings and normalized here: a GitLab release that * adds a pipeline status or a merge status must not fail the whole payload. */ const RawUserSchema = Schema.Struct({ /** * GitLab writes a merge request's reviewers as numeric ids and takes no usernames there, so the * id is carried alongside the handle rather than looked up again when a review is asked for. */ id: Schema.optional(Schema.Int), username: Schema.String, name: Schema.optional(Schema.NullOr(Schema.String)), avatar_url: Schema.optional(Schema.NullOr(Schema.String)), }); const RawPipelineSchema = Schema.Struct({ status: Schema.optional(Schema.NullOr(Schema.String)), web_url: Schema.optional(Schema.NullOr(Schema.String)), source: Schema.optional(Schema.NullOr(Schema.String)), }); const RawMergeRequestSchema = Schema.Struct({ iid: Schema.Int, title: Schema.String, web_url: Schema.String, description: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawUserSchema)), source_branch: Schema.String, target_branch: Schema.String, state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), work_in_progress: Schema.optional(Schema.Boolean), merge_status: Schema.optional(Schema.NullOr(Schema.String)), has_conflicts: Schema.optional(Schema.NullOr(Schema.Boolean)), created_at: Schema.String, updated_at: Schema.String, merged_at: Schema.optional(Schema.NullOr(Schema.String)), closed_at: Schema.optional(Schema.NullOr(Schema.String)), reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUserSchema))), labels: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))), // A string, and "1000+" past GitLab's counting limit, so it is parsed rather than decoded. changes_count: Schema.optional(Schema.NullOr(Schema.String)), head_pipeline: Schema.optional(Schema.NullOr(RawPipelineSchema)), /** * What the requesting account may do, which only the single-merge-request endpoint carries. * GitLab answers `can_merge` for this viewer against this merge request, so it already accounts * for the role, the approval rules and a protected target branch — none of which a project's * access level on its own would tell apart. */ user: Schema.optional( Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), ), /** * Whether GitLab is holding this merge request to merge it once its pipeline goes green. * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so * either one saying yes is a yes. */ merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), /** * How far the target branch has moved on since this one left it, which is the same number * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a * single merge request — a list never carries it, however it is asked for. */ diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), }); const RawNoteSchema = Schema.Struct({ id: Schema.Int, body: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawUserSchema)), created_at: Schema.String, /** True for notes GitLab writes itself ("assigned to…"), which are events, not comments. */ system: Schema.optional(Schema.Boolean), type: Schema.optional(Schema.NullOr(Schema.String)), position: Schema.optional( Schema.NullOr( Schema.Struct({ new_path: Schema.optional(Schema.NullOr(Schema.String)), old_path: Schema.optional(Schema.NullOr(Schema.String)), }), ), ), }); /** * A discussion note carrying its place in the diff, which is the shape the whole thread view * is built from. `resolved` lives on the note rather than on the discussion: GitLab calls a * discussion resolved once every resolvable note in it is. */ const RawDiscussionNoteSchema = Schema.Struct({ id: Schema.Int, body: Schema.optional(Schema.NullOr(Schema.String)), author: Schema.optional(Schema.NullOr(RawUserSchema)), created_at: Schema.String, system: Schema.optional(Schema.Boolean), resolvable: Schema.optional(Schema.Boolean), resolved: Schema.optional(Schema.NullOr(Schema.Boolean)), position: Schema.optional( Schema.NullOr( Schema.Struct({ position_type: Schema.optional(Schema.NullOr(Schema.String)), new_path: Schema.optional(Schema.NullOr(Schema.String)), old_path: Schema.optional(Schema.NullOr(Schema.String)), new_line: Schema.optional(Schema.NullOr(Schema.Int)), old_line: Schema.optional(Schema.NullOr(Schema.Int)), }), ), ), }); const RawDiscussionSchema = Schema.Struct({ id: Schema.String, notes: Schema.optional(Schema.NullOr(Schema.Array(RawDiscussionNoteSchema))), }); const RawDiffRefsSchema = Schema.Struct({ diff_refs: Schema.optional( Schema.NullOr( Schema.Struct({ base_sha: Schema.String, head_sha: Schema.String, start_sha: Schema.String, }), ), ), }); const RawCommitSchema = Schema.Struct({ id: TrimmedNonEmptyString, title: Schema.optional(Schema.NullOr(Schema.String)), committed_date: Schema.optional(Schema.NullOr(Schema.String)), created_at: Schema.optional(Schema.NullOr(Schema.String)), parent_ids: Schema.optional(Schema.Array(Schema.String)), author_name: Schema.optional(Schema.NullOr(Schema.String)), author_email: Schema.optional(Schema.NullOr(Schema.String)), stats: Schema.optional( Schema.NullOr( Schema.Struct({ additions: Schema.optional(Schema.Int), deletions: Schema.optional(Schema.Int), }), ), ), }); const RawDiffSchema = Schema.Struct({ old_path: Schema.String, new_path: Schema.String, a_mode: Schema.optional(Schema.NullOr(Schema.String)), b_mode: Schema.optional(Schema.NullOr(Schema.String)), new_file: Schema.optional(Schema.Boolean), renamed_file: Schema.optional(Schema.Boolean), deleted_file: Schema.optional(Schema.Boolean), diff: Schema.optional(Schema.NullOr(Schema.String)), /** GitLab omits the hunks for a file it considers too large to inline. */ too_large: Schema.optional(Schema.NullOr(Schema.Boolean)), /** And for one it collapsed, which withholds them the same way. */ collapsed: Schema.optional(Schema.NullOr(Schema.Boolean)), }); const RawViewerSchema = Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)), }); /** A GitLab project settles on one merge strategy plus an optional squash. */ const RawProjectMergeSettingsSchema = Schema.Struct({ merge_method: Schema.optional(Schema.NullOr(Schema.String)), squash_option: Schema.optional(Schema.NullOr(Schema.String)), }); export interface GitLabMergeRequestListItem { readonly number: number; readonly title: string; readonly url: string; readonly author: PullRequestActor | null; readonly headBranch: string; readonly baseBranch: string; readonly state: PullRequestState; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; /** * GitLab reports neither added nor removed lines on a merge request, so both stay zero and * the surface omits the stat. The Code tab counts them from the patch it already fetched. */ readonly additions: number; readonly deletions: number; readonly createdAt: string; readonly updatedAt: string; readonly reviewRequestLogins: ReadonlyArray<string>; readonly labels: ReadonlyArray<PullRequestLabel>; } export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { readonly body: string; readonly changedFiles: number; readonly mergedAt: string | null; readonly closedAt: string | null; readonly reviewers: ReadonlyArray<PullRequestActor>; readonly checks: ReadonlyArray<PullRequestCheck>; /** False only where GitLab said so; an answer without the field leaves merging permitted. */ readonly viewerCanMerge: boolean; /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ readonly reviewerIds: ReadonlyArray<number>; /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ readonly autoMergeEnabled?: boolean; /** * Absent where GitLab did not count, which is not the same as a branch that has nothing behind * it: an install too old to answer must not be read as saying the branch is current. */ readonly divergedCommits?: number; } function trimmed(value: string | null | undefined): string | null { const text = value?.trim() ?? ""; return text.length > 0 ? text : null; } function toActor(raw: Schema.Schema.Type<typeof RawUserSchema> | null | undefined) { const login = trimmed(raw?.username); return login === null ? null : { login, name: trimmed(raw?.name), avatarUrl: trimmed(raw?.avatar_url) }; } function toState(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): PullRequestState { if (trimmed(raw.merged_at) !== null) return "merged"; switch (raw.state?.trim().toLowerCase()) { case "merged": return "merged"; case "closed": return "closed"; default: // `locked` is an open merge request whose discussion is locked. return "open"; } } function toMergeability( raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, ): PullRequestMergeability { if (raw.has_conflicts === true) return "conflicting"; switch (raw.merge_status?.trim().toLowerCase()) { case "can_be_merged": return "mergeable"; case "cannot_be_merged": return "conflicting"; default: // `unchecked` and `checking` mean GitLab has not finished the merge check yet. return "unknown"; } } function toLabels(raw: ReadonlyArray<string> | null | undefined): ReadonlyArray<PullRequestLabel> { // GitLab returns label names only, so there is no colour to carry. return (raw ?? []).flatMap((label) => { const name = trimmed(label); return name === null ? [] : [{ name, color: null }]; }); } /** * "3" for a counted change set, "1000+" once GitLab gives up counting. The leading number is * the floor either way, which reads better than dropping an uncounted change set to nothing. */ function toChangedFiles(value: string | null | undefined): number { const parsed = Number.parseInt(value?.trim() ?? "", 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } function toPipelineStatus(value: string | null | undefined): PullRequestCheckStatus { switch (value?.trim().toLowerCase()) { case "success": return "success"; case "failed": return "failure"; case "canceled": case "cancelling": return "cancelled"; case "skipped": return "skipped"; // A pipeline waiting on a person is not progress, and it is not a failure either. case "manual": case "scheduled": return "neutral"; default: return "pending"; } } /** * GitLab has no per-job check list on a merge request, so its pipeline is reported as the one * check. The jobs behind it stay one click away through the pipeline URL. */ function toChecks( raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, ): ReadonlyArray<PullRequestCheck> { const pipeline = raw.head_pipeline; if (!pipeline) return []; return [ { name: "Pipeline", status: toPipelineStatus(pipeline.status), description: trimmed(pipeline.source), url: trimmed(pipeline.web_url), }, ]; } function toListItem( raw: Schema.Schema.Type<typeof RawMergeRequestSchema>, ): GitLabMergeRequestListItem { return { number: raw.iid, title: raw.title, url: raw.web_url, author: toActor(raw.author), headBranch: raw.source_branch, baseBranch: raw.target_branch, state: toState(raw), isDraft: raw.draft ?? raw.work_in_progress ?? false, mergeability: toMergeability(raw), additions: 0, deletions: 0, createdAt: raw.created_at, updatedAt: raw.updated_at, reviewRequestLogins: (raw.reviewers ?? []).flatMap((reviewer) => { const login = trimmed(reviewer.username); return login === null ? [] : [login]; }), labels: toLabels(raw.labels), }; } function toDetail(raw: Schema.Schema.Type<typeof RawMergeRequestSchema>): GitLabMergeRequestDetail { const listItem = toListItem(raw); const autoMerge = raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null ? undefined : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; return { ...listItem, body: raw.description ?? "", changedFiles: toChangedFiles(raw.changes_count), mergedAt: trimmed(raw.merged_at), closedAt: trimmed(raw.closed_at), // Built from the reviewers themselves rather than from their logins, so the avatars survive. reviewers: (raw.reviewers ?? []).flatMap((reviewer) => { const actor = toActor(reviewer); return actor === null ? [] : [actor]; }), checks: toChecks(raw), viewerCanMerge: raw.user?.can_merge !== false, reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => reviewer.id === undefined ? [] : [reviewer.id], ), ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), }; } const decodeUnknownList = decodeJsonResult(Schema.Array(Schema.Unknown)); const decodeMergeRequestEntry = Schema.decodeUnknownExit(RawMergeRequestSchema); const decodeMergeRequest = decodeJsonResult(RawMergeRequestSchema); const decodeNoteEntry = Schema.decodeUnknownExit(RawNoteSchema); const decodeUserEntry = Schema.decodeUnknownExit(RawUserSchema); const decodeCommitEntry = Schema.decodeUnknownExit(RawCommitSchema); const decodeCommit = decodeJsonResult(RawCommitSchema); const decodeDiffEntry = Schema.decodeUnknownExit(RawDiffSchema); const decodeDiscussionEntry = Schema.decodeUnknownExit(RawDiscussionSchema); const decodeDiffRefs = decodeJsonResult(RawDiffRefsSchema); const decodeViewer = decodeJsonResult(RawViewerSchema); const decodeProjectMergeSettings = decodeJsonResult(RawProjectMergeSettingsSchema); type DecodeFailure = Cause.Cause<Schema.SchemaError>; export interface GitLabProjectUsers { readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>; /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ readonly rawCount: number; } export interface GitLabMergeRequestListBatch { readonly items: ReadonlyArray<GitLabMergeRequestListItem>; /** Zero-based positions of the decoded items in GitLab's raw page. */ readonly rawIndexes: ReadonlyArray<number>; /** Rows GitLab returned, counted before decoding, so a skipped row cannot hide a next page. */ readonly rawCount: number; } /** Malformed entries are skipped rather than failing the batch: one unexpected merge request * must not blank the whole list. */ export function decodeMergeRequestListJson( raw: string, ): Result.Result<GitLabMergeRequestListBatch, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const items: GitLabMergeRequestListItem[] = []; const rawIndexes: number[] = []; for (const [rawIndex, entry] of decoded.success.entries()) { const item = decodeMergeRequestEntry(entry); if (Exit.isSuccess(item)) { items.push(toListItem(item.value)); rawIndexes.push(rawIndex); } } return Result.succeed({ items, rawIndexes, rawCount: decoded.success.length }); } export function decodeMergeRequestDetailJson( raw: string, ): Result.Result<GitLabMergeRequestDetail, DecodeFailure> { const decoded = decodeMergeRequest(raw); return Result.isSuccess(decoded) ? Result.succeed(toDetail(decoded.success)) : Result.fail(decoded.failure); } export function decodeViewerJson(raw: string): Result.Result<string | null, DecodeFailure> { const decoded = decodeViewer(raw); return Result.isSuccess(decoded) ? Result.succeed(trimmed(decoded.success.username)) : Result.fail(decoded.failure); } /** * The people with access to the project, which `GET /projects/:id/users` answers with — the same * list GitLab's own reviewer field is filled from, including the members a group above the project * lends it. A malformed row is skipped rather than failing the menu it belongs to. * * Nobody is marked requested here: who has been asked lives on the merge request, and only the * caller holds both. */ export function decodeProjectUsersJson( raw: string, ): Result.Result<GitLabProjectUsers, DecodeFailure> { const decoded = decodeUnknownList(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const candidates: PullRequestReviewerCandidate[] = []; for (const entry of decoded.success) { const user = decodeUserEntry(entry); if (Exit.isFailure(user) || user.value.id === undefined) continue; const actor = toActor(user.value); if (actor === null) continue; candidates.push({ ...actor, id: String(user.value.id), kind: "user", isRequested: false, }); } return Result.succeed({ candidates, rawCount: decoded.success.length }); } /** * GitLab settles the strategy per project rather than offering all three per merge request: * `merge_method` picks one of merge commit, semi-linear or fast-forward, and squashing is a * separate switch. An unrecognized setting offers nothing rather than offering a strategy the * project forbids. */ export function decodeProjectMergeCapabilitiesJson( raw: string, ): Result.Result<PullRequestMergeCapabilities, DecodeFailure> { const decoded = decodeProjectMergeSettings(raw); if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } const mergeMethod = decoded.success.merge_method?.trim().toLowerCase(); const squashOption = decoded.success.squash_option?.trim().toLowerCase(); return Result.succeed({ merge: mergeMethod === "merge", // Both semi-linear and fast-forward histories are reached by rebasing onto the target.