This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# 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 cleanBuildMonitor 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.
The application maintains a nested hierarchy of build data:
- BuildProvider (e.g., GitHub, AzureDevOps) → Owner → Repository → Build → Run
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)
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.
- Main Loop (BuildMonitor.cs): Calls
UpdateAsync()every frame - 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"
- Build Updates (30-120s, priority-based):
- High priority: 30s interval
- Medium priority: 60s interval
- Low priority: 120s interval
- Filtered by provider's
MaxAllowedPrioritybased on budget
- 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
ShouldUpdateproperty - Determines its priority via
Priorityproperty - Is pruned when orphaned (parent entity deleted) or completed (runs only)
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 ownerUpdateBuildsAsync(Repository): Discover/update build definitions (workflows) for a repositoryUpdateBuildAsync(Build): Fetch recent runs for a specific buildUpdateRunAsync(Run): Update the status of a specific run
- Register in
BuildMonitor.OnStart()by adding toAppData.BuildProviders - Implement provider-specific menu actions via
ShowMenu()override
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 ofTryAdd()to get existing or create new - Always updates mutable properties after getting the entity:
IsPrivate: Updated when repository visibility changesIsArchived: Updated when repository is archived/unarchivedIsFork: 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
BuildNameif 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) andUpdateRunFromBuild()(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
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
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)
MaxAllowedPriorityreturns 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(): ResetsRateLimitSleepback to base delay (500ms) when recovering- Status automatically clears on first successful request after rate limiting
Provider-Level Authentication:
- Each provider has an
AccountIdandToken(stored in AppData, persisted) - Set via "Set Credentials" menu item (two-step popup: AccountId, then Token)
- Cleared automatically on
AuthorizationExceptionor 403 Forbidden responses
Owner-Level Authentication (GitHub only):
- Each owner can have an optional
Tokenproperty (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
GitHubClientinstances per token (TokenClientsdictionary) - 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
VssConnectionwithVssBasicCredentialusing AccountId and Token - Gets
ProjectHttpClientandBuildHttpClientfrom connection UpdateAzureDevOpsClientCredentials()recreates clients when credentials change- Organization URL format:
https://dev.azure.com/{AccountId}
- All data updates happen asynchronously via Task-based operations
ConcurrentDictionaryis used throughout for thread-safe collection accessBuildMonitor.SyncLockexists but is only used for menu renderingRequestSemaphorelimits concurrent requests per provider (default: 5)- Rate limiting uses adaptive delays calculated per request via
GetRateLimitWaitTime() - Active requests are tracked in
ActiveRequestsdictionary for UI feedback
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:
- Status (60px): Color indicator or radial progress bar
- Owner (150px): Owner/organization/project name
- Repository (150px): Repository name with property icons
- Build Name (200px): Workflow/build definition name (cleaned of
.ymland path prefixes) - Branch (150px): Git branch name
- Status (80px): Text status (Pending, Running, Success, Failure, Canceled)
- Last Run (180px): Timestamp of last run start (local time with timezone)
- Duration (80px): Duration of the run (format:
hh:mm:ssord.hh:mm:ss) - Estimate (80px): Estimated total duration for the build
- History (80px): Last 5 non-canceled runs as color indicators
- Progress (100px): Radial progress bar + percentage (ongoing builds only)
- ETA (80px): Estimated time remaining (ongoing builds only)
- Errors (200px): Error messages from failed runs (clickable for details)
- 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.ColumnWidthsdictionary - 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.
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
ApiExceptionfailures
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
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)
- GitHub Actions error annotations:
- 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}] Failedif 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
Build duration estimation uses sophisticated statistical methods via DurationEstimator:
Estimation Strategy:
- Sample Selection: Uses last 20 successful runs, ordered by start time (most recent first)
- 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)
- 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)
- 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)^iwhere i is age index
Branch-Specific Estimation:
Build.CalculateEstimatedDuration(branch): Estimates for a specific branchBuild.CalculateEstimatedDuration(): Estimates across all branches- Run ETA calculation uses branch-specific estimation:
Run.CalculateETA()callsBuild.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()
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)
- Fetches authenticated user via
- 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)
- Fetches projects via
- Add Owner: Manually add a project by name
AppData Structure:
Persisted via ktsu.AppDataStorage to JSON file:
WindowState: ImGui window position, size, and stateBuildProviders: Dictionary of provider instances (serialized polymorphically)ColumnWidths: Dictionary of column name to widthSelectedOwnerTabId: Currently selected tab ID- Filter settings per column (text, type, match options):
FilterOwner,FilterOwnerType,FilterOwnerMatchOptionsFilterRepository,FilterRepositoryType,FilterRepositoryMatchOptionsFilterBuildName,FilterBuildNameType,FilterBuildNameMatchOptionsFilterBranch,FilterBranchType,FilterBranchMatchOptionsFilterStatus,FilterStatusType,FilterStatusMatchOptions
Save Batching:
QueueSaveAppData(): Marks data for saving without immediate writeSaveSettingsIfRequired(): Called each frame, saves if queued- Batches multiple changes within a single frame into one write
Data Cleared on Auth Failure:
- Provider
AccountIdandTokencleared on authentication failure - Provider status set to
AuthFailed - Owner tokens preserved (only provider token cleared)
The codebase uses ktsu.Semantics.Strings for type-safe string identifiers:
BuildProviderName,BuildProviderAccountId,BuildProviderTokenOwnerName,OwnerIdRepositoryName,RepositoryIdBuildName,BuildIdRunName,RunIdBranchName
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>();- BuildMonitor.cs: Main application class with UI rendering, update orchestration, and tab management
- AppData.cs: Persistent application state and settings
- 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
- 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
- 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
- DurationEstimator.cs: Statistical duration estimation with IQR outlier removal and exponential weighting
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.
API Client:
- Uses Octokit library (
OctokitNuGet package) - Maintains per-token client cache to avoid race conditions
- Creates
GitHubClientwithProductHeaderValueandCredentials - Token format: Personal Access Token (PAT) with
repoandworkflowscopes
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,IsForkon 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
BuildNameif workflow file is renamed - Maps
Workflow.IdtoBuildIdandWorkflow.NametoBuildName
Run Fetching:
GitHubRuns.ListByWorkflow(): Gets last 10 runs for a workflow- Maps workflow run status/conclusion to
RunStatusenum - 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 runGitHubJobs.GetLogs(): Gets log text for each failed job- Parses logs for
##[error]annotations and error patterns
Rate Limit Handling:
- Updates budget from
ApiInfo.RateLimitafter each successful request - Detects rate limits via 403 Forbidden +
X-RateLimit-Remaining: 0header - Parses reset time from
X-RateLimit-Resetheader (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
API Client:
- Uses Microsoft.TeamFoundation libraries (
Microsoft.TeamFoundationServer.Client, etc.) - Creates
VssConnectionwith organization URI:https://dev.azure.com/{AccountId} - Uses
VssBasicCredentialwith empty username and Personal Access Token (PAT) - Gets
ProjectHttpClientfor project/repository operations - Gets
BuildHttpClientfor 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
BuildNameif build definition is renamed - Maps
BuildDefinitionReference.IdtoBuildId - Maps
BuildDefinitionReference.NametoBuildName - Logs when new build definitions are discovered
Build/Run Fetching:
BuildClient.GetBuildsAsync(project, definitions, top): Gets last 10 builds for a definition- Maps
Build.IdtoRunIdandBuild.BuildNumbertoRunName - Branch name extracted from
SourceBranch(removesrefs/heads/prefix)
Run Updates:
BuildClient.GetBuildAsync(project, buildId): Fetches single build by ID- Uses
StartTimeor falls back toQueueTimefor run start - Uses
FinishTimeor current time for ongoing runs - Maps
BuildStatusandBuildResulttoRunStatus:NotStarted→ PendingInProgress/Cancelling→ RunningCompleted+Succeeded/PartiallySucceeded→ SuccessCompleted+Failed→ FailureCompleted+Canceled→ Canceled
Error Handling:
- Catches
VssServiceResponseExceptionfor 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
- Uses ktsu custom SDK (
ktsu.Sdk,ktsu.Sdk.App) in the .csproj - Targets .NET 10.0
- Requires
AllowUnsafeBlocksfor ImGui interop (column width pointer arithmetic) - Dependencies managed via Central Package Management (Directory.Packages.props)
Key Dependencies:
Hexa.NET.ImGui: Dear ImGui bindings for .NETktsu.ImGui.*: ktsu wrappers and widgets for ImGuiOctokit: GitHub API client libraryMicrosoft.TeamFoundationServer.Client: Azure DevOps API clientMicrosoft.VisualStudio.Services.Client: Azure DevOps service connectionktsu.Semantics.Strings: Type-safe string wrappersktsu.AppDataStorage: JSON persistence for application state
- Create a new class inheriting from
BuildProvider - Add
[JsonDerivedType(typeof(NewProvider), nameof(NewProvider))]toBuildProvider.cs - Implement required abstract methods:
UpdateRepositoriesAsync: Query provider API for repositoriesUpdateBuildsAsync: Query for build definitions/workflowsUpdateBuildAsync: Fetch recent runs for a buildUpdateRunAsync: Update a single run's status
- Override
ShowMenu()for provider-specific menu items - Implement authentication via inherited credential popup or custom logic
- Handle provider-specific rate limiting in request wrapper method
- 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
- 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%)
- Check
BuildSync.PriorityandRunSync.Priorityproperties - Verify
ShouldUpdateis returning true for the entity - Check if provider's
MaxAllowedPriorityis filtering the entity - Check if entity is orphaned via
IsOrphanedproperty - Verify
UpdateTimeris running and interval has elapsed - Check active requests in
BuildMonitor.ActiveRequestsdictionary - Use "Refresh Build Data" context menu to force immediate update
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