# Workspace Locking via Gitea Issues # # Distributed locking mechanism using Gitea issues # # Version: 1.0.0 use api_client.nu * # Lock label constants const LOCK_LABEL_PREFIX = "workspace-lock" const READ_LOCK_LABEL = "read-lock" const WRITE_LOCK_LABEL = "write-lock" const DEPLOY_LOCK_LABEL = "deploy-lock" # Get lock repository def get-lock-repo [] -> record { let config = get-gitea-config let org = $config.repositories.workspaces_org # Use special locks repository {org: $org, repo: "workspace-locks"} } # Ensure locks repository exists def ensure-lock-repo [] -> nothing { let lock_repo = get-lock-repo try { get-repository $lock_repo.org $lock_repo.repo } catch { # Create locks repository create-repository $lock_repo.org $lock_repo.repo "Workspace locking system" true false print $"✓ Created locks repository: ($lock_repo.org)/($lock_repo.repo)" } } # Format lock issue title def format-lock-title [ workspace_name: string lock_type: string user: string ] -> string { $"[LOCK:($lock_type)] ($workspace_name) by ($user)" } # Format lock issue body def format-lock-body [ workspace_name: string lock_type: string user: string operation?: string expiry?: string ] -> string { let timestamp = date now | format date "%Y-%m-%dT%H:%M:%SZ" let body = [ "## Workspace Lock", "", $"- **Lock Type**: ($lock_type)", $"- **Workspace**: ($workspace_name)", $"- **User**: ($user)", $"- **Timestamp**: ($timestamp)", ] let body_with_operation = if ($operation | is-not-empty) { $body | append [$"- **Operation**: ($operation)"] } else { $body } let body_with_expiry = if ($expiry | is-not-empty) { $body_with_operation | append [$"- **Expiry**: ($expiry)"] } else { $body_with_operation } $body_with_expiry | str join "\n" } # Get lock labels def get-lock-labels [ lock_type: string ] -> list { let type_label = match $lock_type { "read" => $READ_LOCK_LABEL, "write" => $WRITE_LOCK_LABEL, "deploy" => $DEPLOY_LOCK_LABEL, _ => $lock_type } [$LOCK_LABEL_PREFIX, $type_label] } # Acquire workspace lock export def acquire-workspace-lock [ workspace_name: string lock_type: string # read, write, deploy operation?: string expiry?: string --user: string = "" ] -> record { # Get current user if not specified let lock_user = if ($user | is-empty) { (get-current-user).login } else { $user } # Ensure locks repository exists ensure-lock-repo let lock_repo = get-lock-repo # Check for conflicting locks let conflicts = check-lock-conflicts $workspace_name $lock_type if ($conflicts | length) > 0 { error make { msg: $"Workspace ($workspace_name) is locked" help: $"Conflicting locks: (($conflicts | each {|c| $"#($c.number)"} | str join ", "))" } } # Create lock issue let title = format-lock-title $workspace_name $lock_type $lock_user let body = format-lock-body $workspace_name $lock_type $lock_user $operation $expiry let labels = get-lock-labels $lock_type let issue = create-issue $lock_repo.org $lock_repo.repo $title $body $labels print $"✓ Workspace lock acquired: #($issue.number)" { lock_id: $issue.number workspace: $workspace_name lock_type: $lock_type user: $lock_user timestamp: (date now | format date "%Y-%m-%dT%H:%M:%SZ") operation: $operation expiry: $expiry } } # Release workspace lock export def release-workspace-lock [ workspace_name: string lock_id: int ] -> bool { let lock_repo = get-lock-repo # Get lock issue let issue = get-issue $lock_repo.org $lock_repo.repo $lock_id # Verify it's a lock for this workspace if not ($issue.title | str contains $workspace_name) { error make { msg: $"Lock #($lock_id) is not for workspace ($workspace_name)" } } # Close issue close-issue $lock_repo.org $lock_repo.repo $lock_id print $"✓ Workspace lock released: #($lock_id)" true } # Check lock conflicts def check-lock-conflicts [ workspace_name: string lock_type: string ] -> list { let lock_repo = get-lock-repo # Get all open locks for workspace let all_locks = list-workspace-locks $workspace_name # Check for conflicts if $lock_type == "write" or $lock_type == "deploy" { # Write/deploy locks conflict with any other lock $all_locks } else if $lock_type == "read" { # Read locks only conflict with write/deploy locks $all_locks | filter {|lock| $lock.lock_type == "write" or $lock.lock_type == "deploy" } } else { [] } } # Check if workspace is locked export def is-workspace-locked [ workspace_name: string lock_type: string ] -> bool { let conflicts = check-lock-conflicts $workspace_name $lock_type ($conflicts | length) > 0 } # List active locks for workspace export def list-workspace-locks [ workspace_name: string ] -> list { let lock_repo = get-lock-repo # Get all open issues with lock label let issues = list-issues $lock_repo.org $lock_repo.repo "open" $LOCK_LABEL_PREFIX # Filter for this workspace $issues | filter {|issue| $issue.title | str contains $workspace_name} | each {|issue| # Parse lock info from issue let lock_type = if ($issue.title | str contains "LOCK:write") { "write" } else if ($issue.title | str contains "LOCK:read") { "read" } else if ($issue.title | str contains "LOCK:deploy") { "deploy" } else { "unknown" } # Extract user from title let title_parts = $issue.title | split row " by " let user = if ($title_parts | length) > 1 { $title_parts.1 } else { "unknown" } { number: $issue.number workspace: $workspace_name lock_type: $lock_type user: $user created_at: $issue.created_at title: $issue.title url: $issue.html_url } } } # List all active locks export def list-all-locks [] -> list { let lock_repo = get-lock-repo # Get all open issues with lock label let issues = list-issues $lock_repo.org $lock_repo.repo "open" $LOCK_LABEL_PREFIX $issues | each {|issue| # Parse lock info from issue let lock_type = if ($issue.title | str contains "LOCK:write") { "write" } else if ($issue.title | str contains "LOCK:read") { "read" } else if ($issue.title | str contains "LOCK:deploy") { "deploy" } else { "unknown" } # Extract workspace and user from title let title_parts = $issue.title | parse "[LOCK:{type}] {workspace} by {user}" let parsed = if ($title_parts | length) > 0 { $title_parts.0 } else { {workspace: "unknown", user: "unknown"} } { number: $issue.number workspace: $parsed.workspace lock_type: $lock_type user: $parsed.user created_at: $issue.created_at title: $issue.title url: $issue.html_url } } } # Force release lock (admin only) export def force-release-lock [ workspace_name: string lock_id: int --reason: string = "Forced unlock" ] -> bool { let lock_repo = get-lock-repo # Get lock issue let issue = get-issue $lock_repo.org $lock_repo.repo $lock_id # Add comment about forced unlock let current_user = (get-current-user).login let comment_body = $"🔓 **Forced unlock by ($current_user)**\n\nReason: ($reason)" # Note: Gitea API doesn't have a direct comment creation endpoint in basic API # For now, just close the issue close-issue $lock_repo.org $lock_repo.repo $lock_id print $"⚠️ Force released lock #($lock_id) for workspace ($workspace_name)" true } # Get lock info export def get-lock-info [ workspace_name: string lock_id: int ] -> record { let lock_repo = get-lock-repo let issue = get-issue $lock_repo.org $lock_repo.repo $lock_id # Verify it's a lock for this workspace if not ($issue.title | str contains $workspace_name) { error make { msg: $"Lock #($lock_id) is not for workspace ($workspace_name)" } } # Parse lock type let lock_type = if ($issue.title | str contains "LOCK:write") { "write" } else if ($issue.title | str contains "LOCK:read") { "read" } else if ($issue.title | str contains "LOCK:deploy") { "deploy" } else { "unknown" } # Extract user from title let title_parts = $issue.title | split row " by " let user = if ($title_parts | length) > 1 { $title_parts.1 } else { "unknown" } { lock_id: $issue.number workspace: $workspace_name lock_type: $lock_type user: $user created_at: $issue.created_at updated_at: $issue.updated_at state: $issue.state title: $issue.title body: $issue.body url: $issue.html_url labels: $issue.labels } } # Cleanup expired locks export def cleanup-expired-locks [] -> list { let lock_repo = get-lock-repo let now = date now let all_locks = list-all-locks # Find expired locks (based on expiry in body) let expired = $all_locks | each {|lock| let info = get-lock-info $lock.workspace $lock.number # Parse expiry from body let expiry_line = $info.body | lines | filter {|line| $line | str contains "Expiry:"} if ($expiry_line | length) > 0 { let expiry_str = $expiry_line.0 | str replace "- **Expiry**: " "" | str trim let expiry = try { $expiry_str | into datetime } catch { null } if ($expiry | is-not-empty) and ($expiry < $now) { $lock } else { null } } else { null } } | filter {|x| $x != null} # Close expired locks $expired | each {|lock| close-issue $lock_repo.org $lock_repo.repo $lock.number print $"✓ Closed expired lock: #($lock.number) for ($lock.workspace)" $lock } } # Auto-lock wrapper for operations export def with-workspace-lock [ workspace_name: string lock_type: string operation: string command: closure ] -> any { # Acquire lock let lock = acquire-workspace-lock $workspace_name $lock_type $operation # Execute command let result = try { do $command } catch {|err| # Release lock on error release-workspace-lock $workspace_name $lock.lock_id error make $err } # Release lock release-workspace-lock $workspace_name $lock.lock_id $result }