Skip to content

fix: handle expired gh auth during runner start#67

Merged
Peyton-Spencer merged 1 commit intomainfrom
feat/53-gh-auth-expiry
Mar 26, 2026
Merged

fix: handle expired gh auth during runner start#67
Peyton-Spencer merged 1 commit intomainfrom
feat/53-gh-auth-expiry

Conversation

@Peyton-Spencer
Copy link
Copy Markdown
Contributor

@Peyton-Spencer Peyton-Spencer commented Mar 25, 2026

Summary

  • validate gh auth status before runner registration work so add/start/restart paths fail with explicit recovery guidance instead of silently breaking when credentials expire
  • surface the auth failure in the menubar footer and refresh that UI state when settings re-check GitHub auth
  • add focused tests for the new auth-state parsing and recovery messaging

Testing

  • git diff --check
  • swift test (not runnable in this container: swift: command not found)

Notes

  • this blocks restart loops caused by expired gh auth by marking the runner with an actionable auth failure instead of repeatedly retrying blind

Summary by CodeRabbit

  • New Features

    • GitHub authentication status is now monitored and refreshed on app startup
    • Authentication issues are displayed in the menu bar when problems are detected
  • Bug Fixes

    • Enhanced error messaging for GitHub authentication failures with specific, actionable recovery guidance
    • Authentication validation now occurs at critical operation points for improved reliability
  • Tests

    • Added test coverage for authentication state detection and error message generation

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 25, 2026

Warning

Rate limit exceeded

@Peyton-Spencer has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 4 minutes and 23 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c131683e-5643-4eda-8cb2-a86228303452

📥 Commits

Reviewing files that changed from the base of the PR and between ff16286dd5de98c8a3b0e51b005f91cebaa09c54 and 1f33fe3.

📒 Files selected for processing (5)
  • Sources/Services/CLIHandler.swift
  • Sources/Services/GHCLIService.swift
  • Sources/Services/RunnerManager.swift
  • Sources/Views/MenuBarView.swift
  • Tests/MacRunnerTests/MacRunnerTests.swift
📝 Walkthrough

Walkthrough

The PR refactors GitHub CLI authentication handling by introducing a new GitHubAuthState value type to replace boolean auth checks. The changes enable richer error messages for authentication failures by extracting statusMessage and recoveryMessage from process outputs. Authentication validation is now integrated into runner operations and surfaced in the UI through a new gitHubAuthIssue state property.

Changes

Cohort / File(s) Summary
GitHub CLI Service Refactoring
Sources/Services/GHCLIService.swift
Added GitHubAuthState struct with isAuthenticated, statusMessage, and recoveryMessage fields. Introduced validateAuth() method returning state object; refactored checkAuth() and authStatus() to use the new state-based approach for consistent error handling.
CLI Handler Integration
Sources/Services/CLIHandler.swift
Updated handleAdd command to use validateAuth() instead of checkAuth(), guarding on authState.isAuthenticated and displaying authState.recoveryMessage for authentication failures.
Runner Manager Auth Management
Sources/Services/RunnerManager.swift
Added gitHubAuthIssue published property for state tracking. Introduced refreshGitHubAuthStatus() and private validateGitHubAuth() helper method. Integrated auth validation into addRunner and startRunner workflows to catch authentication issues before token/repo operations.
UI Auth Status Display
Sources/Views/MenuBarView.swift
Added conditional rendering of gitHubAuthIssue message in menu bar footer as a red caption. Updated SettingsView.checkAuth() to call refreshGitHubAuthStatus() after fetching initial auth status.
Test Coverage
Tests/MacRunnerTests/MacRunnerTests.swift
Added three test cases validating GitHubAuthState.fromProcessResult() logic: authenticated state with login message, unauthenticated state with token expiration details, and unauthenticated state with generic fallback message.

Sequence Diagram(s)

sequenceDiagram
    participant Runner as Runner Operation
    participant Manager as RunnerManager
    participant Service as GHCLIService
    participant Process as gh auth status
    participant UI as MenuBar/UI

    Runner->>Manager: addRunner() / startRunner()
    activate Manager
    Manager->>Manager: validateGitHubAuth()
    activate Manager
    Manager->>Service: validateAuth()
    activate Service
    Service->>Process: runGH(["auth","status"])
    Process-->>Service: stdout/stderr/exitCode
    Service->>Service: GitHubAuthState.fromProcessResult()
    Service-->>Manager: GitHubAuthState
    deactivate Service
    alt isAuthenticated == false
        Manager->>Manager: Set gitHubAuthIssue
        Manager-->>Runner: throw GHError.authFailed
    else isAuthenticated == true
        Manager->>Manager: Clear gitHubAuthIssue
    end
    deactivate Manager
    deactivate Manager
    
    Manager->>UI: Publish gitHubAuthIssue
    UI->>UI: Render auth error message
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related issues

  • Handle gh auth token expiry gracefully for long-running runners #53: The changes implement validateAuth() returning rich auth state and integrate validateGitHubAuth() checks in runner operations (addRunner/startRunner), plus UI display of auth issues—directly addressing the request to validate gh auth on runner start/restart and surface error messages for expired tokens.

Suggested labels

priority:high

Poem

🐰 A hop through auth, so clean and bright,
State messages guide us left and right,
No more booleans, we now declare,
Rich errors flow through every layer,
GitHub's trust, now crystal clear!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the main objective: handling expired GitHub authentication during runner operations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/53-gh-auth-expiry

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
Sources/Views/MenuBarView.swift (1)

560-564: Use one auth snapshot for the Settings refresh.

Lines 561-563 run gh auth status three times (checkAuth(), authStatus(), then refreshGitHubAuthStatus()). If the CLI state changes between calls, the GitHub tab and footer can disagree. Fetch validateAuth() once here and derive all three UI fields from that result.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Views/MenuBarView.swift` around lines 560 - 564, Replace the three
separate CLI calls with a single auth snapshot fetch: call
GHCLIService.shared.validateAuth() once, store its result, set isAuthenticated
from that snapshot and set authStatusText from the snapshot's status string,
then call runnerManager.refreshGitHubAuthStatus(...) using the same snapshot (or
add a refresh overload that accepts the snapshot) so all UI state
(isAuthenticated, authStatusText, and runner refresh) derive from the same
validateAuth() result instead of calling checkAuth(), authStatus(), and
refreshGitHubAuthStatus() separately.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Services/GHCLIService.swift`:
- Around line 92-105: The current validateAuth() uses try? which swallows launch
errors from runGH and returns a generic "auth expired" message; change
validateAuth() to use a do-catch around runGH(["auth", "status"]) so you can
inspect thrown errors (e.g., GHError.processLaunchFailed) and return a distinct
GitHubAuthState when the gh binary failed to launch (with a recoveryMessage
advising to check the gh installation/path), while keeping the existing
GitHubAuthState.fromProcessResult(...) for normal process results; update the
error branch to preserve and surface launch-failure details rather than always
suggesting "gh auth login".

In `@Sources/Services/RunnerManager.swift`:
- Around line 1104-1113: The recoveryMessage from ghService.validateAuth can
contain embedded newlines, so normalize it to a single-line string before using
it: in validateGitHubAuth(for:operation:) call ghService.validateAuth(), then
create a sanitized variable (e.g., normalizedRecovery =
authState.recoveryMessage.replacingOccurrences(of: "\n", with: "
").trimmingCharacters(in: .whitespacesAndNewlines)) and assign gitHubAuthIssue,
error, pass normalizedRecovery into logRunnerEvent(for:message:) and throw
GHError.authFailed(normalizedRecovery) so runner.log receives one timestamped
line and formatting stays intact.

---

Nitpick comments:
In `@Sources/Views/MenuBarView.swift`:
- Around line 560-564: Replace the three separate CLI calls with a single auth
snapshot fetch: call GHCLIService.shared.validateAuth() once, store its result,
set isAuthenticated from that snapshot and set authStatusText from the
snapshot's status string, then call runnerManager.refreshGitHubAuthStatus(...)
using the same snapshot (or add a refresh overload that accepts the snapshot) so
all UI state (isAuthenticated, authStatusText, and runner refresh) derive from
the same validateAuth() result instead of calling checkAuth(), authStatus(), and
refreshGitHubAuthStatus() separately.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e7486c2-fb83-4e6c-8cc6-eaa93440a664

📥 Commits

Reviewing files that changed from the base of the PR and between c708907 and ff16286dd5de98c8a3b0e51b005f91cebaa09c54.

📒 Files selected for processing (5)
  • Sources/Services/CLIHandler.swift
  • Sources/Services/GHCLIService.swift
  • Sources/Services/RunnerManager.swift
  • Sources/Views/MenuBarView.swift
  • Tests/MacRunnerTests/MacRunnerTests.swift

Comment on lines +92 to +105
func validateAuth() async -> GitHubAuthState {
guard let result = try? await runGH(["auth", "status"]) else {
return false
return GitHubAuthState(
isAuthenticated: false,
statusMessage: "gh CLI not found or not authenticated",
recoveryMessage: "GitHub authentication expired or is invalid. Run: gh auth login"
)
}
return result.exitCode == 0

return GitHubAuthState.fromProcessResult(
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve gh launch failures instead of rewriting them as auth expiry.

Line 93 uses try?, so GHError.processLaunchFailed(...) gets flattened into the same “authentication expired” recovery text. On machines where gh is missing or the configured path is wrong, the suggested fix (gh auth login) is incorrect.

💡 Suggested fix
     func validateAuth() async -> GitHubAuthState {
-        guard let result = try? await runGH(["auth", "status"]) else {
-            return GitHubAuthState(
-                isAuthenticated: false,
-                statusMessage: "gh CLI not found or not authenticated",
-                recoveryMessage: "GitHub authentication expired or is invalid. Run: gh auth login"
-            )
-        }
-
-        return GitHubAuthState.fromProcessResult(
-            exitCode: result.exitCode,
-            stdout: result.stdout,
-            stderr: result.stderr
-        )
+        do {
+            let result = try await runGH(["auth", "status"])
+            return GitHubAuthState.fromProcessResult(
+                exitCode: result.exitCode,
+                stdout: result.stdout,
+                stderr: result.stderr
+            )
+        } catch let GHError.processLaunchFailed(detail) {
+            return GitHubAuthState(
+                isAuthenticated: false,
+                statusMessage: "Failed to launch gh CLI at \(ghPath)",
+                recoveryMessage: "GitHub CLI is unavailable at \(ghPath). Verify the path or install `gh`, then retry.\n\(detail)"
+            )
+        } catch {
+            return GitHubAuthState(
+                isAuthenticated: false,
+                statusMessage: "gh CLI not found or not authenticated",
+                recoveryMessage: "GitHub authentication expired or is invalid. Run: gh auth login"
+            )
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Services/GHCLIService.swift` around lines 92 - 105, The current
validateAuth() uses try? which swallows launch errors from runGH and returns a
generic "auth expired" message; change validateAuth() to use a do-catch around
runGH(["auth", "status"]) so you can inspect thrown errors (e.g.,
GHError.processLaunchFailed) and return a distinct GitHubAuthState when the gh
binary failed to launch (with a recoveryMessage advising to check the gh
installation/path), while keeping the existing
GitHubAuthState.fromProcessResult(...) for normal process results; update the
error branch to preserve and surface launch-failure details rather than always
suggesting "gh auth login".

Comment on lines +1104 to +1113
private func validateGitHubAuth(for runner: Runner, operation: String) async throws {
let authState = await ghService.validateAuth()
guard authState.isAuthenticated else {
gitHubAuthIssue = authState.recoveryMessage
error = authState.recoveryMessage
logRunnerEvent(
for: runner,
message: "GitHub auth check failed during \(operation): \(authState.recoveryMessage)"
)
throw GHError.authFailed(authState.recoveryMessage)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize multiline recovery text before writing it to runner.log.

Line 1111 logs recoveryMessage verbatim, but that string now intentionally includes embedded newlines. logRunnerEvent(...) writes one timestamped line per call, so the detailed auth text will spill into unprefixed lines and break log formatting.

💡 Suggested fix
         guard authState.isAuthenticated else {
             gitHubAuthIssue = authState.recoveryMessage
             error = authState.recoveryMessage
+            let logMessage = authState.recoveryMessage.replacingOccurrences(of: "\n", with: " | ")
             logRunnerEvent(
                 for: runner,
-                message: "GitHub auth check failed during \(operation): \(authState.recoveryMessage)"
+                message: "GitHub auth check failed during \(operation): \(logMessage)"
             )
             throw GHError.authFailed(authState.recoveryMessage)
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func validateGitHubAuth(for runner: Runner, operation: String) async throws {
let authState = await ghService.validateAuth()
guard authState.isAuthenticated else {
gitHubAuthIssue = authState.recoveryMessage
error = authState.recoveryMessage
logRunnerEvent(
for: runner,
message: "GitHub auth check failed during \(operation): \(authState.recoveryMessage)"
)
throw GHError.authFailed(authState.recoveryMessage)
private func validateGitHubAuth(for runner: Runner, operation: String) async throws {
let authState = await ghService.validateAuth()
guard authState.isAuthenticated else {
gitHubAuthIssue = authState.recoveryMessage
error = authState.recoveryMessage
let logMessage = authState.recoveryMessage.replacingOccurrences(of: "\n", with: " | ")
logRunnerEvent(
for: runner,
message: "GitHub auth check failed during \(operation): \(logMessage)"
)
throw GHError.authFailed(authState.recoveryMessage)
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Services/RunnerManager.swift` around lines 1104 - 1113, The
recoveryMessage from ghService.validateAuth can contain embedded newlines, so
normalize it to a single-line string before using it: in
validateGitHubAuth(for:operation:) call ghService.validateAuth(), then create a
sanitized variable (e.g., normalizedRecovery =
authState.recoveryMessage.replacingOccurrences(of: "\n", with: "
").trimmingCharacters(in: .whitespacesAndNewlines)) and assign gitHubAuthIssue,
error, pass normalizedRecovery into logRunnerEvent(for:message:) and throw
GHError.authFailed(normalizedRecovery) so runner.log receives one timestamped
line and formatting stays intact.

@Peyton-Spencer Peyton-Spencer force-pushed the feat/53-gh-auth-expiry branch from ff16286 to 4baba28 Compare March 25, 2026 20:43
Validate gh auth before fetching registration tokens, publish actionable auth failures in the menubar UI, and stop restart paths from failing silently when credentials expire. Add focused tests around the new auth-state messaging so the recovery guidance stays explicit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Peyton-Spencer Peyton-Spencer force-pushed the feat/53-gh-auth-expiry branch from 4baba28 to 1f33fe3 Compare March 25, 2026 20:52
@Peyton-Spencer Peyton-Spencer merged commit dc4efa2 into main Mar 26, 2026
2 checks passed
@Peyton-Spencer Peyton-Spencer deleted the feat/53-gh-auth-expiry branch March 26, 2026 00:40
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 1.14.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant