Menu

#393 feat(oauth): native GitHub OAuth device flow (replace headless gh CLI)

closed
nobody
None
2026-07-06
2026-07-06
Anonymous
No

Originally created by: Akarsh-Hegde

Native GitHub OAuth device flow replacing the broken headless gh CLI, plus removal of the now-dead gh-CLI login path.

Scope note (post-merge reconciliation): pre-main independently landed its own GitHub Projects picker while this branch was open. To avoid a duplicate feature, this PR's own picker + sync-feedback commits were reverted, and pre-main's picker is adopted as-is. What remains unique here is the device flow and the gh-CLI removal. The two implementations now compose cleanly: device-flow connect (this PR) → pre-main's project picker.


1. Native GitHub OAuth device flow — feat(oauth)

Problem: the connect flow spawned gh auth login --web headless (stdin null, output to a log). GitHub's --web is the OAuth device flow, which prints a one-time code the user must enter at github.com/login/device — but that code went to a log file the user never saw, so authorization never completed and the UI hung on "Waiting for authorization…" until it timed out. A secondary "gh not found" failure hit when neither gh nor brew was on the hardcoded paths.

Fix — device flow in-process (no gh, no brew, no terminal):

  • meridian-oauth/src/github.rs (new): request_device_code() + poll_for_token() (handles authorization_pending / slow_down backoff / expired_token / access_denied). Public client, no secret; client id baked via option_env!, overridable with GITHUB_OAUTH_CLIENT_ID.
  • Tray (start_oauth_github_device): requests the code synchronously, returns user_code + verification_uri for the UI, opens the browser, polls in the background — writing GITHUB_TOKEN to ~/.meridian/.env. In-flight-guarded.
  • UI: the "waiting" state shows the one-time code (with Copy) + verify link; device-flow poll deadline extended to 15 min to match the code lifetime.
  • CI: release + release-staging bake MERIDIAN_GITHUB_OAUTH_CLIENT_ID from the GH_OAUTH_CLIENT_ID repo secret; build.rs tracks it; .env.example documents it.

Prerequisite: a GitHub OAuth App with Device Flow enabled. Registered (Ov23li…); GH_OAUTH_CLIENT_ID repo secret added. Confirmed working via a live POST /login/device/code and end-to-end (connect → pick projects → sync).

2. Remove dead gh-CLI login path — refactor(oauth)

The device flow fully replaced the shell-out, so the daemon-side src/intelligence/oauth/github.rs module and the meridian oauth-login github subcommand were dead code. Deleted, stale gh-CLI doc references refreshed. GitHub now has exactly one auth code path. (Untouched: the providers/ GitHub API integration for task/worklog sync.)


Verification

  • meridian-oauth: clippy -D warnings clean + unit tests (device-code / client-id parsing)
  • daemon + tray: clippy -D warnings clean; cargo fmt clean
  • UI: typecheck clean, 148/148 tests pass
  • Post-merge with pre-main: MERGEABLE / CLEAN, full pre-push suite green
  • Manually verified end-to-end on a dev build: device-flow connect → project picker → GitHub tasks synced into pm_tasks

Notes for reviewers

  • Targets pre-main (staging). Pure app code — no services/ changes.
  • History includes two Revert commits (this PR's own picker/sync-feedback, dropped in favour of pre-main's picker) — see the scope note above.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
  • GitHub sign-in now uses a browser-based device flow with a one-time code and verification link shown in the app.
  • GitHub connections can now complete setup directly in the UI and proceed to project selection.
  • Bug Fixes
  • Improved GitHub OAuth reliability during staging and release builds.
  • Updated setup guidance and scopes so GitHub authentication works with the new browser-based flow.

Related

Tickets: #416

Discussion

  • Anonymous

    Anonymous - 2026-07-06

    Originally posted by: coderabbitai[bot]

    Review Change Stack

    [!IMPORTANT]

    Review skipped

    Auto reviews are disabled on base/target branches other than the default branch.

    Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.


    ⚙️ Run configuration

    Configuration used: Organization UI

    Review profile: ASSERTIVE

    Plan: Pro Plus

    Run ID: 763e01d3-600f-44ff-a60b-62b1c1c6e9c8

    You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

    Use the checkbox below for a quick retry:
    - [ ] 🔍 Trigger review

    📝 Walkthrough ## Walkthrough This PR replaces the gh CLI-based GitHub OAuth login with an in-process OAuth device flow. A new meridian-oauth github.rs module handles device code requests and token polling, the tray backend runs this flow and persists GITHUB_TOKEN to .env, the CLI drops the github provider branch, and the UI displays device-flow instructions. Build/CI/config are updated for the new client id. ### Changes **GitHub OAuth Device Flow Migration** |Layer / File(s)|Summary| |---|---| |**Device flow core library**
    `meridian-oauth/src/github.rs`, `meridian-oauth/src/lib.rs`|New module implements `request_device_code` and `poll_for_token` against GitHub's device endpoints, resolves an overridable `client_id`, and includes unit tests; exported via `lib.rs`.| |**Remove gh CLI login path**
    `src/intelligence/oauth/github.rs`, `src/intelligence/oauth/mod.rs`, `src/main.rs`|Deletes the old `gh`-CLI-based login module and its export, and removes the `github` provider branch from `meridian oauth-login`, leaving `jira`/`trello` as supported providers.| |**Tray backend device flow wiring**
    `tray/src-tauri/src/commands/integrations.rs`|Adds a GitHub in-flight guard, extends `StartOAuthResponse` with `user_code`/`verification_uri`, and implements `start_oauth_github_device` to request the code, open the verification URL, poll for a token, persist `GITHUB_TOKEN`, and reload the daemon.| |**UI device flow display**
    `ui/components/IntegrationConnect.tsx`, `ui/lib/integrations.ts`|Adds device-flow state and a dedicated timeout, renders verify URL/code/copy UI when device-flow data is present, and updates the GitHub OAuth label/hint to "Browser".| |**Build, CI, and config updates**
    `build.rs`, `.github/workflows/release.yml`, `.github/workflows/release-staging.yml`, `.env.example`, `src/config.rs`|Wires `MERIDIAN_GITHUB_OAUTH_CLIENT_ID` through build rebuild triggers and release workflow secrets, updates `.env.example` docs, and corrects the GitHub token scope comment to `read:project`.| **Estimated code review effort:** 4 (Complex) | ~60 minutes ### Sequence Diagram(s) :::mermaid sequenceDiagram participant UI as IntegrationConnect UI participant Tray as Tray backend participant OAuthLib as meridian-oauth github.rs participant GitHub as GitHub API participant Daemon UI->>Tray: start_oauth("github") Tray->>OAuthLib: request_device_code(client_id, scopes) OAuthLib->>GitHub: POST device code endpoint GitHub-->>OAuthLib: device_code, user_code, verification_uri OAuthLib-->>Tray: DeviceCode Tray-->>UI: user_code, verification_uri UI->>UI: display code + verify link Tray->>OAuthLib: poll_for_token (async) loop until success/denied/expired OAuthLib->>GitHub: POST token endpoint GitHub-->>OAuthLib: authorization_pending / slow_down / access_token end OAuthLib-->>Tray: access_token Tray->>Tray: persist GITHUB_TOKEN to .env Tray->>Daemon: reload_daemon() **Poem** > A rabbit hopped past the old CLI gate, > No more `gh auth` to make you wait — > Now a code appears, so bright and small, > Type it in browser, that's really all! 🐇 > Tokens land safe in the burrow's `.env`, > Device flow magic, again and again.
    🚥 Pre-merge checks | ✅ 5
    ✅ Passed checks (5 passed) | Check name | Status | Explanation | | :------------------------: | :------- | :--------------------------------------------------------------------------------------------------------------------------- | | Title check | ✅ Passed | The title clearly summarizes the main change: replacing the headless GitHub CLI flow with native OAuth device flow. | | Description check | ✅ Passed | The description covers the PR purpose, testing/verification, checklist items, and related context, so it is mostly complete. | | Docstring Coverage | ✅ Passed | No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. | | Linked Issues check | ✅ Passed | Check skipped because no linked issues were found for this pull request. | | Out of Scope Changes check | ✅ Passed | Check skipped because no linked issues were found for this pull request. |
    ✨ Finishing Touches
    🧪 Generate unit tests (beta) - [ ] Create PR with unit tests - [ ] Commit unit tests in branch `feat/github-oauth-device-flow`

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

     
  • Anonymous

    Anonymous - 2026-07-06

    Originally posted by: Akarsh-Hegde

    Review

    Nice fix for the actual root cause (headless --web device code going to a log the user never saw). The device-flow implementation, in-flight guard, and UI code display are solid. Two things worth a look before merge:

    1. poll_for_token aborts the whole login on any transient network error (not just GitHub's in-band error codes)

    meridian-oauth/src/github.rs:

    let raw: TokenPollRaw = client
        .post(TOKEN_URL)
        .header("Accept", "application/json")
        .form(&[...])
        .send()
        .await
        .with_context(|| format!("POST {TOKEN_URL}"))?
        .json()
        .await
        .context("parsing GitHub token-poll response")?;
    

    Both .send()? and .json()? propagate via ? and end poll_for_token entirely. Only the in-band error field on a successfully parsed 200 (authorization_pending/slow_down/expired_token/access_denied) is treated as "keep polling" — a plain network blip (wifi hiccup, laptop sleep/wake, transient DNS failure, a GitHub 5xx that doesn't deserialize as TokenPollRaw) on any one of the ~180 polls over the 15-minute window kills the flow outright. start_oauth_github_device doesn't retry the poll on Err — it just writes the .error sentinel — and the UI's "Try again" always calls start_oauth fresh, requesting a brand-new user_code/device_code rather than resuming.

    This is a robustness regression vs. the gh CLI it replaces: gh (and RFC 8628 implementations generally) ride through transient errors during the polling window; this hand-rolled loop doesn't. Also worth noting poll_for_token's request skips .error_for_status() before .json(), unlike request_device_code's initial call — an HTTP error response there will fail to deserialize and hit the same bail path.

    Suggested fix: on a transient .send()/parse error, continue (log at debug/warn) instead of bailing — the existing expires_in deadline is already the correct backstop for giving up.

    2. Minor: get_oauth_status's doc comment is now stale (nit, pre-existing function, not touched by this diff)

    /// Returns `connected=true` once `~/.meridian/oauth/<provider>.json` exists,
    /// or `error` with the last non-empty line from the OAuth log if the child
    /// process exited with a non-zero status.
    

    No code path writes an error via log-tailing anymore for any provider — start_oauth_in_process and the new start_oauth_github_device/persist_github_token all write the real formatted anyhow error directly to the .error sentinel. This PR is what removes the last log-tailing mechanism (the old github subprocess), so it's a good time to fix the doc comment too — a maintainer debugging an OAuth failure will otherwise go looking for a log file that no longer exists.

    Also a very minor naming nit: GH_CLI_PROVIDERS (integrations.rs) is now a misnomer since there's no more CLI subprocess involved.

    Nothing here blocks merge on its own, but [#1] is worth fixing given how central "the user actually completes the device code" is to this PR's whole purpose.

     

    Related

    Tickets: #1

  • Anonymous

    Anonymous - 2026-07-06

    Originally posted by: Akarsh-Hegde

    Follow-up review — new commits (GitHub Projects v2 picker + reloaded signal)

    Nice addition — picking Projects from a list instead of hand-copying PVT_… node IDs is a real UX improvement. A few things on the new code:

    1. discover_github_projects discards valid data on a partial GraphQL error

    tray/src-tauri/src/commands/integrations.rs:

    if let Some(errs) = body.get("errors").and_then(|e| e.as_array()) {
        // A token missing read:project surfaces here rather than as a non-2xx.
        let msg = errs.first().and_then(|e| e.get("message")).and_then(|m| m.as_str())
            .unwrap_or("unknown GraphQL error");
        return Err(format!("GitHub: {msg}"));
    }
    let viewer = &body["data"]["viewer"];
    

    GraphQL returns HTTP 200 with both data and errors on a partial failure — a common real case is a user who belongs to an org with SAML SSO enforcement (their PAT/OAuth token isn't SSO-authorized for that org) or an org that restricts Projects v2 visibility. GitHub returns that org's slice as an errors entry while still returning the user's own/other-org projects in data. This code bails on the very first errors entry and never reads data.viewer at all, so a user in exactly that (not-uncommon) situation gets an empty picker + a cryptic "GitHub: …" message instead of their own available projects. Suggest: only bail if data is absent/empty after checking errors, otherwise parse what's there and maybe surface the partial-error orgs as a non-fatal note.

    2. The new reloaded signal is only wired into GitHubProjectPicker, not the other 3 save_integration_token callers

    save_integration_token now returns { ok, reloaded } so the UI can warn when the daemon wasn't reachable to pick up new credentials (dev daemon not launchd-supervised, etc.) — but TokenSetup (Jira token / Linear / GitHub PAT path) and AzureDevOpsSetup both call mutate('/api/auth/token', 'save_integration_token', ...) and ignore the return value entirely, still showing a flat "✓ Connected!" regardless of reloaded. Only the new GitHubProjectPicker.save() (which calls the separate save_github_projects) reads and surfaces it. Is that intentional (scoped to just this PR) or should the other three pick up the same warning in a follow-up? Worth a decision either way so the signal isn't half-wired.

    Minor / lower priority

    • Same file, discover_github_projects: resp.json().await? is parsed before checking status.is_success() — if a non-2xx response has a non-JSON body (Cloudflare/edge gateway 502/503/524, an abuse-detection HTML page), the error becomes a confusing "parsing GitHub response: …" instead of "GitHub API error (…)". Low frequency (GitHub's normal 401/403/404/429 bodies are JSON), but the sibling Azure DevOps helper in the same file checks status before parsing — this is an inconsistency, easy to align.
    • projectsV2(first: 100) / organizations(first: 50) have no pagination follow-up and no UI indication if truncated — silently drops entries for very large accounts/orgs. Edge case, probably fine for v1.

    (Note: the two items from the earlier review — poll_for_token's hard-abort on transient network errors, and get_oauth_status's stale "OAuth log" doc comment — are still present in this update, unchanged.)

     
  • Anonymous

    Anonymous - 2026-07-06

    Originally posted by: Akarsh-Hegde

    @coderabbitai review

     
  • Anonymous

    Anonymous - 2026-07-06

    Originally posted by: coderabbitai[bot]

    ✅ Action performed Review finished. > Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.
     
  • Anonymous

    Anonymous - 2026-07-06

    Originally posted by: Akarsh-Hegde

    Update — merge conflict resolved ✅

    Review comments: CodeRabbit skipped auto-review (org config disables it on non-default target branches); an explicit `@coderabbitai review` was acknowledged but produced no findings for the same reason. No actionable inline comments were left.

    Conflict resolved: the conflict was a parallel-feature collisionpre-main independently landed its own GitHub Projects picker while this branch was open, producing duplicate discover_github_projects / GitHubProjectPicker definitions on merge.

    Resolution:

    • Reverted this branch's own picker + sync-feedback commits (they duplicated pre-main's picker) — kept pre-main's picker as the single implementation.
    • Kept the unique work here: the native device flow and the gh-CLI removal.
    • Merged pre-main; the two compose cleanly — start_oauth routes GitHub to start_oauth_github_device (device flow), and pre-main's GitHubProjectPicker handles project selection afterward. Verified only one definition of each remains.

    Verification after resolution: daemon + tray + meridian-oauth clippy -D warnings clean, cargo fmt clean, UI typecheck clean, 148/148 UI tests pass, full pre-push suite green. PR is now MERGEABLE / CLEAN.

     
  • Anonymous

    Anonymous - 2026-07-06

    Originally posted by: coderabbitai[bot]

    @Akarsh-Hegde Thanks for the detailed update on the conflict resolution — reverting the duplicate picker/sync-feedback commits in favor of pre-main's implementation while retaining the device-flow and gh-CLI removal work sounds like the right call, and it's good that the two compose cleanly post-merge.

    Regarding CodeRabbit skipping the auto-review on the non-default target branch: that's expected behavior when your org config restricts automated reviews to the default branch. If you'd like reviews on PRs targeting pre-main (or other non-default branches) going forward, that's controlled by the base_branches / review scope settings in your CodeRabbit configuration — happy to help you check the current settings if useful.

    One note carried over from the earlier review: the two previously flagged items (poll_for_token's hard-abort on transient network errors in meridian-oauth/src/github.rs, and the stale doc comment on get_oauth_status) were called out as still present as of the last picker-review round. Since those are in the device-flow code that's staying in this PR (not the reverted picker commits), worth confirming whether you intend to address them here or in a fast-follow before merge.

    Congrats on getting this to MERGEABLE/CLEAN with the full suite green! 🐰✨

    ✅ Action performed Review finished. > Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.
     
  • Anonymous

    Anonymous - 2026-07-06

    Originally posted by: Akarsh-Hegde

    Review comments addressed ✅

    All items from both review rounds have been fixed in 63157bfe:

    From review [#1]

    • poll_for_token transient error handling.send() and .json() errors now continue the poll loop instead of propagating with ?. A wifi blip or a GitHub 5xx that doesn't parse as TokenPollRaw no longer aborts the 15-minute flow; the expires_in deadline is the authoritative backstop.
    • get_oauth_status doc comment — updated to describe the actual sentinel-file mechanism (~/.meridian/oauth/<p>.error) instead of the removed "last non-empty line from the OAuth log / child process" language.
    • GH_CLI_PROVIDERS rename — renamed to ENV_OAUTH_PROVIDERS; no CLI subprocess has existed since the device-flow rewrite.

    From review [#2]

    • discover_github_projects partial GraphQL errors — now only hard-fails when data.viewer is absent entirely (e.g. bad credentials). A partial-failure response (SAML SSO unenforced org, restricted visibility) is logged as a tracing::warn! while the available projects in data.viewer are still returned — so a user in a partially-blocked org gets their own/other-org projects instead of an empty picker.
    • reloaded signal wired consistentlysave_integration_token now returns { ok, reloaded }. All three callers — TokenSetup (Jira token/Linear/GitHub PAT), AzureDevOpsSetup, and GitHubProjectPicker — read the field and surface a non-fatal note when reloaded === false ("The daemon wasn't running — credentials saved, will take effect on next start"). No more half-wired signal.
    • Status-before-JSON in discover_github_projects — this was already correct in the current code (the pre-main merge brought it in); no change needed.

    All checks green: cargo clippy -D warnings, cargo test (21/21), UI typecheck clean, 181/181 UI tests pass.

     

    Related

    Tickets: #1
    Tickets: #2

  • Anonymous

    Anonymous - 2026-07-06

    Ticket changed by: Akarsh-Hegde

    • status: open --> closed
     

Log in to post a comment.