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-mainindependently 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, andpre-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.
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.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.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).
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.)
meridian-oauth: clippy -D warnings clean + unit tests (device-code / client-id parsing)clippy -D warnings clean; cargo fmt cleanpre-main: MERGEABLE / CLEAN, full pre-push suite greenpm_taskspre-main (staging). Pure app code — no services/ changes.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
Originally posted by: coderabbitai[bot]
📝 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 helpto get the list of available commands.Originally posted by: Akarsh-Hegde
Review
Nice fix for the actual root cause (headless
--webdevice 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_tokenaborts the whole login on any transient network error (not just GitHub's in-band error codes)meridian-oauth/src/github.rs:Both
.send()?and.json()?propagate via?and endpoll_for_tokenentirely. Only the in-banderrorfield 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 asTokenPollRaw) on any one of the ~180 polls over the 15-minute window kills the flow outright.start_oauth_github_devicedoesn't retry the poll onErr— it just writes the.errorsentinel — and the UI's "Try again" always callsstart_oauthfresh, requesting a brand-newuser_code/device_coderather than resuming.This is a robustness regression vs. the
ghCLI it replaces:gh(and RFC 8628 implementations generally) ride through transient errors during the polling window; this hand-rolled loop doesn't. Also worth notingpoll_for_token's request skips.error_for_status()before.json(), unlikerequest_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 existingexpires_indeadline 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)No code path writes an error via log-tailing anymore for any provider —
start_oauth_in_processand the newstart_oauth_github_device/persist_github_tokenall write the real formattedanyhowerror directly to the.errorsentinel. 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:
#1Originally posted by: Akarsh-Hegde
Follow-up review — new commits (GitHub Projects v2 picker +
reloadedsignal)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_projectsdiscards valid data on a partial GraphQL errortray/src-tauri/src/commands/integrations.rs:GraphQL returns HTTP 200 with both
dataanderrorson 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 anerrorsentry while still returning the user's own/other-org projects indata. This code bails on the very firsterrorsentry and never readsdata.viewerat 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 ifdatais 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
reloadedsignal is only wired intoGitHubProjectPicker, not the other 3save_integration_tokencallerssave_integration_tokennow 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.) — butTokenSetup(Jira token / Linear / GitHub PAT path) andAzureDevOpsSetupboth callmutate('/api/auth/token', 'save_integration_token', ...)and ignore the return value entirely, still showing a flat "✓ Connected!" regardless ofreloaded. Only the newGitHubProjectPicker.save()(which calls the separatesave_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
discover_github_projects:resp.json().await?is parsed before checkingstatus.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, andget_oauth_status's stale "OAuth log" doc comment — are still present in this update, unchanged.)Originally posted by: Akarsh-Hegde
@coderabbitai review
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.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 collision —
pre-mainindependently landed its own GitHub Projects picker while this branch was open, producing duplicatediscover_github_projects/GitHubProjectPickerdefinitions on merge.Resolution:
pre-main's picker) — keptpre-main's picker as the single implementation.pre-main; the two compose cleanly —start_oauthroutes GitHub tostart_oauth_github_device(device flow), andpre-main'sGitHubProjectPickerhandles project selection afterward. Verified only one definition of each remains.Verification after resolution: daemon + tray +
meridian-oauthclippy -D warningsclean,cargo fmtclean, UI typecheck clean, 148/148 UI tests pass, full pre-push suite green. PR is now MERGEABLE / CLEAN.Originally posted by: coderabbitai[bot]
@Akarsh-HegdeThanks for the detailed update on the conflict resolution — reverting the duplicate picker/sync-feedback commits in favor ofpre-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 thebase_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 inmeridian-oauth/src/github.rs, and the stale doc comment onget_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.Originally posted by: Akarsh-Hegde
Review comments addressed ✅
All items from both review rounds have been fixed in
63157bfe:From review [#1]
poll_for_tokentransient error handling —.send()and.json()errors nowcontinuethe poll loop instead of propagating with?. A wifi blip or a GitHub 5xx that doesn't parse asTokenPollRawno longer aborts the 15-minute flow; theexpires_indeadline is the authoritative backstop.get_oauth_statusdoc 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_PROVIDERSrename — renamed toENV_OAUTH_PROVIDERS; no CLI subprocess has existed since the device-flow rewrite.From review [#2]
discover_github_projectspartial GraphQL errors — now only hard-fails whendata.vieweris absent entirely (e.g. bad credentials). A partial-failure response (SAML SSO unenforced org, restricted visibility) is logged as atracing::warn!while the available projects indata.viewerare still returned — so a user in a partially-blocked org gets their own/other-org projects instead of an empty picker.reloadedsignal wired consistently —save_integration_tokennow returns{ ok, reloaded }. All three callers —TokenSetup(Jira token/Linear/GitHub PAT),AzureDevOpsSetup, andGitHubProjectPicker— read the field and surface a non-fatal note whenreloaded === false("The daemon wasn't running — credentials saved, will take effect on next start"). No more half-wired signal.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:
#1Tickets:
#2Ticket changed by: Akarsh-Hegde