mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
#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.
331 lines
12 KiB
TypeScript
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'
|
|
}
|