Files
orca/src/main/gitlab/mappers.ts
T
Neil 77f23b013f refactor(shared): drop the shared/types barrel and import from the real modules (#14447)
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as
a re-export barrel so the import sites did not have to change. This removes
the barrel: every consumer now imports from the module that actually declares
the type, and `src/shared/types.ts` is deleted.

Barrels hide where a type lives, make every consumer look like it depends on
the whole domain, and let an unrelated edit invalidate a module that ~2,000
files transitively import.

2,323 import declarations across 2,321 files. Rewritten mechanically: each
specifier was resolved to an absolute path via the TypeScript AST and
recomputed, rather than string-substituted, so alias forms (`@/../../shared/
types`) and per-specifier `type` modifiers survive.

Four cases the mechanical pass had to handle, each found by a gate rather than
by reading the diff:

- Modules inside `src/shared` import the barrel as `./types`, not
  `shared/types`. A pre-filter on the latter string skipped 176 of them and
  left imports dangling at a deleted file, which surfaced as confusing
  `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>`
  errors rather than "module not found".
- The barrel RENAMED one type on the way through
  (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name
  in the owning module has to be re-aliased at each consumer.
- Three test files put `;(globalThis as ...)` on the line after the import.
  TypeScript parses that `;` as the import statement's terminator, so
  replacing through `statement.getEnd()` deletes it and breaks ASI. The
  rewrite now stops at the module specifier.
- A file that already imported directly from a module got a SECOND import
  from it, because the barrel re-exported those same names — which trips
  `import/no-duplicates` under `--deny-warnings`. A post-pass merges
  declarations sharing a specifier and type-only-ness; the `import type` plus
  `import` pair from one module is left alone, since that form is allowed.

Splitting one barrel import into several genuinely adds lines, which pushed
`terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character
import must wrap, and neither local type collapses onto one line (101 and 116
characters). Rather than contort a type declaration to fit a line budget,
`collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` —
they are pure structural operations on the layout tree and independent of PTY
ownership. `visible-worktrees.ts` similarly loses its own mini-barrel
re-export of `isDefaultBranchWorkspace`, with the four real consumers
repointed at the declaring module. No `max-lines` bypass added.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches); the full
`pnpm lint` green, not just bare oxlint — the narrower local check is what let
the duplicate imports reach CI; max-lines ratchet OK at 344.
2026-08-13 22:48:24 -07:00

331 lines
12 KiB
TypeScript

import type { CheckStatus } from '../../shared/github/pull-request-types'
import type { GitLabIssueInfo, GitLabWorkItem, MRInfo, MRState } from '../../shared/gitlab-types'
import {
mapGitLabPipelineJobStatusToCheckStatus,
mapGitLabPipelineJobStatusToConclusion
} from '../../shared/gitlab-pipeline-checks'
import { summarizeProviderChecks } from '../../shared/provider-check-summary'
// ── Pipeline job mapping (GitLab REST `/pipelines/:id/jobs`) ────────
// Why: GitLab pipeline jobs roughly map to GitHub check-runs, but use a
// single `status` field that combines lifecycle + outcome. We split it
// into PRCheckDetail's status + conclusion shape so the renderer can
// share a row with the GitHub side.
export const mapPipelineJobStatusToCheckStatus = mapGitLabPipelineJobStatusToCheckStatus
export const mapPipelineJobStatusToConclusion = mapGitLabPipelineJobStatusToConclusion
// ── MR state mapping ────────────────────────────────────────────────
// Why: glab returns the API state directly. Apply the draft flag (or a
// `Draft:` title prefix, which is GitLab's title-based draft convention)
// so the UI sees a single discriminator.
export function mapMRState(state: string, isDraft?: boolean, title?: string): MRState {
const s = state?.toLowerCase()
if (s === 'merged') {
return 'merged'
}
if (s === 'closed') {
return 'closed'
}
if (s === 'locked') {
return 'locked'
}
// Why: GitLab supports drafts via either a boolean field (newer API) or
// a `Draft:` / `WIP:` title prefix (legacy). Either signal counts.
if (isDraft || (title && /^(draft|wip):\s*/i.test(title))) {
return 'draft'
}
return 'opened'
}
// ── Issue mapping ────────────────────────────────────────────────────
// glab issue view returns: { iid, title, state, web_url, labels: [{name}] | string[] }
// `state` is already lowercase 'opened' | 'closed' so the mapping is
// mostly a normalization shim.
export function mapGitLabIssueInfo(data: {
iid?: number
number?: number
title: string
state: string
web_url?: string
url?: string
labels?: { name: string }[] | string[]
updated_at?: string
description?: string | null
author?: { username?: string | null; avatar_url?: string | null } | null
}): GitLabIssueInfo {
// Why: glab CLI flips between exposing `iid` and `number` depending on
// command + --output flag combination. Accept both.
const number = data.iid ?? data.number ?? 0
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
return {
number,
title: data.title,
state: data.state?.toLowerCase() === 'opened' ? 'opened' : 'closed',
url: data.web_url ?? data.url ?? '',
labels,
...(data.updated_at ? { updatedAt: data.updated_at } : {}),
// Why: same description / author optional plumbing as mapMRInfo —
// list payloads strip these so callers can tell "absent" from "blank".
...(typeof data.description === 'string' ? { description: data.description } : {}),
...(data.author?.username ? { author: data.author.username } : {}),
...(data.author?.avatar_url ? { authorAvatarUrl: data.author.avatar_url } : {})
}
}
// ── MR info mapping ──────────────────────────────────────────────────
// Why: parallel to mapPRState's role for GitHub. glab returns iid +
// web_url + state + draft + sha + has_conflicts.
type GitLabMRRaw = {
iid?: number
number?: number
title: string
state: string
draft?: boolean
web_url?: string
url?: string
updated_at?: string
updatedAt?: string
sha?: string
has_conflicts?: boolean
detailed_merge_status?: string
/** Deprecated since GitLab 15.6, but the only merge signal older instances return. */
merge_status?: string
description?: string | null
target_branch?: string
author?: { username?: string | null; avatar_url?: string | null } | null
}
export function mapMRInfo(data: GitLabMRRaw, pipelineStatus: CheckStatus): MRInfo {
const mergeable = deriveMergeable(data)
const mergeStateStatus = deriveMergeStateStatus(data, mergeable)
return {
number: data.iid ?? data.number ?? 0,
title: data.title,
state: mapMRState(data.state, data.draft, data.title),
url: data.web_url ?? data.url ?? '',
pipelineStatus,
updatedAt: data.updated_at ?? data.updatedAt ?? '',
mergeable,
...(mergeStateStatus ? { mergeStateStatus } : {}),
headSha: data.sha,
baseRefName: data.target_branch,
// Why: detail-endpoint payloads include `description`; list endpoints
// strip it. Pass through what's present rather than coercing missing
// values to '' so downstream UIs can distinguish "no body authored"
// from "this came from a list and the body is unknown".
...(typeof data.description === 'string' ? { description: data.description } : {}),
...(data.author?.username ? { author: data.author.username } : {}),
...(data.author?.avatar_url ? { authorAvatarUrl: data.author.avatar_url } : {})
}
}
function deriveMergeable(data: GitLabMRRaw): MRInfo['mergeable'] {
if (data.has_conflicts === true) {
return 'CONFLICTING'
}
// Why: detailed_merge_status is GitLab's richest signal. Treat
// 'mergeable' as the only positive value — every other state
// (checking, ci_must_pass, draft_status, etc.) is an unknown from the
// user's POV because it may flip without warning.
if (data.detailed_merge_status === 'mergeable') {
return 'MERGEABLE'
}
if (data.detailed_merge_status === 'broken_status' || data.detailed_merge_status === 'conflict') {
return 'CONFLICTING'
}
// Why: `detailed_merge_status` only exists from GitLab 15.6. Without this fallback an older
// instance reports UNKNOWN forever, and the merge UI (which now gates on MERGEABLE) would
// permanently show "Checking" with no merge button. Only the positive legacy value is trusted;
// `cannot_be_merged` stays UNKNOWN because `has_conflicts` above already owns the conflict case.
if (data.detailed_merge_status === undefined && data.merge_status === 'can_be_merged') {
return 'MERGEABLE'
}
return 'UNKNOWN'
}
/** Project GitLab detailed_merge_status onto a short reason the merge UI can label. */
function deriveMergeStateStatus(
data: GitLabMRRaw,
mergeable: MRInfo['mergeable']
): string | undefined {
if (mergeable === 'CONFLICTING') {
return 'conflict'
}
if (mergeable === 'MERGEABLE') {
return 'mergeable'
}
const status = data.detailed_merge_status?.toLowerCase()
if (!status) {
return undefined
}
return status
}
// ── Pipeline rollup (parallel to GitHub deriveCheckStatus) ──────────
// Why: GitLab returns a single pipeline `status` for the head commit; we
// can also receive an array of jobs and roll them up the same way the
// GitHub side does. Accept either shape.
export function derivePipelineStatus(
rollup: { status?: string }[] | { status?: string } | string | null | undefined
): CheckStatus {
if (!rollup) {
return 'neutral'
}
if (typeof rollup === 'string') {
return classifyPipelineString(rollup)
}
if (!Array.isArray(rollup)) {
return classifyPipelineString(rollup.status ?? '')
}
// Why: a job array is just a check list, so route it through the shared classifier instead of
// re-deriving the rules here — a local copy drifted (manual-only read green, an unrecognized
// status demoted a passing pipeline to neutral).
const { state } = summarizeProviderChecks(
rollup.map((job) => {
const s = job.status?.toLowerCase() ?? ''
return {
status: mapPipelineJobStatusToCheckStatus(s),
conclusion: mapPipelineJobStatusToConclusion(s)
}
})
)
// Why: CheckStatus has no 'none'; an empty job list carries the same "nothing to report" meaning.
return state === 'none' ? 'neutral' : state
}
// ── Raw → GitLabWorkItem mapping ────────────────────────────────────
// Why: list endpoints return MR / issue records; the picker consumes a
// unified GitLabWorkItem. Mirrors the GitHub side where MainWorkItem is
// produced from PR / issue REST + GraphQL responses.
type GitLabMRRawForWorkItem = {
id?: number
iid?: number
title: string
state: string
draft?: boolean
web_url?: string
url?: string
updated_at?: string
source_branch?: string
target_branch?: string
author?: { username?: string | null } | null
labels?: ({ name: string } | string)[]
/** Why: source_project_id !== target_project_id signals a fork MR.
* GitLab list endpoints include both — the picker uses this flag the
* same way GitHub's isCrossRepository disables fork-MR start points
* when the workspace flow can't safely resolve the head. */
source_project_id?: number
target_project_id?: number
has_conflicts?: boolean
detailed_merge_status?: string
}
export function mapMRToWorkItem(
data: GitLabMRRawForWorkItem,
repoId: string,
projectRef?: GitLabWorkItem['projectRef']
): GitLabWorkItem {
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
const number = data.iid ?? 0
return {
// Why: id needs to be unique across providers in the picker. Prefix
// 'gitlab-mr-' so a GitHub PR #5 and a GitLab MR !5 don't collide.
id: `gitlab-mr-${data.id ?? `${repoId}-${number}`}`,
type: 'mr',
number,
title: data.title,
state: mapMRState(data.state, data.draft, data.title),
url: data.web_url ?? data.url ?? '',
labels,
updatedAt: data.updated_at ?? '',
author: data.author?.username ?? null,
branchName: data.source_branch,
baseRefName: data.target_branch,
isCrossRepository:
data.source_project_id !== undefined &&
data.target_project_id !== undefined &&
data.source_project_id !== data.target_project_id,
repoId,
...(data.has_conflicts !== undefined || data.detailed_merge_status !== undefined
? { mergeable: deriveMergeable(data) }
: {}),
...(projectRef ? { projectRef } : {})
}
}
type GitLabIssueRawForWorkItem = {
id?: number
iid?: number
title: string
state: string
web_url?: string
url?: string
updated_at?: string
author?: { username?: string | null } | null
labels?: ({ name: string } | string)[]
}
export function mapIssueToWorkItem(
data: GitLabIssueRawForWorkItem,
repoId: string,
projectRef?: GitLabWorkItem['projectRef']
): GitLabWorkItem {
const labels = (data.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name))
const number = data.iid ?? 0
// Issues only ever resolve to 'opened' or 'closed' (issue state space is
// narrower than MRs); coerce defensively without inventing values.
const state = data.state?.toLowerCase() === 'opened' ? 'opened' : 'closed'
return {
id: `gitlab-issue-${data.id ?? `${repoId}-${number}`}`,
type: 'issue',
number,
title: data.title,
state,
url: data.web_url ?? data.url ?? '',
labels,
updatedAt: data.updated_at ?? '',
author: data.author?.username ?? null,
repoId,
...(projectRef ? { projectRef } : {})
}
}
function classifyPipelineString(status: string): CheckStatus {
const s = status.toLowerCase()
if (s === 'success') {
return 'success'
}
if (s === 'failed' || s === 'action_required') {
return 'failure'
}
// Why: GitLab only reports pipeline-level `manual` when the pipeline is *blocked* on a human
// trigger (a manual job with allow_failure: false), so it is outstanding rather than broken or
// done. Red overstated it; neutral would drop the cue entirely and paint the worktree card's MR
// icon green (worktree-review-helpers.tsx defaults `open` to emerald), which is worse — with
// "Pipelines must succeed" on, GitLab rejects this merge.
if (s === 'manual') {
return 'pending'
}
// Why: `skipped` and `canceled` pipelines stay neutral (the fall-through below) even though the
// job rollup calls the same jobs passing/failing. GitLab's own MR widget paints both grey, and
// `allow_merge_on_skipped_pipeline` defaults to false, so a skipped pipeline still blocks merge —
// flipping either tone is a product decision that belongs in its own change, not this one.
if (
s === 'created' ||
s === 'pending' ||
s === 'running' ||
s === 'waiting_for_resource' ||
s === 'preparing' ||
s === 'scheduled'
) {
return 'pending'
}
return 'neutral'
}