Skip to content

Latest commit

 

History

History
743 lines (594 loc) · 33.8 KB

File metadata and controls

743 lines (594 loc) · 33.8 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Build Commands

# Build the solution
dotnet build

# Run all tests
dotnet test

# Run a single test by name filter
dotnet test --filter "FullyQualifiedName~EstimationIsDeterministic"

# Run the application
dotnet run --project BuildMonitor/BuildMonitor.csproj

# Clean build artifacts
dotnet clean

Architecture Overview

BuildMonitor is a desktop ImGui-based application that monitors CI/CD build statuses across multiple providers (GitHub Actions and Azure DevOps). The architecture follows a hierarchical data model with concurrent updating, priority-based scheduling, and adaptive rate limiting.

Core Data Hierarchy

The application maintains a nested hierarchy of build data:

  • BuildProvider (e.g., GitHub, AzureDevOps) → OwnerRepositoryBuildRun

Each level is stored in ConcurrentDictionary collections to support thread-safe updates from async operations.

Owner Semantics by Provider:

  • GitHub: Owners represent GitHub users or organizations
  • Azure DevOps: Owners represent projects within an organization (the account ID is the organization name)

Dual-Entity Pattern with Priority Scheduling

The codebase uses a "sync" pattern where domain entities (Build, Run) are paired with synchronization classes:

  • Build / BuildSync: The Build entity stores data; BuildSync manages polling intervals, priority calculation, and async updates
  • Run / RunSync: Same pattern for individual workflow runs

BuildSync and RunSync objects track when entities should be updated based on their state and calculate request priorities:

  • High Priority (0): Running/in-progress builds (users care most about these)
  • Medium Priority (1): Recent failures or builds with recent activity (within last hour)
  • Low Priority (2): Completed/successful builds, discovery operations

Priority affects both update frequency and whether requests are made when API budget is constrained.

Update Flow with Adaptive Intervals

  1. Main Loop (BuildMonitor.cs): Calls UpdateAsync() every frame
  2. Provider Refresh (every 300s): Updates repositories and builds from providers
    • Skipped when provider is in low budget mode (< 15% remaining API calls)
    • Logs: "Skipping discovery due to low budget"
  3. Build Updates (30-120s, priority-based):
    • High priority: 30s interval
    • Medium priority: 60s interval
    • Low priority: 120s interval
    • Filtered by provider's MaxAllowedPriority based on budget
  4. Run Updates (10-60s, adaptive): Updates only ongoing runs
    • Dynamically adjusts interval based on estimated time remaining (ETA)
    • Clamped between 10s minimum and 60s maximum
    • Only high-priority requests (ongoing runs)

The update cycle creates BuildSync/RunSync entries in global collections (BuildSyncCollection, RunSyncCollection). Each sync object:

  • Tracks elapsed time with a Stopwatch
  • Calculates whether it should update via ShouldUpdate property
  • Determines its priority via Priority property
  • Is pruned when orphaned (parent entity deleted) or completed (runs only)

Provider Architecture

BuildProvider is an abstract base class with JSON polymorphic serialization support. Currently supported providers:

  • GitHub: Uses Octokit library for GitHub Actions API
  • AzureDevOps: Uses Microsoft.TeamFoundation libraries for Azure DevOps REST API

New providers should:

  • Inherit from BuildProvider
  • Add [JsonDerivedType] attribute to BuildProvider base class
  • Implement the four abstract update methods:
    • UpdateRepositoriesAsync(Owner): Discover/update repositories for an owner
    • UpdateBuildsAsync(Repository): Discover/update build definitions (workflows) for a repository
    • UpdateBuildAsync(Build): Fetch recent runs for a specific build
    • UpdateRunAsync(Run): Update the status of a specific run
  • Register in BuildMonitor.OnStart() by adding to AppData.BuildProviders
  • Implement provider-specific menu actions via ShowMenu() override

Entity Update Strategy

The application uses a GetOrAdd pattern to ensure existing entities are updated when new data arrives, preventing data loss and keeping properties in sync with the remote APIs.

Repository Updates:

  • Uses ConcurrentDictionary.GetOrAdd() instead of TryAdd() to get existing or create new
  • Always updates mutable properties after getting the entity:
    • IsPrivate: Updated when repository visibility changes
    • IsArchived: Updated when repository is archived/unarchived
    • IsFork: Updated when repository fork status changes
  • Only triggers QueueSaveAppData() when actual changes occur
  • Logs separately: new repositories, updated repositories, archived repositories removed

Build Updates:

  • Uses GetOrAdd() pattern for build/workflow definitions
  • Updates BuildName if the workflow file or build definition is renamed
  • Detects changes by comparing current value with API value
  • Only saves when new builds are discovered or names change

Run Updates:

  • Run entities are always updated in place (no GetOrAdd needed)
  • UpdateRunFromWorkflowAsync() (GitHub) and UpdateRunFromBuild() (Azure DevOps) always update properties
  • Updates: Status, Started, LastUpdated, Duration, Branch, Errors
  • Errors are cleared when run status changes from failure to non-failure
  • Triggers Build.UpdateFromRun() to keep parent build state in sync

Benefits of Update Strategy:

  • Repository visibility changes are reflected without manual intervention
  • Renamed workflows/build definitions don't create duplicate entries
  • No data accumulation from stale entities
  • Efficient AppData saves (only when changes occur)
  • Better logging distinguishes between discovery and updates

Provider Status Tracking

Each BuildProvider tracks its operational status with visual indicators in the status bar:

  • ProviderStatus.OK (green): Operating normally
  • ProviderStatus.RateLimited (yellow): API rate limit hit, includes backoff delay and reset time
  • ProviderStatus.AuthFailed (red): Authentication failed, credentials cleared
  • ProviderStatus.Error (magenta): Connection or other error

Status is displayed in a status bar at the top of the UI with:

  • Color indicator showing status
  • Provider name and status label
  • Rate limit consumption display (e.g., "4532/5000")
  • Tooltip with detailed status message and rate limit reset time

Rate Limit Management and Adaptive Pacing

The application implements sophisticated rate limit management to avoid hitting API limits:

Budget Tracking:

  • Tracks remaining API calls (RateLimitBudgetRemaining) and total limit (RateLimitBudgetLimit)
  • Updates budget from successful API responses via UpdateRateLimitBudget()
  • Calculates budget percentage: BudgetPercentage (0.0 to 1.0)

Budget Thresholds:

  • Low Budget Mode (< 15% remaining): Skips low-priority requests and discovery operations
  • Critical Budget Mode (< 5% remaining): Only processes high-priority requests (running builds)
  • MaxAllowedPriority returns the maximum priority level allowed given current budget

Adaptive Pacing:

  • CalculateAdaptivePacing(): Spreads remaining requests evenly across the time window until reset
  • Reserves 10% of budget (or 50 requests minimum) for unexpected requests
  • Calculates delay per request: timeUntilReset / usableBudget * 1.1 (with 10% safety margin)
  • Clamped between 100ms minimum and 30s maximum

Wait Time Calculation:

  • GetRateLimitWaitTime(): Returns the appropriate delay before the next request
  • When fully rate limited: waits until reset time (capped at 10 minutes)
  • Otherwise: uses the larger of adaptive pacing delay or current RateLimitSleep
  • Includes 5-second buffer for clock skew when waiting for reset

Rate Limit Recovery:

  • ClearStatus(): Resets RateLimitSleep back to base delay (500ms) when recovering
  • Status automatically clears on first successful request after rate limiting

Authentication and Credentials

Provider-Level Authentication:

  • Each provider has an AccountId and Token (stored in AppData, persisted)
  • Set via "Set Credentials" menu item (two-step popup: AccountId, then Token)
  • Cleared automatically on AuthorizationException or 403 Forbidden responses

Owner-Level Authentication (GitHub only):

  • Each owner can have an optional Token property (overrides provider token)
  • Enables access to private repositories in different organizations
  • Set via "Providers → GitHub → Set Owner Token" submenu
  • Clear via "Providers → GitHub → Set Owner Token → Clear Owner Token" submenu
  • Owner names in the menu show "(token)" indicator if owner has a token
  • HasValidCredentials(owner) checks owner token first, then falls back to provider token

GitHub Client Management:

  • Maintains cache of GitHubClient instances per token (TokenClients dictionary)
  • Uses AsyncLocal<GitHubClient> for current client context (CurrentClientLocal)
  • SetCurrentClient(owner) must be called before each API operation
  • Prevents race conditions when concurrent requests use different owner tokens

Azure DevOps Client Management:

  • Creates VssConnection with VssBasicCredential using AccountId and Token
  • Gets ProjectHttpClient and BuildHttpClient from connection
  • UpdateAzureDevOpsClientCredentials() recreates clients when credentials change
  • Organization URL format: https://dev.azure.com/{AccountId}

Threading and Synchronization

  • All data updates happen asynchronously via Task-based operations
  • ConcurrentDictionary is used throughout for thread-safe collection access
  • BuildMonitor.SyncLock exists but is only used for menu rendering
  • RequestSemaphore limits concurrent requests per provider (default: 5)
  • Rate limiting uses adaptive delays calculated per request via GetRateLimitWaitTime()
  • Active requests are tracked in ActiveRequests dictionary for UI feedback

User Interface

Multi-Tab Layout: The UI uses a tabbed interface (OwnerTabPanel) with dynamic tab management:

  • "All" Tab: Shows builds from all owners across all providers
  • Owner/Project Tabs:
    • GitHub: One tab per owner (user or organization)
    • Azure DevOps: One tab per account (the organization itself)
    • Tab IDs format: "GitHub:{ownerName}" or "ADO:{accountId}"
  • "Logs" Tab: Application logs with color-coded levels and auto-scrolling

Tabs are dynamically created/removed as owners are discovered or removed. Selected tab is persisted in AppData.

Build Table Columns:

  1. Status (60px): Color indicator or radial progress bar
  2. Owner (150px): Owner/organization/project name
  3. Repository (150px): Repository name with property icons
  4. Build Name (200px): Workflow/build definition name (cleaned of .yml and path prefixes)
  5. Branch (150px): Git branch name
  6. Status (80px): Text status (Pending, Running, Success, Failure, Canceled)
  7. Last Run (180px): Timestamp of last run start (local time with timezone)
  8. Duration (80px): Duration of the run (format: hh:mm:ss or d.hh:mm:ss)
  9. Estimate (80px): Estimated total duration for the build
  10. History (80px): Last 5 non-canceled runs as color indicators
  11. Progress (100px): Radial progress bar + percentage (ongoing builds only)
  12. ETA (80px): Estimated time remaining (ongoing builds only)
  13. Errors (200px): Error messages from failed runs (clickable for details)
  14. Next Update (100px): Countdown to next poll with radial progress indicator

Repository Icons: Repositories display nerd font icons to indicate properties:

  • \uf023 (lock): Private repository
  • \uf126 (code fork): Forked repository
  • \uf187 (archive): Archived repository

Empty Repositories: Repositories without workflows/builds are shown with:

  • Gray status indicator
  • Owner and repository name (with property icons)
  • "No workflows" text in gray in the Build Name column
  • Hidden when any build name, branch, or status filter is active

Progress Indicators: The UI uses radial progress bars (from ImGuiWidgets.RadialProgressBar) for:

  • Build Status Column: Ongoing builds show progress instead of color indicator
  • Progress Column: Radial bar + percentage text for ongoing builds
  • Next Update Column: Countdown visualization showing time until next poll

Filtering:

  • Filter row below table headers with search boxes for each column
  • Supports multiple filter types (via TextFilterType): Contains, Exact, Wildcard, Regex
  • Match options: Case-sensitive, whole word, etc.
  • Filters are persisted per column in AppData
  • Empty repositories hidden when build name, branch, or status filters are active

Column Width Management:

  • Widths are persisted in AppData.ColumnWidths dictionary
  • Default widths defined in DefaultColumnWidths
  • Manual pointer arithmetic used to read column widths from ImGui's native structs
  • Workaround for Hexa.NET.ImGui struct layout bug (8-byte size difference)
  • SaveColumnWidth() saves when width changes by more than 1px

The struct-layout workaround validates itself before reading. Hexa.NET.ImGui binds eight ImGuiTableColumn index fields one byte narrower than native Dear ImGui does, so the C# struct is 8 bytes smaller and sizeof(ImGuiTableColumn) is the wrong stride. Measured against 2.2.9: sizeof is 108, the eight fields sit contiguously at offsets 86-93 at one byte apiece, and the native stride is 116.

ProbeNativeImGuiTableColumnSize runs once from a static initializer and measures the bug itself — how many bytes those eight fields actually occupy — rather than inferring it from a size threshold. The measuring and the deciding are deliberately separate: the probe needs unsafe, reflection and a real Hexa.NET.ImGui type, none of which a test can vary, so it hands three plain integers to ResolveNativeColumnStride, which is internal and covered by ColumnStrideTests. That is what makes the corrected-binding and unrecognised-layout branches reachable from a test at all — neither can be produced with the binding currently referenced.

The rule it applies: eight bytes means the binding is still narrow and the stride is sizeof + 8; sixteen means it has been fixed, so sizeof is correct and the whole workaround can go; anything else, a missing field, a moved WidthGiven, or a Marshal failure disables column-width persistence and logs a warning.

This deliberately replaced two Debug.Assert calls. Those are compiled out of a Release build, so a fixed binding or a reordered struct would have left Release silently applying an offset that no longer matched and reading from the wrong address. The probe fails closed instead: GetColumnWidth already falls back to the saved or default width, so the app keeps working and only stops tracking new widths. Do not reintroduce an assert here — it must hold in Release, which is the only configuration users run.

Context Menu Actions

Right-clicking on any build row opens a context menu with the following actions:

Repository Actions:

  • Open Repository in Browser
  • Copy Repository URL

Workflow Actions:

  • Open Workflow in Browser
  • Copy Workflow URL

Branch Actions:

  • Open Branch in Browser
  • Copy Branch URL

Latest Run Actions:

  • Open Latest Run in Browser
  • Copy Latest Run URL

GitHub API Actions (GitHub provider only):

  • Re-run Latest Workflow: Re-runs a completed workflow (disabled for running builds)
  • Cancel Running Workflow: Cancels an in-progress workflow (only shown for running builds)
  • Trigger Workflow on Branch: Dispatches a new workflow run on the selected branch

Data Refresh:

  • Refresh Build Data: Forces immediate update by calling BuildSync.ResetTimer()

All actions that modify build state (rerun, cancel, trigger) use ExecuteGitHubApiAction() which:

  • Runs the action asynchronously in a background task
  • Automatically triggers a build refresh on success
  • Logs but does not crash on ApiException failures

Logs Tab

The Logs tab provides a real-time view of application logs:

Features:

  • Color-coded log levels:
    • Debug: Gray
    • Info: White
    • Warning: Yellow
    • Error: Red
  • Format: [HH:mm:ss.fff] [LEVEL] message
  • Auto-scrolling: Stays at bottom when already scrolled to bottom
  • Clear logs button at top
  • Scrollable region with horizontal scrollbar support

Log Sources:

  • Provider operations (authentication, rate limiting, errors)
  • API requests (start/completion with duration)
  • Build status transitions (started, succeeded, failed)
  • Discovery operations (owners, repositories, builds)
  • Budget management decisions

Error Display

Failed runs display error information fetched from provider-specific job logs:

GitHub Error Fetching:

  • Errors are fetched via FetchRunErrorsAsync() when a run transitions to failure state or is a failure without errors
  • Job logs retrieved via GitHubJobs.GetLogs()
  • Parsed with ParseLogForErrors() using:
    • GitHub Actions error annotations: ##[error]message
    • Common error patterns: regex (?:^|\s)error\s*: (case-insensitive)
  • Errors are deduplicated using HashSet<string> (case-insensitive)
  • Limited to first 10 errors to avoid UI overload
  • Prefixed with job name: [{job.Name}] {errorMessage}
  • Falls back to failed step names if no log errors found
  • Further falls back to [{job.Name}] Failed if no steps failed

UI Display:

  • Errors shown in red text in the Errors column
  • Text is ellipsized if longer than column width (with "..." suffix)
  • Clickable to open a popup with full error details
  • Popup shows build name, branch, and all errors with word wrapping
  • Tooltip on hover shows full error text
  • Errors cleared when run status changes from failure to non-failure

Duration Estimation

Build duration estimation uses sophisticated statistical methods via DurationEstimator:

Estimation Strategy:

  1. Sample Selection: Uses last 20 successful runs, ordered by start time (most recent first)
  2. Branch-Specific Estimation: When estimating for a branch:
    • First attempts estimate using only runs from that branch
    • Falls back to overall build estimation if branch has insufficient data (< 3 samples)
  3. Outlier Removal: Uses IQR (Interquartile Range) method with 1.5× multiplier (Tukey fence)
    • Calculates Q1 (25th percentile) and Q3 (75th percentile)
    • Removes values outside [Q1 - 1.5*IQR, Q3 + 1.5*IQR]
    • Falls back to median if too many samples removed (< 3 remaining)
  4. Exponentially Weighted Average: Recent runs have higher weight
    • Weight decreases by 70% for each older sample (decay factor: 0.3)
    • Most recent run has weight 1.0, next has 0.7, next has 0.49, etc.
    • Formula: weight = (1 - decayFactor)^i where i is age index

Branch-Specific Estimation:

  • Build.CalculateEstimatedDuration(branch): Estimates for a specific branch
  • Build.CalculateEstimatedDuration(): Estimates across all branches
  • Run ETA calculation uses branch-specific estimation: Run.CalculateETA() calls Build.CalculateEstimatedDuration(Branch)
  • More accurate than global estimates for workflows that vary significantly by branch

Estimation Statistics:

  • DurationEstimator.GetEstimationStats(): Returns detailed statistics for debugging
  • Statistics include: sample count, min, max, median, final estimate, whether filtering was used

Usage:

  • Used by UI to show estimated duration in "Estimate" column
  • Used to calculate ETA (Estimated Time Remaining) for ongoing runs
  • Drives adaptive update intervals in RunSync.UpdateAsync()

Menu System

File Menu:

  • Clear Data: Clears all repositories and builds for all providers (preserves provider credentials)
  • Exit: Closes the application

Providers Menu: Each provider has a submenu with provider-specific actions:

GitHub Provider Menu:

  • Set Credentials: Two-step popup for AccountId and Token
  • Discover All Owners: Automatically discovers user and all organizations
    • Fetches authenticated user via CurrentClient.User.Current()
    • Fetches all organizations via CurrentClient.Organization.GetAllForCurrent()
    • Adds each as an owner (user and orgs)
  • Add Owner: Manually add an owner by name
  • Set Owner Token (submenu): Per-owner token management
    • Lists all owners, shows "(token)" indicator for owners with tokens
    • Opens popup to set token for selected owner
    • Clear Owner Token (nested submenu): Remove token from selected owner

Azure DevOps Provider Menu:

  • Set Credentials: Two-step popup for AccountId (organization name) and Token (PAT)
  • Discover All Projects: Discovers all projects in the organization
    • Fetches projects via ProjectClient.GetProjects()
    • Creates an owner entry for each project
    • Creates a repository entry (project acts as both owner and repository)
  • Add Owner: Manually add a project by name

State Management and Persistence

AppData Structure: Persisted via ktsu.AppDataStorage to JSON file:

  • WindowState: ImGui window position, size, and state
  • BuildProviders: Dictionary of provider instances (serialized polymorphically)
  • ColumnWidths: Dictionary of column name to width
  • SelectedOwnerTabId: Currently selected tab ID
  • Filter settings per column (text, type, match options):
    • FilterOwner, FilterOwnerType, FilterOwnerMatchOptions
    • FilterRepository, FilterRepositoryType, FilterRepositoryMatchOptions
    • FilterBuildName, FilterBuildNameType, FilterBuildNameMatchOptions
    • FilterBranch, FilterBranchType, FilterBranchMatchOptions
    • FilterStatus, FilterStatusType, FilterStatusMatchOptions

Save Batching:

  • QueueSaveAppData(): Marks data for saving without immediate write
  • SaveSettingsIfRequired(): Called each frame, saves if queued
  • Batches multiple changes within a single frame into one write

Data Cleared on Auth Failure:

  • Provider AccountId and Token cleared on authentication failure
  • Provider status set to AuthFailed
  • Owner tokens preserved (only provider token cleared)

Semantic String Types

The codebase uses ktsu.Semantics.Strings for type-safe string identifiers:

  • BuildProviderName, BuildProviderAccountId, BuildProviderToken
  • OwnerName, OwnerId
  • RepositoryName, RepositoryId
  • BuildName, BuildId
  • RunName, RunId
  • BranchName

These prevent mixing up different types of identifiers and enable type-safe conversions via .As<T>().

Example:

OwnerName ownerName = "microsoft".As<OwnerName>();
BuildId buildId = workflowId.ToString().As<BuildId>();

Key Files

Core Application

  • BuildMonitor.cs: Main application class with UI rendering, update orchestration, and tab management
  • AppData.cs: Persistent application state and settings

Provider System

  • BuildProvider.cs: Abstract base class with status tracking, rate limiting, and budget management
  • Providers/GitHub.cs: GitHub Actions implementation with Octokit API integration
  • Providers/AzureDevOps.cs: Azure DevOps implementation with Microsoft.TeamFoundation libraries

Data Model

  • Owner.cs: Represents a user/organization (GitHub) or project (Azure DevOps)
  • Repository.cs: Represents a repository with properties (IsPrivate, IsArchived, IsFork)
  • Build.cs: Represents a build definition/workflow with duration estimation
  • Run.cs: Represents a workflow run with status and error information

Synchronization

  • BuildSync.cs: Synchronization wrapper for Build with priority-based update scheduling
  • RunSync.cs: Synchronization wrapper for Run with adaptive update intervals
  • RequestPriority.cs: Priority enum and BuildSync class with priority logic

Estimation

  • DurationEstimator.cs: Statistical duration estimation with IQR outlier removal and exponential weighting

Testing

BuildMonitor.Test uses MSTest.Sdk with the Microsoft Testing Platform, targeting net10.0. The application project exposes its internals to it via InternalsVisibleTo in BuildMonitor/AssemblyInfo.cs.

Most of this application cannot be unit tested — the UI needs a live ImGui context and the providers need credentials and network. What is covered is the provider-independent logic that decides what the user sees and how hard the APIs get hit:

  • DurationEstimatorTests — the sample floor, the IQR outlier filter, exponential weighting toward recent runs, that only completed successful runs are sampled, branch-specific estimation and its fallback, determinism, and that the estimate stays inside its own sample range.
  • ColumnStrideTests — the ImGuiTableColumn layout decision described above.

When adding a test that needs a Build, note that DurationEstimator orders samples by Started descending, so a fixture must set distinct Started values for "most recent" to mean anything; DurationEstimatorTests.BuildWithDurations does this and is the pattern to copy.

Anything genuinely worth testing that is currently tangled up with ImGui or a provider is usually worth extracting into a plain method first, the way ResolveNativeColumnStride was — the extraction is what makes it testable, and the pure function is easier to reason about besides.

Provider Implementation Details

GitHub Provider

API Client:

  • Uses Octokit library (Octokit NuGet package)
  • Maintains per-token client cache to avoid race conditions
  • Creates GitHubClient with ProductHeaderValue and Credentials
  • Token format: Personal Access Token (PAT) with repo and workflow scopes

Repository Discovery:

  • For authenticated user: GitHubRepository.GetAllForCurrent() (includes private repos)
    • Filters to only repos owned by the user (not all accessible repos)
  • For other users: GitHubRepository.GetAllForUser(owner) (public repos only)
  • For organizations: GitHubRepository.GetAllForOrg(owner) (respects org visibility)
  • Uses GetOrAdd() to get existing or create new repository
  • Updates properties: IsPrivate, IsArchived, IsFork on each refresh
  • Archived repositories are removed from tracking
  • Logs: "{newRepos} new, {updatedRepos} updated, {archivedRepos} archived removed"

Workflow/Build Discovery:

  • GitHubWorkflows.List(owner, repo): Gets all workflows in repository
  • Uses GetOrAdd() to get existing or create new build
  • Updates BuildName if workflow file is renamed
  • Maps Workflow.Id to BuildId and Workflow.Name to BuildName

Run Fetching:

  • GitHubRuns.ListByWorkflow(): Gets last 10 runs for a workflow
  • Maps workflow run status/conclusion to RunStatus enum
  • Logs status transitions (started, succeeded, failed)

Run Updates:

  • GitHubRuns.Get(): Fetches single run by ID
  • Updates on status change: Pending → Running → Success/Failure/Canceled

Error Fetching:

  • GitHubJobs.List(): Gets all jobs for a run
  • GitHubJobs.GetLogs(): Gets log text for each failed job
  • Parses logs for ##[error] annotations and error patterns

Rate Limit Handling:

  • Updates budget from ApiInfo.RateLimit after each successful request
  • Detects rate limits via 403 Forbidden + X-RateLimit-Remaining: 0 header
  • Parses reset time from X-RateLimit-Reset header (Unix timestamp)
  • Distinguishes 403 rate limit from 403 auth failure by checking headers

GitHub-Specific Actions:

  • RerunWorkflowAsync(): GitHubRuns.Rerun()
  • CancelWorkflowAsync(): GitHubRuns.Cancel()
  • TriggerWorkflowAsync(): GitHubActions.Workflows.CreateDispatch() with branch reference

Azure DevOps Provider

API Client:

  • Uses Microsoft.TeamFoundation libraries (Microsoft.TeamFoundationServer.Client, etc.)
  • Creates VssConnection with organization URI: https://dev.azure.com/{AccountId}
  • Uses VssBasicCredential with empty username and Personal Access Token (PAT)
  • Gets ProjectHttpClient for project/repository operations
  • Gets BuildHttpClient for build definition and run operations

Project/Owner Discovery:

  • ProjectClient.GetProjects(): Gets all projects in the organization
  • Each project becomes an owner (project name → OwnerName)
  • Each project also creates a repository entry (project acts as both)
  • Uses GetOrAdd() to ensure repository entry exists for each project
  • Repository ID uses project GUID

Build Definition Discovery:

  • BuildClient.GetDefinitionsAsync(projectName): Gets all build definitions in project
  • Uses GetOrAdd() to get existing or create new build
  • Updates BuildName if build definition is renamed
  • Maps BuildDefinitionReference.Id to BuildId
  • Maps BuildDefinitionReference.Name to BuildName
  • Logs when new build definitions are discovered

Build/Run Fetching:

  • BuildClient.GetBuildsAsync(project, definitions, top): Gets last 10 builds for a definition
  • Maps Build.Id to RunId and Build.BuildNumber to RunName
  • Branch name extracted from SourceBranch (removes refs/heads/ prefix)

Run Updates:

  • BuildClient.GetBuildAsync(project, buildId): Fetches single build by ID
  • Uses StartTime or falls back to QueueTime for run start
  • Uses FinishTime or current time for ongoing runs
  • Maps BuildStatus and BuildResult to RunStatus:
    • NotStarted → Pending
    • InProgress/Cancelling → Running
    • Completed + Succeeded/PartiallySucceeded → Success
    • Completed + Failed → Failure
    • Completed + Canceled → Canceled

Error Handling:

  • Catches VssServiceResponseException for HTTP errors
  • 401 Unauthorized → OnAuthenticationFailure()
  • 429 Too Many Requests → OnRateLimitExceeded()
  • Other VssServiceException → Sets Error status
  • HttpRequestException → Sets Error status

Limitations:

  • No error log fetching implemented yet (Azure DevOps uses different log API)
  • No workflow actions (rerun, cancel, trigger) - Azure DevOps API differs from GitHub

Project Structure

  • Uses ktsu custom SDK (ktsu.Sdk, ktsu.Sdk.App) in the .csproj
  • Targets .NET 10.0
  • Requires AllowUnsafeBlocks for ImGui interop (column width pointer arithmetic)
  • Dependencies managed via Central Package Management (Directory.Packages.props)

Key Dependencies:

  • Hexa.NET.ImGui: Dear ImGui bindings for .NET
  • ktsu.ImGui.*: ktsu wrappers and widgets for ImGui
  • Octokit: GitHub API client library
  • Microsoft.TeamFoundationServer.Client: Azure DevOps API client
  • Microsoft.VisualStudio.Services.Client: Azure DevOps service connection
  • ktsu.Semantics.Strings: Type-safe string wrappers
  • ktsu.AppDataStorage: JSON persistence for application state

Common Development Scenarios

Adding a New Build Provider

  1. Create a new class inheriting from BuildProvider
  2. Add [JsonDerivedType(typeof(NewProvider), nameof(NewProvider))] to BuildProvider.cs
  3. Implement required abstract methods:
    • UpdateRepositoriesAsync: Query provider API for repositories
    • UpdateBuildsAsync: Query for build definitions/workflows
    • UpdateBuildAsync: Fetch recent runs for a build
    • UpdateRunAsync: Update a single run's status
  4. Override ShowMenu() for provider-specific menu items
  5. Implement authentication via inherited credential popup or custom logic
  6. Handle provider-specific rate limiting in request wrapper method
  7. Register provider in BuildMonitor.OnStart():
    needsSave |= AppData.BuildProviders.TryAdd(NewProvider.BuildProviderName, new NewProvider());

IMPORTANT: Use GetOrAdd Pattern for Entity Updates

When implementing provider methods, always use GetOrAdd() instead of TryAdd() to ensure existing entities are updated:

// ❌ WRONG - Only adds new, doesn't update existing
Repository repository = owner.CreateRepository(name, id);
repository.IsPrivate = apiRepo.Private;
if (owner.Repositories.TryAdd(id, repository))
{
    BuildMonitor.QueueSaveAppData();
}

// ✅ CORRECT - Updates existing or creates new
bool isNew = false;
Repository repository = owner.Repositories.GetOrAdd(id, _ =>
{
    isNew = true;
    return owner.CreateRepository(name, id);
});

// Always update mutable properties
bool hasChanges = false;
if (repository.IsPrivate != apiRepo.Private)
{
    repository.IsPrivate = apiRepo.Private;
    hasChanges = true;
}

// Only save when changes occur
if (isNew || hasChanges)
{
    BuildMonitor.QueueSaveAppData();
}

This pattern ensures:

  • Existing entities receive property updates (e.g., repository visibility changes)
  • No duplicate entities are created
  • Efficient saves (only when actual changes occur)
  • Proper logging of new vs. updated entities

Debugging Rate Limit Issues

  • Check provider status bar for rate limit consumption (e.g., "4532/5000")
  • Hover over status for detailed tooltip with reset time
  • Check Logs tab for rate limit messages:
    • "Rate limit pacing - waiting Xms"
    • "Skipping discovery due to low budget (X% remaining)"
    • "Rate limited - resets at HH:mm:ss"
  • Adjust thresholds in BuildProvider.cs:
    • LowBudgetThreshold (currently 15%)
    • CriticalBudgetThreshold (currently 5%)
  • Adjust pacing parameters:
    • BaseRequestDelay (currently 500ms)
    • MinRequestDelay (currently 100ms)
    • Reserve budget percentage in CalculateAdaptivePacing() (currently 10%)

Debugging Priority/Update Issues

  • Check BuildSync.Priority and RunSync.Priority properties
  • Verify ShouldUpdate is returning true for the entity
  • Check if provider's MaxAllowedPriority is filtering the entity
  • Check if entity is orphaned via IsOrphaned property
  • Verify UpdateTimer is running and interval has elapsed
  • Check active requests in BuildMonitor.ActiveRequests dictionary
  • Use "Refresh Build Data" context menu to force immediate update

Modifying Duration Estimation

Key parameters in DurationEstimator.cs:

  • MinSamplesForEstimate: Minimum successful runs required (currently 3)
  • MaxSamplesToConsider: Maximum recent runs to analyze (currently 20)
  • ExponentialDecayFactor: Weight decay per older sample (currently 0.3 = 70% decay)
  • IqrMultiplier: Outlier detection sensitivity (currently 1.5 = standard Tukey fence)

To favor more recent runs: Increase ExponentialDecayFactor (e.g., 0.5 = 50% decay) To be more aggressive with outlier removal: Decrease IqrMultiplier (e.g., 1.0) To use more history: Increase MaxSamplesToConsider