mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
* Resolve and fetch the review target branch (compareBaseRef) during PR and MR worktree creation. * Persist this ref on worktree metadata instead of pinning the head SHA. * Dynamically repair existing worktrees with stale commit SHA compare bases in the Source Control UI using linked review metadata.
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
import type {
|
|
CheckStatus,
|
|
GitLabIssueInfo,
|
|
GitLabWorkItem,
|
|
MRInfo,
|
|
MRState
|
|
} from '../../shared/types'
|
|
import {
|
|
mapGitLabPipelineJobStatusToCheckStatus,
|
|
mapGitLabPipelineJobStatusToConclusion
|
|
} from '../../shared/gitlab-pipeline-checks'
|
|
|
|
// ── 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
|
|
description?: string | null
|
|
target_branch?: string
|
|
author?: { username?: string | null; avatar_url?: string | null } | null
|
|
}
|
|
|
|
export function mapMRInfo(data: GitLabMRRaw, pipelineStatus: CheckStatus): MRInfo {
|
|
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: deriveMergeable(data),
|
|
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'
|
|
}
|
|
return 'UNKNOWN'
|
|
}
|
|
|
|
// ── 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 ?? '')
|
|
}
|
|
if (rollup.length === 0) {
|
|
return 'neutral'
|
|
}
|
|
let hasFailure = false
|
|
let hasPending = false
|
|
for (const job of rollup) {
|
|
const s = job.status?.toLowerCase()
|
|
if (s === 'failed') {
|
|
hasFailure = true
|
|
} else if (
|
|
s === 'created' ||
|
|
s === 'pending' ||
|
|
s === 'running' ||
|
|
s === 'waiting_for_resource' ||
|
|
s === 'preparing' ||
|
|
s === 'scheduled'
|
|
) {
|
|
hasPending = true
|
|
}
|
|
}
|
|
if (hasFailure) {
|
|
return 'failure'
|
|
}
|
|
if (hasPending) {
|
|
return 'pending'
|
|
}
|
|
return 'success'
|
|
}
|
|
|
|
// ── 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
|
|
}
|
|
|
|
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,
|
|
...(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') {
|
|
return 'failure'
|
|
}
|
|
if (
|
|
s === 'created' ||
|
|
s === 'pending' ||
|
|
s === 'running' ||
|
|
s === 'waiting_for_resource' ||
|
|
s === 'preparing' ||
|
|
s === 'scheduled'
|
|
) {
|
|
return 'pending'
|
|
}
|
|
return 'neutral'
|
|
}
|