diff --git a/tools/workflow-engine/gitlab-world.test.ts b/tools/workflow-engine/gitlab-world.test.ts new file mode 100644 index 0000000000..8a561db8c1 --- /dev/null +++ b/tools/workflow-engine/gitlab-world.test.ts @@ -0,0 +1,187 @@ +// Invariant tests for GitLabWorld per-host adapter (B-0867.15 PoC). + +import { describe, expect, test } from "bun:test"; +import { EMPTY_WORLD } from "./world.js"; +import { buildGitWorld } from "./git-world.js"; +import { + type GitLabResourceBudget, + type MrLifetime, + buildGitLabWorld, + canAffordGitLab, + gitLabRateLimitTier, + GITLAB_MR_UNIVERSE, + GITLAB_DISCUSSION_UNIVERSE, + GITLAB_PIPELINE_UNIVERSE, + GITLAB_REQUIRE_RESOLVED_VERDICT, + GITLAB_APPROVAL_NOT_MET_VERDICT, +} from "./gitlab-world.js"; + +describe("GitLabWorld constructor + inheritance", () => { + test("buildGitLabWorld extends GitWorld base", () => { + const gitWorld = buildGitWorld(); + const gitLabWorld = buildGitLabWorld(gitWorld); + expect(gitLabWorld.forgeName).toBe("git"); // inherited + expect(gitLabWorld.forgeSpecialization).toBe("gitlab"); + expect(gitLabWorld.branchUniverse.length).toBe(4); // inherited from GitWorld + expect(gitLabWorld.commitUniverse.length).toBe(5); // inherited + }); + + test("buildGitLabWorld populates MR + Discussion + Pipeline universes", () => { + const gitLabWorld = buildGitLabWorld(buildGitWorld()); + expect(gitLabWorld.mrUniverse.length).toBe(6); + expect(gitLabWorld.discussionUniverse.length).toBe(2); + expect(gitLabWorld.pipelineUniverse.length).toBe(8); + }); + + test("buildGitLabWorld accepts optional resourceBudget", () => { + const budget: GitLabResourceBudget = { + restRemaining: 1500, + restLimit: 2000, + restResetAt: 1700000000, + graphqlRemaining: 800, + graphqlLimit: 1000, + graphqlResetAt: 1700000060, + }; + const gitLabWorld = buildGitLabWorld(buildGitWorld(), budget); + expect(gitLabWorld.resourceBudget).toEqual(budget); + }); + + test("buildGitLabWorld omits resourceBudget when not provided", () => { + const gitLabWorld = buildGitLabWorld(buildGitWorld()); + expect(gitLabWorld.resourceBudget).toBeUndefined(); + }); +}); + +describe("gitLabRateLimitTier tier boundaries", () => { + test("normal tier when remaining > 800", () => { + expect(gitLabRateLimitTier(2000)).toBe("normal"); + expect(gitLabRateLimitTier(801)).toBe("normal"); + }); + test("cost-aware tier when 400 < remaining ≤ 800", () => { + expect(gitLabRateLimitTier(800)).toBe("cost-aware"); + expect(gitLabRateLimitTier(401)).toBe("cost-aware"); + }); + test("extreme-cost-aware tier when 80 < remaining ≤ 400", () => { + expect(gitLabRateLimitTier(400)).toBe("extreme-cost-aware"); + expect(gitLabRateLimitTier(81)).toBe("extreme-cost-aware"); + }); + test("pure-git tier when remaining ≤ 80", () => { + expect(gitLabRateLimitTier(80)).toBe("pure-git"); + expect(gitLabRateLimitTier(0)).toBe("pure-git"); + }); +}); + +describe("canAffordGitLab budget enforcement", () => { + const baseWorld = buildGitLabWorld(buildGitWorld(), { + restRemaining: 100, + restLimit: 2000, + restResetAt: 1700000060, + graphqlRemaining: 50, + graphqlLimit: 1000, + graphqlResetAt: 1700000120, + }); + + test("within budget returns ok", () => { + const result = canAffordGitLab(baseWorld, { restCost: 5, graphqlCost: 5 }); + expect(result.ok).toBe(true); + }); + + test("REST exceeded returns ResourceBudgetExhausted with rest", () => { + const result = canAffordGitLab(baseWorld, { restCost: 200 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.feedback.kind).toBe("ResourceBudgetExhausted"); + if (result.feedback.kind === "ResourceBudgetExhausted") { + expect(result.feedback.budget).toBe("rest"); + } + } + }); + + test("GraphQL exceeded returns ResourceBudgetExhausted with graphql", () => { + const result = canAffordGitLab(baseWorld, { graphqlCost: 100 }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.feedback.kind).toBe("ResourceBudgetExhausted"); + if (result.feedback.kind === "ResourceBudgetExhausted") { + expect(result.feedback.budget).toBe("graphql"); + } + } + }); + + test("no budget loaded → ok by default", () => { + const noBudgetWorld = buildGitLabWorld(buildGitWorld()); + const result = canAffordGitLab(noBudgetWorld, { restCost: 1_000_000 }); + expect(result.ok).toBe(true); + }); +}); + +describe("reusable substrate exports", () => { + test("GITLAB_MR_UNIVERSE has 6 variants", () => { + expect(GITLAB_MR_UNIVERSE.length).toBe(6); + const kinds = GITLAB_MR_UNIVERSE.map((m) => m.kind); + expect(kinds).toContain("draft"); + expect(kinds).toContain("opened"); + expect(kinds).toContain("merged"); + }); + test("GITLAB_DISCUSSION_UNIVERSE has 2 variants", () => { + expect(GITLAB_DISCUSSION_UNIVERSE.length).toBe(2); + expect(GITLAB_DISCUSSION_UNIVERSE.map((d) => d.kind)).toEqual([ + "unresolved", + "resolved", + ]); + }); + test("GITLAB_PIPELINE_UNIVERSE has 8 variants (GitLab-native CI/CD)", () => { + expect(GITLAB_PIPELINE_UNIVERSE.length).toBe(8); + const kinds = GITLAB_PIPELINE_UNIVERSE.map((p) => p.kind); + expect(kinds).toContain("manual"); // GitLab-specific + expect(kinds).toContain("skipped"); // GitLab-specific (CI rules) + }); + test("GITLAB_REQUIRE_RESOLVED_VERDICT is block with GitLab vocabulary", () => { + expect(GITLAB_REQUIRE_RESOLVED_VERDICT.kind).toBe("block"); + if (GITLAB_REQUIRE_RESOLVED_VERDICT.kind === "block") { + expect(GITLAB_REQUIRE_RESOLVED_VERDICT.reason).toContain("GitLab"); + expect(GITLAB_REQUIRE_RESOLVED_VERDICT.reason).toContain("discussions"); + } + }); + test("GITLAB_APPROVAL_NOT_MET_VERDICT is block with approval vocabulary", () => { + expect(GITLAB_APPROVAL_NOT_MET_VERDICT.kind).toBe("block"); + if (GITLAB_APPROVAL_NOT_MET_VERDICT.kind === "block") { + expect(GITLAB_APPROVAL_NOT_MET_VERDICT.reason).toContain("approval"); + } + }); +}); + +describe("type-level MrLifetime exhaustive switch (compile check)", () => { + test("all 6 MR variants distinguishable", () => { + const variants: MrLifetime[] = [ + { kind: "draft" }, + { kind: "opened" }, + { kind: "reviewer-assigned" }, + { kind: "approved" }, + { kind: "merged" }, + { kind: "closed" }, + ]; + expect(variants.length).toBe(6); + }); +}); + +describe("end-to-end composition (GitWorld → GitLabWorld → budget-check)", () => { + test("full chain: GitWorld base + GitLab specialization + budget enforcement", () => { + const gitWorld = buildGitWorld(); + expect(EMPTY_WORLD.registry.size).toBe(0); + const gitLabWorld = buildGitLabWorld(gitWorld, { + restRemaining: 1500, + restLimit: 2000, + restResetAt: 1700000060, + graphqlRemaining: 800, + graphqlLimit: 1000, + graphqlResetAt: 1700000120, + }); + expect(gitLabWorld.forgeSpecialization).toBe("gitlab"); + expect(gitLabWorld.mrUniverse.length).toBe(6); + expect(gitLabWorld.pipelineUniverse.length).toBe(8); + const budgetCheck = canAffordGitLab(gitLabWorld, { restCost: 10, graphqlCost: 5 }); + expect(budgetCheck.ok).toBe(true); + expect(gitLabRateLimitTier(gitLabWorld.resourceBudget!.restRemaining)).toBe("normal"); + }); +}); diff --git a/tools/workflow-engine/gitlab-world.ts b/tools/workflow-engine/gitlab-world.ts new file mode 100644 index 0000000000..e3083c0405 --- /dev/null +++ b/tools/workflow-engine/gitlab-world.ts @@ -0,0 +1,305 @@ +// tools/workflow-engine/gitlab-world.ts +// +// B-0867.15 — GitLabWorld per-host adapter PoC. +// +// Aaron 2026-05-28 lane-status framing: workflow-engine substrate Lane 2 +// (GitHub accelerator/workflow). B-0867.15 names per-host adapters +// extending PR #5775's GitWorld → GitHubWorld specialization hierarchy +// to GitLab + Gitea + Bitbucket + Codeberg + Sourcehut. +// +// This file ships the GitLabWorld instantiation — first per-host adapter +// extending the base. Pattern: same as GitHubWorld but with forge- +// specific lifetime variants + GitLab-native substrate (merge requests +// instead of pull requests; discussions instead of review threads; +// pipelines as first-class CI/CD substrate). +// +// Composes with: +// - PR #5775 git-world.ts (GitWorld base + GitHubWorld first specialization) +// - PR #5776 world-hierarchy.ts (Clifford → DBSP → Git → forge-specific) +// - B-0867.15 backlog row (per-host adapters target) +// - .claude/rules/asymmetric-authorship (per-forge feedback variants substrate-entity-authored) +// - .claude/rules/monad-propagation (Result shape) + +import { + registerLifetimePair, + type ComposedKey, + type LifetimeState, + type StandardVerdict, +} from "./world.js"; +import { type GitWorld } from "./git-world.js"; + +// ───────────────────────────────────────────────────────────────────── +// GitLab forge-specific lifetime types +// ───────────────────────────────────────────────────────────────────── + +/** + * Merge Request lifetime (GitLab's analog of GitHub's PR). + * + * GitLab MR state machine variants per GitLab REST API v4 spec: + * https://docs.gitlab.com/ee/api/merge_requests.html + */ +export interface MrLifetime extends LifetimeState { + readonly kind: "draft" | "opened" | "reviewer-assigned" | "approved" | "merged" | "closed"; +} + +/** + * Discussion lifetime (GitLab's analog of GitHub's review thread). + * + * GitLab discussions can be resolvable or non-resolvable; this DU covers + * the resolvable shape used by code review. + */ +export interface DiscussionLifetime extends LifetimeState { + readonly kind: "unresolved" | "resolved"; +} + +/** + * Pipeline lifetime (GitLab-native; first-class CI/CD substrate). + * + * GitLab makes CI/CD pipelines first-class in the API model. Pipeline + * state-machine variants per GitLab REST API v4 spec. + */ +export interface PipelineLifetime extends LifetimeState { + readonly kind: + | "created" + | "pending" + | "running" + | "success" + | "failed" + | "canceled" + | "skipped" + | "manual"; +} + +// ───────────────────────────────────────────────────────────────────── +// GitLab resource-allocation substrate +// ───────────────────────────────────────────────────────────────────── + +/** + * GitLab resource budget (forge-specific). + * + * GitLab uses per-minute rate limits (RateLimit-Remaining + RateLimit- + * Reset headers). Tier varies by plan; free tier defaults are typically + * 2000/min authenticated for REST. Self-hosted instances can configure + * limits per project / per user. + * + * Distinct from GitHub's per-hour 5000 budget; this models the per-minute + * rolling window GitLab uses. + */ +export interface GitLabResourceBudget { + readonly restRemaining: number; + readonly restLimit: number; // per-minute window; tier-dependent + readonly restResetAt: number; // unix timestamp + readonly graphqlRemaining: number; + readonly graphqlLimit: number; // GitLab GraphQL has its own budget + readonly graphqlResetAt: number; +} + +/** + * GitLab-specific rate-limit tier per framework's rate-limit substrate. + * + * Tiers shifted from GitHub's 5000/hour to GitLab's typical 2000/minute + * authenticated. Thresholds preserve the same operational substrate + * (normal / cost-aware / extreme-cost-aware / pure-git) at scaled + * boundaries. + */ +export type GitLabRateLimitTier = + | "normal" // > 800 remaining (40% of typical 2000/min) + | "cost-aware" // 400-800 + | "extreme-cost-aware" // 80-400 + | "pure-git"; // 0-80 + +export function gitLabRateLimitTier(remaining: number): GitLabRateLimitTier { + if (remaining > 800) return "normal"; + if (remaining > 400) return "cost-aware"; + if (remaining > 80) return "extreme-cost-aware"; + return "pure-git"; +} + +// ───────────────────────────────────────────────────────────────────── +// GitLabWorld specialization +// ───────────────────────────────────────────────────────────────────── + +/** + * GitLabWorld — specialization of GitWorld for GitLab forge. + * + * Per Aaron 2026-05-28 lane-status framing (Lane 2 GitHub accelerator): + * B-0867.15 names per-host adapters as extension target. GitLabWorld is + * the first concrete adapter beyond GitHubWorld; demonstrates the + * pattern that GiteaWorld + BitbucketWorld + CodebergWorld + SourcehutWorld + * follow. + * + * Inherits all GitWorld substrate + adds: + * - MR substrate (MrLifetime; analog of GitHub PR) + * - Discussion substrate (DiscussionLifetime; analog of GitHub review thread) + * - Pipeline substrate (PipelineLifetime; GitLab-native first-class CI/CD) + * - Resource-allocation substrate (REST + GraphQL per-minute budgets) + * - GitLab-specific optimizations (merge-train, approval rules, suggestion patches) + */ +export interface GitLabWorld extends GitWorld { + readonly forgeName: "git"; // inherits GitWorld base + readonly forgeSpecialization: "gitlab"; + readonly mrUniverse: ReadonlyArray; + readonly discussionUniverse: ReadonlyArray; + readonly pipelineUniverse: ReadonlyArray; + readonly resourceBudget?: GitLabResourceBudget; +} + +/** + * Build the GitLabWorld substrate from a base GitWorld. + */ +export function buildGitLabWorld( + gitWorld: GitWorld, + resourceBudget?: GitLabResourceBudget, +): GitLabWorld { + return { + ...gitWorld, + forgeSpecialization: "gitlab", + mrUniverse: [ + { kind: "draft" }, + { kind: "opened" }, + { kind: "reviewer-assigned" }, + { kind: "approved" }, + { kind: "merged" }, + { kind: "closed" }, + ], + discussionUniverse: [ + { kind: "unresolved" }, + { kind: "resolved" }, + ], + pipelineUniverse: [ + { kind: "created" }, + { kind: "pending" }, + { kind: "running" }, + { kind: "success" }, + { kind: "failed" }, + { kind: "canceled" }, + { kind: "skipped" }, + { kind: "manual" }, + ], + ...(resourceBudget !== undefined && { resourceBudget }), + }; +} + +// ───────────────────────────────────────────────────────────────────── +// GitLab-specific feedback substrate +// ───────────────────────────────────────────────────────────────────── + +export type GitLabFeedback = + | { kind: "UnsupportedGitLabFeature"; feature: string } + | { kind: "ResourceBudgetExhausted"; budget: "rest" | "graphql"; resetAt: number } + | { kind: "ApprovalRulesNotMet"; required: number; actual: number } + | { kind: "MergeBlocked"; reason: string }; + +export type GitLabResult = + | { ok: true; world: T } + | { ok: false; feedback: GitLabFeedback }; + +/** + * Check if a GitLab operation is within budget. + * + * Same shape as GitHubWorld's canAfford; per-minute window vs per-hour. + */ +export interface GitLabOperationCost { + readonly restCost?: number; + readonly graphqlCost?: number; +} + +export function canAffordGitLab( + world: GitLabWorld, + cost: GitLabOperationCost, +): GitLabResult { + const budget = world.resourceBudget; + if (!budget) { + return { ok: true, world }; + } + const restCost = cost.restCost ?? 0; + const graphqlCost = cost.graphqlCost ?? 0; + if (restCost > budget.restRemaining) { + return { + ok: false, + feedback: { + kind: "ResourceBudgetExhausted", + budget: "rest", + resetAt: budget.restResetAt, + }, + }; + } + if (graphqlCost > budget.graphqlRemaining) { + return { + ok: false, + feedback: { + kind: "ResourceBudgetExhausted", + budget: "graphql", + resetAt: budget.graphqlResetAt, + }, + }; + } + return { ok: true, world }; +} + +/** + * Convenience: register a lifetime pair in the GitLabWorld. + */ +export function registerInGitLab< + A extends LifetimeState, + B extends LifetimeState, + T, +>( + world: GitLabWorld, + pairName: string, + matrix: ReadonlyMap, T>, +): GitLabWorld { + return registerLifetimePair(world, pairName, matrix); +} + +// ───────────────────────────────────────────────────────────────────── +// Reusable substrate exports +// ───────────────────────────────────────────────────────────────────── + +export const GITLAB_MR_UNIVERSE: ReadonlyArray = [ + { kind: "draft" }, + { kind: "opened" }, + { kind: "reviewer-assigned" }, + { kind: "approved" }, + { kind: "merged" }, + { kind: "closed" }, +]; + +export const GITLAB_DISCUSSION_UNIVERSE: ReadonlyArray = [ + { kind: "unresolved" }, + { kind: "resolved" }, +]; + +export const GITLAB_PIPELINE_UNIVERSE: ReadonlyArray = [ + { kind: "created" }, + { kind: "pending" }, + { kind: "running" }, + { kind: "success" }, + { kind: "failed" }, + { kind: "canceled" }, + { kind: "skipped" }, + { kind: "manual" }, +]; + +/** + * Reusable verdict for GitLab's "must resolve all discussions before + * merge" branch protection pattern (settings/general/merge_request). + * + * Same shape as GITHUB's REQUIRE_RESOLVED_VERDICT but named with + * GitLab vocabulary. + */ +export const GITLAB_REQUIRE_RESOLVED_VERDICT: StandardVerdict = { + kind: "block", + reason: "GitLab require_all_discussions_resolved: unresolved discussions block merge", +}; + +/** + * Reusable verdict for GitLab's "approval rules not met" pattern. + * + * GitLab MRs can have approval rules requiring N approvals from specific + * groups; this verdict captures the blocked state. + */ +export const GITLAB_APPROVAL_NOT_MET_VERDICT: StandardVerdict = { + kind: "block", + reason: "GitLab approval rules not met: required approvals missing", +};