All files / src/services InstanceCapabilities.ts

100% Statements 64/64
89.23% Branches 58/65
100% Functions 11/11
100% Lines 55/55

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205      20x                                                                                     20x     20x           20x     20x 6828x 6828x           5808x 5808x 5808x             20x 6609x 6609x                       20x       5073x 5067x 5026x 4352x 3807x                 20x         3932x   558x           3374x                   20x       3679x     537x 1687x 1063x               20x       4143x 4143x 1617x 1617x   4143x             20x                 1624x 1x   1623x 1611x 1611x 1611x 154x   1457x 1456x 721x   735x             20x 7x 6x 6x 2x 3x 3x 2x       6x    
import type { GitLabTier, GitLabFeatures } from './GitLabVersionDetector';
import type { GitLabScope } from './TokenScopeDetector';
import type { ToolRequirement, ToolRequirements } from '../types';
import { parseVersion } from '../utils/version';
 
/**
 * Aggregated, session-scoped view of a single GitLab instance's capabilities.
 *
 * Composes the previously scattered detection signals (version + tier + features
 * from {@link GitLabInstanceInfo}, token scopes from the token-scope detector,
 * admin elevation from #434) into one typed blob. The registry consults this to
 * decide which tools, actions, and parameters the instance can satisfy instead
 * of letting unsupported calls fail at the GitLab API with an opaque error.
 */
export interface InstanceCapabilities {
  /** Detected GitLab version (semver string), or 'unknown' when not yet probed. */
  version: string;
  /** Licensed tier. Free covers CE and EE-without-license. */
  tier: GitLabTier;
  /** Per-feature availability map (epics, iterations, vulnerabilities, ...). */
  features: GitLabFeatures;
  /** Token scopes (api, read_api, ...). Empty when scope detection was skipped. */
  scopes: GitLabScope[];
  /**
   * Whether the authenticated user is an instance admin. `undefined` when the
   * admin probe did not run (OAuth mode) or failed — treated as fail-open.
   */
  isAdmin?: boolean;
  /**
   * Whether GitLab admin-mode elevation is currently active for the session.
   * `undefined` when the admin probe did not run (OAuth mode) or failed.
   * OAuth tokens cannot elevate admin mode, so it stays undefined under OAuth.
   */
  adminModeActive?: boolean;
}
 
/**
 * Minimal slice of {@link InstanceCapabilities} needed to evaluate a tool
 * requirement. The registry holds only version/tier during cache builds (the
 * feature map and scopes are not consulted for version/tier/admin gating), so
 * the gating helpers accept this narrower shape and a full InstanceCapabilities
 * satisfies it structurally.
 */
export type CapabilityGate = Pick<InstanceCapabilities, 'version' | 'tier' | 'adminModeActive'>;
 
/** Tier hierarchy for comparison: free < premium < ultimate. */
const TIER_ORDER: Record<string, number> = { free: 0, premium: 1, ultimate: 2 };
 
/** Default requirement applied when a tool/action omits an explicit tier. */
const DEFAULT_TIER = 'free' as const;
 
/**
 * Oldest GitLab release this server supports. Every tool, action and parameter
 * requires at least this version; a declared minVersion only matters above it.
 */
export const MIN_SUPPORTED_VERSION = '16.0';
 
/** Version a requirement gates on: its own minVersion, never below the supported floor. */
export function effectiveMinVersion(req: ToolRequirement | undefined): string {
  const declared = req?.minVersion;
  return declared && parseVersion(declared) > parseVersion(MIN_SUPPORTED_VERSION)
    ? declared
    : MIN_SUPPORTED_VERSION;
}
 
function isTierSufficient(actual: GitLabTier, required: ToolRequirement['tier']): boolean {
  const actualLevel = TIER_ORDER[actual] ?? 0;
  const requiredLevel = TIER_ORDER[required ?? DEFAULT_TIER] ?? 0;
  return actualLevel >= requiredLevel;
}
 
/**
 * Resolve the effective requirement for a tool, narrowing to an action-specific
 * override when one exists. Returns the tool default otherwise.
 */
export function resolveRequirement(reqs: ToolRequirements, action?: string): ToolRequirement {
  const override = action ? reqs.actions?.[action] : undefined;
  return override ?? reqs.default;
}
 
/**
 * Check whether the instance satisfies a single requirement (version + tier +
 * admin). When the version is unknown the requirement is treated as met
 * (fail-open) so tools are not hidden before detection completes. The admin gate
 * keys on admin-mode ELEVATION, not the role: admin-only endpoints return 403
 * unless admin mode is active, so an admin without elevation is gated out just
 * like a non-admin. It only filters when elevation is *known* inactive; an
 * undefined status (probe not landed / OAuth) is permissive.
 */
export function meetsRequirement(req: ToolRequirement, caps: CapabilityGate): boolean {
  // The admin gate is independent of version detection: if elevation is known
  // inactive, the endpoint will 403 regardless of whether the version probe
  // landed, so gate it BEFORE the version-unknown fail-open.
  if (req.requiresAdmin && caps.adminModeActive === false) return false;
  if (caps.version === 'unknown') return true;
  if (parseVersion(caps.version) < parseVersion(effectiveMinVersion(req))) return false;
  if (!isTierSufficient(caps.tier, req.tier)) return false;
  return true;
}
 
/**
 * Whether a tool is available on the instance for the given (optional) action.
 *
 * @param reqs - The tool's declared requirements, or undefined when the tool
 *   declares none, in which case only the supported version floor applies.
 */
export function isToolAvailable(
  reqs: ToolRequirements | undefined,
  caps: CapabilityGate,
  action?: string,
): boolean {
  if (!reqs) {
    // Unannotated tools have no admin gate; only the supported version floor.
    return caps.version === 'unknown'
      ? true
      : parseVersion(caps.version) >= parseVersion(MIN_SUPPORTED_VERSION);
  }
  // Delegate to meetsRequirement so the admin gate applies even when version is
  // unknown (it short-circuits version/tier internally).
  return meetsRequirement(resolveRequirement(reqs, action), caps);
}
 
/**
 * Names of parameters that must be stripped from a tool's JSON Schema because the
 * instance does not meet their declared requirement. Version/tier requirements
 * fail-open while the version is unknown, but admin-gated parameters are still
 * stripped when admin-mode elevation is known inactive. Empty when the tool gates
 * no parameters.
 */
export function getRestrictedParameters(
  reqs: ToolRequirements | undefined,
  caps: CapabilityGate,
): string[] {
  if (!reqs?.parameters) return [];
  // No blanket version-unknown skip: meetsRequirement still fail-opens version/tier
  // when unknown, but an admin-gated param with inactive elevation stays restricted.
  return Object.entries(reqs.parameters)
    .filter(([, req]) => !meetsRequirement(req, caps))
    .map(([name]) => name);
}
 
/**
 * Actions whose own requirement the instance does not meet, keyed by lowercase
 * action name with the reason. Actions without an override follow the tool
 * default, which gates the whole tool instead.
 */
export function getUnavailableActions(
  reqs: ToolRequirements | undefined,
  caps: CapabilityGate,
): Map<string, string> {
  const unavailable = new Map<string, string>();
  for (const action of Object.keys(reqs?.actions ?? {})) {
    const reason = getUnmetReason(reqs, caps, action);
    if (reason) unavailable.set(action.toLowerCase(), reason);
  }
  return unavailable;
}
 
/**
 * Human-readable reason a tool/action is unavailable, or null when available.
 * Intended for diagnostics that explain why a tool was filtered.
 */
export function getUnmetReason(
  reqs: ToolRequirements | undefined,
  caps: CapabilityGate,
  action?: string,
): string | null {
  // Admin gate first — independent of version detection (see meetsRequirement).
  // adminModeActive === false covers BOTH a non-admin account (no role) and an
  // admin without active elevation, so the wording must not assume the caller can
  // elevate — it states the requirement, not a single fix.
  if (reqs && resolveRequirement(reqs, action).requiresAdmin && caps.adminModeActive === false) {
    return 'Requires administrator privileges (admin mode must be active)';
  }
  if (caps.version === 'unknown') return null;
  const req = reqs ? resolveRequirement(reqs, action) : undefined;
  const minVersion = effectiveMinVersion(req);
  if (parseVersion(caps.version) < parseVersion(minVersion)) {
    return `Requires GitLab ${minVersion}+, current version is ${caps.version}`;
  }
  if (!req) return null;
  if (!isTierSufficient(caps.tier, req.tier)) {
    return `Requires GitLab ${req.tier ?? DEFAULT_TIER} tier or higher, current tier is ${caps.tier}`;
  }
  return null;
}
 
/**
 * Highest tier required by any of a tool's actions (or its default). Used by the
 * documentation generator to label a consolidated tool with its strictest tier.
 */
export function getHighestTier(reqs: ToolRequirements | undefined): GitLabTier {
  if (!reqs) return 'free';
  let highest: GitLabTier = reqs.default.tier ?? 'free';
  if (reqs.actions) {
    for (const req of Object.values(reqs.actions)) {
      const tier = req.tier ?? 'free';
      if ((TIER_ORDER[tier] ?? 0) > (TIER_ORDER[highest] ?? 0)) {
        highest = tier;
      }
    }
  }
  return highest;
}