From 27a4a0a85ba6d755295739ad43274d287134dcdf Mon Sep 17 00:00:00 2001 From: Guillaume Huard Hughes Date: Fri, 5 Jun 2026 06:00:36 -0400 Subject: [PATCH 01/44] Add GitHub auth process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿช„ Commit via GitWand --- .gitignore | 2 + CHANGELOG.md | 8 + apps/desktop/dev-server.mjs | 39 + apps/desktop/src-tauri/src/commands/gh.rs | 30 + .../src-tauri/src/commands/github_api.rs | 699 ++++++++++++++++++ apps/desktop/src-tauri/src/commands/mod.rs | 1 + apps/desktop/src-tauri/src/commands/ops.rs | 34 + apps/desktop/src-tauri/src/lib.rs | 4 + apps/desktop/src-tauri/src/types.rs | 27 + .../src/components/SettingsAccountsTab.vue | 127 +++- apps/desktop/src/composables/useGithubAuth.ts | 148 ++++ apps/desktop/src/locales/en.ts | 7 + apps/desktop/src/locales/es.ts | 7 + apps/desktop/src/locales/fr.ts | 7 + apps/desktop/src/locales/pt-BR.ts | 7 + apps/desktop/src/locales/zh-CN.ts | 7 + apps/desktop/src/utils/backend-pr.ts | 69 ++ 17 files changed, 1216 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src-tauri/src/commands/github_api.rs create mode 100644 apps/desktop/src/composables/useGithubAuth.ts diff --git a/.gitignore b/.gitignore index 44a4ff1e..a5226469 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,5 @@ research/ .release-notes-v1.5.0.md website/.vitepress/.temp .claude/worktrees/ + +.gitwandrc diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea8c93d..0b14cbcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Sign in with GitHub (no `gh` CLI required)** โ€” Settings โ†’ Accounts now offers a "Sign in with GitHub" button using the OAuth device flow. The resulting token is stored in the OS keychain; once present, the GitHub PR workflow (list, count, detail, diff, checks, files, create, merge, checkout, mark-ready) routes through the GitHub REST API instead of shelling out to `gh`. The `gh` CLI still works as before when no Settings token is configured โ€” the ambient `GH_TOKEN`/`GITHUB_TOKEN` env path is unchanged. + +### Notes + +- Ships with GitWand's registered GitHub OAuth App `client_id` (`Ov23licwiCpPiRPRodWN`, public โ€” device flow enabled) baked into `github_api.rs`. Override at runtime or build time via `GITWAND_GH_CLIENT_ID` if needed. + ## [2.17.0] - 2026-06-04 v2.17 rounds out the agent-CLI lineup with **opencode** as a first-class AI provider, and gives every CLI agent its own model picker โ€” a second select under the provider dropdown, scoped per provider so switching back restores the previous choice. diff --git a/apps/desktop/dev-server.mjs b/apps/desktop/dev-server.mjs index 913efd36..d1f6ef86 100644 --- a/apps/desktop/dev-server.mjs +++ b/apps/desktop/dev-server.mjs @@ -152,6 +152,12 @@ function gitSpawn(args, cwd) { }); } +// โ”€โ”€ Mock state: GitHub OAuth device flow (dev:web only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// The real flow lives in Rust (`github_api.rs`). In the browser mock we fake it +// so the Settings > Accounts UI can be exercised without Tauri. It does NOT log +// you in โ€” it just returns a static code then "succeeds" after a couple polls. +let _mockGithubPolls = 0; + /** * Get a GitHub OAuth token โ€” tries in order: * 1. GH_TOKEN / GITHUB_TOKEN env vars @@ -2179,6 +2185,39 @@ async function handleRequest(req, res) { // โ”€โ”€โ”€ GitHub REST API endpoints (no gh binary needed) โ”€โ”€โ”€โ”€โ”€โ”€ + // POST /api/github-device-start โ€” MOCK device flow (dev:web only). + // Returns a static user code + verification URL. The frontend opens the URL + // and starts polling; see /api/github-device-poll below. + if (url.pathname === "/api/github-device-start" && req.method === "POST") { + _mockGithubPolls = 0; + // NOTE: verification_uri intentionally does NOT point at the real + // github.com/login/device โ€” this is a fake flow and a real code is never + // issued. Sending the user to GitHub with a bogus code only confuses. + return jsonResponse(req, res, { + device_code: "mock-device-code", + user_code: "DEV-MOCK", + verification_uri: "about:blank#gitwand-dev-mock", + verification_uri_complete: "", + expires_in: 900, + interval: 1, + }); + } + + // POST /api/github-device-poll โ€” MOCK: "pending" twice, then "success". + // No real token is stored; this only unblocks UI iteration in dev:web. + if (url.pathname === "/api/github-device-poll" && req.method === "POST") { + _mockGithubPolls += 1; + if (_mockGithubPolls < 3) { + return jsonResponse(req, res, { status: "pending", login: "", error: "" }); + } + return jsonResponse(req, res, { status: "success", login: "dev-user", error: "" }); + } + + // POST /api/github-token-present โ€” MOCK: never "logged in" in dev:web. + if (url.pathname === "/api/github-token-present" && req.method === "POST") { + return jsonResponse(req, res, false); + } + // GET /api/gh-current-user โ€” returns the authenticated GitHub login if (url.pathname === "/api/gh-current-user" && req.method === "GET") { try { diff --git a/apps/desktop/src-tauri/src/commands/gh.rs b/apps/desktop/src-tauri/src/commands/gh.rs index 3c350c78..a34489b1 100644 --- a/apps/desktop/src-tauri/src/commands/gh.rs +++ b/apps/desktop/src-tauri/src/commands/gh.rs @@ -15,6 +15,7 @@ use crate::git::*; use crate::types::*; +use crate::commands::github_api; /// List pull requests using `gh` CLI. /// @@ -45,6 +46,11 @@ pub(crate) fn gh_list_prs( limit: Option, offset: Option, ) -> Result, String> { + // Settings-managed token present โ†’ tokenless REST path (no `gh` needed). + if let Some(tok) = github_api::settings_github_token() { + let st = if state.is_empty() { "open" } else { &state }; + return github_api::rest_list_prs(&cwd, st, limit.unwrap_or(10), offset.unwrap_or(0), &tok); + } let st = if state.is_empty() { "open" } else { &state }; // Naรฏve pagination: `gh pr list` has no native --offset, so we ask for // `offset + limit` and slice. Works correctly for the small windows the @@ -101,6 +107,9 @@ pub(crate) fn gh_list_prs( /// dashboard can still render โ€” the caller decides whether to surface. #[tauri::command] pub(crate) fn gh_pr_count(cwd: String, state: String) -> Result { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_count(&cwd, &state, &tok); + } let st = state.to_lowercase(); let states_expr = match st.as_str() { "closed" => "[CLOSED]", @@ -191,6 +200,9 @@ pub(crate) fn gh_create_pr( draft: bool, reviewers: Option>, ) -> Result { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_create_pr(&cwd, title, body, base, draft, reviewers, &tok); + } let mut args = vec![ "pr".to_string(), "create".to_string(), @@ -368,6 +380,9 @@ pub(crate) fn gh_list_reviewer_candidates(cwd: String) -> Result Result<(), String> { + if github_api::settings_github_token().is_some() { + return github_api::rest_checkout_pr(&cwd, number); + } let output = hidden_cmd("gh") .args(["pr", "checkout", &number.to_string()]) .current_dir(&cwd) @@ -385,6 +400,9 @@ pub(crate) fn gh_checkout_pr(cwd: String, number: i64) -> Result<(), String> { /// Merge a PR using `gh` CLI. #[tauri::command] pub(crate) fn gh_merge_pr(cwd: String, number: i64, method: String) -> Result<(), String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_merge_pr(&cwd, number, &method, &tok); + } let merge_flag = match method.as_str() { "squash" => "--squash", "rebase" => "--rebase", @@ -411,6 +429,9 @@ pub(crate) fn gh_merge_pr(cwd: String, number: i64, method: String) -> Result<() /// "Pull request #N is already marked as ready for review." #[tauri::command] pub(crate) fn gh_pr_ready(cwd: String, number: i64) -> Result<(), String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_ready(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args(["pr", "ready", &number.to_string()]) .current_dir(&cwd) @@ -428,6 +449,9 @@ pub(crate) fn gh_pr_ready(cwd: String, number: i64) -> Result<(), String> { /// Get detailed PR information using `gh` CLI. #[tauri::command] pub(crate) fn gh_pr_detail(cwd: String, number: i64) -> Result { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_detail(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args([ "pr", "view", &number.to_string(), @@ -451,6 +475,9 @@ pub(crate) fn gh_pr_detail(cwd: String, number: i64) -> Result Result { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_diff(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args(["pr", "diff", &number.to_string()]) .current_dir(&cwd) @@ -467,6 +494,9 @@ pub(crate) fn gh_pr_diff(cwd: String, number: i64) -> Result { /// Get CI checks for a PR using `gh` CLI. #[tauri::command] pub(crate) fn gh_pr_checks(cwd: String, number: i64) -> Result, String> { + if let Some(tok) = github_api::settings_github_token() { + return github_api::rest_pr_checks(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args([ "pr", "checks", &number.to_string(), diff --git a/apps/desktop/src-tauri/src/commands/github_api.rs b/apps/desktop/src-tauri/src/commands/github_api.rs new file mode 100644 index 00000000..32d66d59 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/github_api.rs @@ -0,0 +1,699 @@ +//! GitHub REST/OAuth Tauri commands โ€” tokenless PR workflow. +//! +//! ## Why this exists +//! +//! The historical GitHub integration (`gh.rs`, `ops.rs`) shells out to the +//! `gh` CLI. That requires the user to install and `gh auth login` from a +//! terminal โ€” a friction point for non-CLI users. This module adds a second, +//! self-contained auth path: +//! +//! 1. **OAuth device flow** (`github_device_start` / `github_device_poll`): +//! "Sign in with GitHub" from Settings โ†’ Accounts. The resulting token is +//! stored in the OS keychain under `service = "gitwand:github"`, +//! `account = "oauth"`. +//! 2. **REST API calls** via `curl` (mirrors `bitbucket.rs`): when a keychain +//! token is present, the `gh_*` commands route here instead of spawning +//! `gh`, so the `gh` binary is no longer required. +//! +//! ## Routing rule (see `settings_github_token`) +//! +//! REST is used **only** when an explicit Settings token exists in the keychain. +//! The ambient `GH_TOKEN` / `GITHUB_TOKEN` env vars (auto-populated from +//! `gh auth token` on macOS, see `shell_env.rs`) keep driving the `gh` CLI path +//! exactly as before โ€” we never silently change behaviour for existing `gh` +//! users. Token in keychain โ‡’ REST; otherwise โ‡’ `gh`. +//! +//! ## Scope +//! +//! Core PR workflow only: current-user, list, count, detail, diff, checks, +//! files, create, merge, checkout, ready. Comments / reviews / reviewer +//! candidates / intelligence keep the `gh` path (or degrade gracefully). +//! +//! ## Security note +//! +//! The token is injected via the `Authorization` header in `curl` process +//! arguments โ€” same exposure profile as `bitbucket.rs` (acceptable for now; +//! harden via `curl --config -` stdin piping in a follow-up). The token is +//! never logged or included in error messages. + +use crate::git::{git_cmd, hidden_cmd}; +use crate::types::*; + +// โ”€โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Keychain service for the Settings-managed GitHub token. +pub(crate) const GH_SERVICE: &str = "gitwand:github"; +/// Keychain account key โ€” fixed, since the REST helpers resolve the token +/// without knowing the login up front. +pub(crate) const GH_ACCOUNT: &str = "oauth"; + +/// GitHub OAuth App client_id (public, not a secret โ€” safe to ship). +/// +/// **Must be replaced** with a real client_id from a registered GitHub OAuth +/// App that has *device flow* enabled +/// (github.com/settings/developers โ†’ New OAuth App โ†’ "Enable Device Flow"). +/// +/// Resolution order: +/// 1. `GITWAND_GH_CLIENT_ID` at **runtime** (env var of the running app) โ€” +/// lets `GITWAND_GH_CLIENT_ID=โ€ฆ pnpm tauri dev` work without fighting +/// cargo's build cache. +/// 2. `GITWAND_GH_CLIENT_ID` baked in at **build time** (release builds). +/// 3. Placeholder โ€” surfaces a clear "not configured" error. +fn client_id() -> String { + if let Ok(v) = std::env::var("GITWAND_GH_CLIENT_ID") { + let v = v.trim().to_string(); + if !v.is_empty() { + return v; + } + } + option_env!("GITWAND_GH_CLIENT_ID") + // GitWand's registered GitHub OAuth App (device flow enabled). Public + // client_id โ€” not a secret, safe to ship. + .unwrap_or("Ov23licwiCpPiRPRodWN") + .to_string() +} + +const API_BASE: &str = "https://api.github.com"; + +// โ”€โ”€โ”€ Token resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Read the Settings-managed GitHub token from the OS keychain. +/// +/// Returns `Some(token)` only when the user has explicitly signed in via +/// Settings > Accounts. Ambient env tokens are intentionally ignored here so +/// existing `gh` users are unaffected (see module docs). +pub(crate) fn settings_github_token() -> Option { + let entry = keyring::Entry::new(GH_SERVICE, GH_ACCOUNT).ok()?; + let tok = entry.get_password().ok()?; + let tok = tok.trim().to_string(); + if tok.is_empty() { None } else { Some(tok) } +} + +// โ”€โ”€โ”€ Owner/repo resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Parse `(owner, repo)` from `git remote get-url origin` in `cwd`. +/// +/// Handles HTTPS and SSH GitHub remote URL formats. +fn owner_repo(cwd: &str) -> Result<(String, String), String> { + let output = hidden_cmd("git") + .args(["remote", "get-url", "origin"]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git remote get-url: {}", e))?; + if !output.status.success() { + return Err("No 'origin' remote found in this repo.".to_string()); + } + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + let path = if url.starts_with("git@") || url.contains("@github.com:") { + // git@github.com:owner/repo.git โ†’ owner/repo + url.splitn(2, ':') + .nth(1) + .unwrap_or("") + .trim_end_matches(".git") + .to_string() + } else { + // https://github.com/owner/repo.git โ†’ owner/repo + url.trim_end_matches('/') + .trim_end_matches(".git") + .splitn(2, "github.com/") + .nth(1) + .unwrap_or("") + .to_string() + }; + + let mut parts = path.splitn(2, '/'); + let owner = parts.next().unwrap_or("").to_string(); + let repo = parts.next().unwrap_or("").to_string(); + if owner.is_empty() || repo.is_empty() { + return Err(format!("Could not parse GitHub owner/repo from remote URL: {}", url)); + } + Ok((owner, repo)) +} + +/// Current branch name (`git rev-parse --abbrev-ref HEAD`). +fn current_branch(cwd: &str) -> Result { + let output = hidden_cmd("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git rev-parse: {}", e))?; + if !output.status.success() { + return Err("Could not determine current branch.".to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +// โ”€โ”€โ”€ HTTP transport (curl) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Run a `curl` request and return `(http_status, body_text)`. +/// +/// `accept` selects the representation (`application/vnd.github+json` for JSON, +/// `application/vnd.github.v3.diff` for raw diffs). The token, when present, +/// is sent as a Bearer credential. +fn curl_raw( + method: &str, + url: &str, + token: Option<&str>, + body_json: Option<&str>, + accept: &str, +) -> Result<(i32, String), String> { + // `-w` appends the HTTP status on its own marker line so we can split it + // off the body without a second request. + const MARKER: &str = "\n__GW_HTTP_STATUS__"; + let mut args: Vec = vec![ + "-s".to_string(), + "-X".to_string(), method.to_string(), + "-H".to_string(), format!("Accept: {}", accept), + "-H".to_string(), "User-Agent: GitWand".to_string(), + "-H".to_string(), "X-GitHub-Api-Version: 2022-11-28".to_string(), + ]; + if let Some(tok) = token { + args.push("-H".to_string()); + args.push(format!("Authorization: Bearer {}", tok)); + } + if let Some(b) = body_json { + args.push("-H".to_string()); + args.push("Content-Type: application/json".to_string()); + args.push("-d".to_string()); + args.push(b.to_string()); + } + args.push("-w".to_string()); + args.push(format!("{}%{{http_code}}", MARKER)); + args.push(url.to_string()); + + let output = hidden_cmd("curl") + .args(&args) + .output() + .map_err(|e| format!("curl not found or failed to spawn: {}", e))?; + + let combined = String::from_utf8_lossy(&output.stdout).to_string(); + let (body, status) = match combined.rsplit_once(MARKER) { + Some((b, s)) => (b.to_string(), s.trim().parse::().unwrap_or(0)), + None => (combined, 0), + }; + Ok((status, body)) +} + +/// Perform a GitHub JSON API call. Maps HTTP โ‰ฅ 400 to a user-facing error, +/// preferring GitHub's `message` field. +fn api_json( + method: &str, + url: &str, + token: &str, + body_json: Option<&str>, +) -> Result { + let (status, body) = curl_raw(method, url, Some(token), body_json, "application/vnd.github+json")?; + if status >= 400 { + let msg = serde_json::from_str::(body.trim()) + .ok() + .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from)) + .unwrap_or_else(|| format!("HTTP {}", status)); + return Err(format!("GitHub API error ({}): {}", status, msg)); + } + if body.trim().is_empty() { + return Ok(serde_json::Value::Null); + } + serde_json::from_str(body.trim()) + .map_err(|e| format!("Failed to parse GitHub response: {}", e)) +} + +// โ”€โ”€โ”€ JSON field helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +fn js(v: &serde_json::Value, key: &str) -> String { + v.get(key).and_then(|x| x.as_str()).unwrap_or("").to_string() +} + +fn ji(v: &serde_json::Value, key: &str) -> i64 { + v.get(key).and_then(|x| x.as_i64()).unwrap_or(0) +} + +fn jb(v: &serde_json::Value, key: &str) -> bool { + v.get(key).and_then(|x| x.as_bool()).unwrap_or(false) +} + +/// `obj[outer][inner]` as a string. +fn jnested(v: &serde_json::Value, outer: &str, inner: &str) -> String { + v.get(outer).and_then(|o| o.get(inner)).and_then(|s| s.as_str()).unwrap_or("").to_string() +} + +/// Collect `[{ key: "..." }]` string fields into a Vec. +fn jlogins(v: &serde_json::Value, arr_key: &str, field: &str) -> Vec { + v.get(arr_key) + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|u| u.get(field).and_then(|s| s.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default() +} + +// โ”€โ”€โ”€ Mapping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Map a GitHub REST pull-request object to `PullRequest`. +fn json_to_pr(pr: &serde_json::Value) -> PullRequest { + let merged = pr.get("merged_at").map(|m| !m.is_null()).unwrap_or(false); + let state = if merged { "merged".to_string() } else { js(pr, "state") }; + PullRequest { + number: ji(pr, "number"), + title: js(pr, "title"), + state, + author: jnested(pr, "user", "login"), + branch: jnested(pr, "head", "ref"), + base: jnested(pr, "base", "ref"), + draft: jb(pr, "draft"), + created_at: js(pr, "created_at"), + updated_at: js(pr, "updated_at"), + url: js(pr, "html_url"), + additions: ji(pr, "additions"), + deletions: ji(pr, "deletions"), + labels: jlogins(pr, "labels", "name"), + assignees: jlogins(pr, "assignees", "login"), + review_requested: jlogins(pr, "requested_reviewers", "login"), + review_decision: String::new(), + merge_state_status: js(pr, "mergeable_state").to_uppercase(), + checks_rollup: String::new(), + comment_count: ji(pr, "comments"), + } +} + +/// Map a GitHub REST pull-request object to `PullRequestDetail`. +fn json_to_detail(pr: &serde_json::Value) -> PullRequestDetail { + let merged_at = pr.get("merged_at").and_then(|m| m.as_str()).unwrap_or("").to_string(); + let merged = !merged_at.is_empty(); + let state = if merged { "merged".to_string() } else { js(pr, "state") }; + // GitHub `mergeable`: true / false / null (still computing). + let mergeable = match pr.get("mergeable").and_then(|m| m.as_bool()) { + Some(true) => "MERGEABLE".to_string(), + Some(false) => "CONFLICTING".to_string(), + None => "UNKNOWN".to_string(), + }; + PullRequestDetail { + number: ji(pr, "number"), + title: js(pr, "title"), + body: js(pr, "body"), + state, + author: jnested(pr, "user", "login"), + branch: jnested(pr, "head", "ref"), + base: jnested(pr, "base", "ref"), + draft: jb(pr, "draft"), + created_at: js(pr, "created_at"), + updated_at: js(pr, "updated_at"), + merged_at, + url: js(pr, "html_url"), + additions: ji(pr, "additions"), + deletions: ji(pr, "deletions"), + changed_files: ji(pr, "changed_files"), + comments: ji(pr, "comments"), + review_comments: ji(pr, "review_comments"), + labels: jlogins(pr, "labels", "name"), + reviewers: jlogins(pr, "requested_reviewers", "login"), + mergeable, + checks_status: String::new(), + } +} + +// โ”€โ”€โ”€ REST PR workflow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +pub(crate) fn rest_current_user(token: &str) -> Result { + let v = api_json("GET", &format!("{}/user", API_BASE), token, None)?; + let login = v.get("login").and_then(|l| l.as_str()).unwrap_or("").to_string(); + if login.is_empty() { + return Err("GitHub returned an empty login for this token.".to_string()); + } + Ok(login) +} + +pub(crate) fn rest_list_prs( + cwd: &str, + state: &str, + limit: i64, + offset: i64, + token: &str, +) -> Result, String> { + let (owner, repo) = owner_repo(cwd)?; + let page = limit.max(1); + let off = offset.max(0); + // Naรฏve pagination identical to gh_list_prs: fetch offset+limit, then slice. + let per_page = (page + off).clamp(1, 100); + // "merged" is not a native GitHub pulls state โ€” fetch closed and filter. + let api_state = match state { + "closed" => "closed", + "merged" => "closed", + "all" => "all", + _ => "open", + }; + let url = format!( + "{}/repos/{}/{}/pulls?state={}&per_page={}&page=1&sort=updated&direction=desc", + API_BASE, owner, repo, api_state, per_page + ); + let v = api_json("GET", &url, token, None)?; + let arr = v.as_array().cloned().unwrap_or_default(); + let mut prs: Vec = arr + .iter() + .filter(|pr| { + if state == "merged" { + pr.get("merged_at").map(|m| !m.is_null()).unwrap_or(false) + } else { + true + } + }) + .map(json_to_pr) + .collect(); + if off > 0 { + let skip = (off as usize).min(prs.len()); + prs.drain(..skip); + } + prs.truncate(page as usize); + Ok(prs) +} + +pub(crate) fn rest_pr_count(cwd: &str, state: &str, token: &str) -> Result { + let (owner, repo) = owner_repo(cwd)?; + let qualifier = match state.to_lowercase().as_str() { + "closed" => "+state:closed", + "merged" => "+is:merged", + "all" => "", + _ => "+state:open", + }; + // /search/issues returns total_count without expanding every item. + let url = format!( + "{}/search/issues?q=repo:{}/{}+type:pr{}&per_page=1", + API_BASE, owner, repo, qualifier + ); + let v = api_json("GET", &url, token, None)?; + Ok(v.get("total_count").and_then(|c| c.as_i64()).unwrap_or(0)) +} + +pub(crate) fn rest_pr_detail(cwd: &str, number: i64, token: &str) -> Result { + let (owner, repo) = owner_repo(cwd)?; + let url = format!("{}/repos/{}/{}/pulls/{}", API_BASE, owner, repo, number); + let v = api_json("GET", &url, token, None)?; + Ok(json_to_detail(&v)) +} + +pub(crate) fn rest_pr_diff(cwd: &str, number: i64, token: &str) -> Result { + let (owner, repo) = owner_repo(cwd)?; + let url = format!("{}/repos/{}/{}/pulls/{}", API_BASE, owner, repo, number); + let (status, body) = curl_raw("GET", &url, Some(token), None, "application/vnd.github.v3.diff")?; + if status >= 400 { + return Err(format!("GitHub diff failed (HTTP {})", status)); + } + Ok(body) +} + +pub(crate) fn rest_pr_checks(cwd: &str, number: i64, token: &str) -> Result, String> { + let (owner, repo) = owner_repo(cwd)?; + // Resolve the PR head SHA, then list check-runs for that commit. + let pr = api_json( + "GET", + &format!("{}/repos/{}/{}/pulls/{}", API_BASE, owner, repo, number), + token, + None, + )?; + let sha = jnested(&pr, "head", "sha"); + if sha.is_empty() { + return Ok(Vec::new()); + } + let url = format!("{}/repos/{}/{}/commits/{}/check-runs", API_BASE, owner, repo, sha); + let v = match api_json("GET", &url, token, None) { + Ok(v) => v, + Err(_) => return Ok(Vec::new()), // no checks configured โ€” not fatal + }; + let runs = v.get("check_runs").and_then(|r| r.as_array()).cloned().unwrap_or_default(); + let checks = runs + .iter() + .map(|run| CICheck { + name: js(run, "name"), + state: js(run, "status"), + conclusion: js(run, "conclusion"), + details_url: js(run, "html_url"), + }) + .collect(); + Ok(checks) +} + +pub(crate) fn rest_pr_files(cwd: &str, number: i64, token: &str) -> Result, String> { + let (owner, repo) = owner_repo(cwd)?; + let url = format!("{}/repos/{}/{}/pulls/{}/files?per_page=100", API_BASE, owner, repo, number); + let v = api_json("GET", &url, token, None)?; + let files = v + .as_array() + .map(|arr| arr.iter().map(|f| js(f, "filename")).filter(|s| !s.is_empty()).collect()) + .unwrap_or_default(); + Ok(files) +} + +pub(crate) fn rest_create_pr( + cwd: &str, + title: String, + body: String, + base: String, + draft: bool, + reviewers: Option>, + token: &str, +) -> Result { + let (owner, repo) = owner_repo(cwd)?; + let head = current_branch(cwd)?; + + // Resolve base from the repo default branch when the caller left it empty. + let base = if base.is_empty() { + let repo_info = api_json("GET", &format!("{}/repos/{}/{}", API_BASE, owner, repo), token, None)?; + let default = js(&repo_info, "default_branch"); + if default.is_empty() { "main".to_string() } else { default } + } else { + base + }; + + let payload = serde_json::json!({ + "title": title, + "head": head, + "base": base, + "body": body, + "draft": draft, + }); + let url = format!("{}/repos/{}/{}/pulls", API_BASE, owner, repo); + let created = api_json("POST", &url, token, Some(&payload.to_string()))?; + let number = ji(&created, "number"); + + // Best-effort reviewer request โ€” never fail PR creation if this errors. + if let Some(revs) = reviewers { + let cleaned: Vec = revs + .into_iter() + .map(|r| r.trim().trim_start_matches('@').to_string()) + .filter(|r| !r.is_empty()) + .collect(); + if !cleaned.is_empty() && number > 0 { + let rev_payload = serde_json::json!({ "reviewers": cleaned }); + let rev_url = format!( + "{}/repos/{}/{}/pulls/{}/requested_reviewers", + API_BASE, owner, repo, number + ); + let _ = api_json("POST", &rev_url, token, Some(&rev_payload.to_string())); + } + } + + Ok(json_to_pr(&created)) +} + +pub(crate) fn rest_merge_pr(cwd: &str, number: i64, method: &str, token: &str) -> Result<(), String> { + let (owner, repo) = owner_repo(cwd)?; + let merge_method = match method { + "squash" => "squash", + "rebase" => "rebase", + _ => "merge", + }; + // Capture the head branch first so we can delete it after a clean merge. + let pr = api_json( + "GET", + &format!("{}/repos/{}/{}/pulls/{}", API_BASE, owner, repo, number), + token, + None, + )?; + let branch = jnested(&pr, "head", "ref"); + + let payload = serde_json::json!({ "merge_method": merge_method }); + let url = format!("{}/repos/{}/{}/pulls/{}/merge", API_BASE, owner, repo, number); + api_json("PUT", &url, token, Some(&payload.to_string()))?; + + // Best-effort branch deletion (mirrors `gh pr merge --delete-branch`). + if !branch.is_empty() { + let ref_url = format!("{}/repos/{}/{}/git/refs/heads/{}", API_BASE, owner, repo, branch); + let _ = api_json("DELETE", &ref_url, token, None); + } + Ok(()) +} + +pub(crate) fn rest_pr_ready(cwd: &str, number: i64, token: &str) -> Result<(), String> { + let (owner, repo) = owner_repo(cwd)?; + // Draftโ†’ready is GraphQL-only; resolve the PR node_id first. + let pr = api_json( + "GET", + &format!("{}/repos/{}/{}/pulls/{}", API_BASE, owner, repo, number), + token, + None, + )?; + let node_id = js(&pr, "node_id"); + if node_id.is_empty() { + return Err("Could not resolve PR node_id for ready conversion.".to_string()); + } + let query = format!( + "mutation {{ markPullRequestReadyForReview(input: {{ pullRequestId: \"{}\" }}) {{ pullRequest {{ isDraft }} }} }}", + node_id + ); + let payload = serde_json::json!({ "query": query }); + let v = api_json("POST", &format!("{}/graphql", API_BASE), token, Some(&payload.to_string()))?; + if let Some(errors) = v.get("errors").and_then(|e| e.as_array()) { + if !errors.is_empty() { + let msg = errors[0].get("message").and_then(|m| m.as_str()).unwrap_or("GraphQL error"); + return Err(format!("Mark-ready failed: {}", msg)); + } + } + Ok(()) +} + +pub(crate) fn rest_checkout_pr(cwd: &str, number: i64) -> Result<(), String> { + // Local git fetch of the PR head ref โ€” token not needed (git uses its own + // credential helper). Works for same-repo and fork PRs alike. + let local = format!("pr-{}", number); + let refspec = format!("pull/{}/head:{}", number, local); + let fetch = git_cmd() + .args(["fetch", "origin", &refspec]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git fetch failed: {}", e))?; + if !fetch.status.success() { + return Err(format!( + "git fetch pull/{}/head failed: {}", + number, + String::from_utf8_lossy(&fetch.stderr).trim() + )); + } + let checkout = git_cmd() + .args(["checkout", &local]) + .current_dir(cwd) + .output() + .map_err(|e| format!("git checkout failed: {}", e))?; + if !checkout.status.success() { + return Err(format!( + "git checkout {} failed: {}", + local, + String::from_utf8_lossy(&checkout.stderr).trim() + )); + } + Ok(()) +} + +// โ”€โ”€โ”€ OAuth device flow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Begin the OAuth device flow. Returns the user code + verification URL the +/// frontend shows, plus the `device_code` used for polling. +#[tauri::command] +pub(crate) fn github_device_start() -> Result { + let cid = client_id(); + if cid.starts_with("REPLACE_WITH") { + return Err( + "GitHub login is not configured: missing OAuth App client_id. \ + Set GITWAND_GH_CLIENT_ID at build time or update github_api.rs." + .to_string(), + ); + } + // GitHub's OAuth endpoints accept a JSON body when Content-Type is JSON + // (which `curl_raw` sets whenever a body is present). + let body = serde_json::json!({ "client_id": cid, "scope": "repo" }); + let (status, text) = curl_raw( + "POST", + "https://github.com/login/device/code", + None, + Some(&body.to_string()), + "application/json", + )?; + if status >= 400 { + return Err(format!("GitHub device-code request failed (HTTP {})", status)); + } + let v: serde_json::Value = serde_json::from_str(text.trim()) + .map_err(|e| format!("Failed to parse device-code response: {}", e))?; + if let Some(err) = v.get("error").and_then(|e| e.as_str()) { + return Err(format!("GitHub device-code error: {}", err)); + } + Ok(GithubDeviceCode { + device_code: js(&v, "device_code"), + user_code: js(&v, "user_code"), + verification_uri: js(&v, "verification_uri"), + verification_uri_complete: js(&v, "verification_uri_complete"), + expires_in: ji(&v, "expires_in"), + interval: ji(&v, "interval").max(5), + }) +} + +/// Poll once for the OAuth access token. +/// +/// `status` is one of: `"pending"` (keep polling), `"slow_down"` (back off), +/// `"success"` (token stored, `login` populated), or `"error"`. +/// On success the token is persisted to the OS keychain so the REST path can +/// pick it up; the secret is never returned to the frontend. +#[tauri::command] +pub(crate) fn github_device_poll(device_code: String) -> Result { + let cid = client_id(); + let body = serde_json::json!({ + "client_id": cid, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + }); + let (status, text) = curl_raw( + "POST", + "https://github.com/login/oauth/access_token", + None, + Some(&body.to_string()), + "application/json", + )?; + if status >= 400 { + return Err(format!("GitHub token poll failed (HTTP {})", status)); + } + let v: serde_json::Value = serde_json::from_str(text.trim()) + .map_err(|e| format!("Failed to parse token response: {}", e))?; + + if let Some(err) = v.get("error").and_then(|e| e.as_str()) { + let kind = match err { + "authorization_pending" => "pending", + "slow_down" => "slow_down", + _ => "error", + }; + return Ok(GithubDevicePoll { + status: kind.to_string(), + login: String::new(), + error: if kind == "error" { err.to_string() } else { String::new() }, + }); + } + + let token = js(&v, "access_token"); + if token.is_empty() { + return Ok(GithubDevicePoll { + status: "pending".to_string(), + login: String::new(), + error: String::new(), + }); + } + + // Persist to keychain so the REST path activates. + let entry = keyring::Entry::new(GH_SERVICE, GH_ACCOUNT) + .map_err(|e| format!("keyring init failed: {}", e))?; + entry + .set_password(&token) + .map_err(|e| format!("Failed to store GitHub token: {}", e))?; + + // Resolve the login for display (non-fatal if it fails). + let login = rest_current_user(&token).unwrap_or_default(); + Ok(GithubDevicePoll { + status: "success".to_string(), + login, + error: String::new(), + }) +} + +/// Whether a Settings-managed GitHub token is currently stored. +#[tauri::command] +pub(crate) fn github_token_present() -> Result { + Ok(settings_github_token().is_some()) +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 29abc78b..86fba264 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod bitbucket; pub(crate) mod credentials; pub(crate) mod files; pub(crate) mod gh; +pub(crate) mod github_api; pub(crate) mod gitlab; pub(crate) mod mcp_catalog; pub(crate) mod network; diff --git a/apps/desktop/src-tauri/src/commands/ops.rs b/apps/desktop/src-tauri/src/commands/ops.rs index ca726bb6..ad0ad9a2 100644 --- a/apps/desktop/src-tauri/src/commands/ops.rs +++ b/apps/desktop/src-tauri/src/commands/ops.rs @@ -2663,6 +2663,10 @@ pub(crate) fn shell_exec(cwd: String, command: String) -> Result /// Calls `gh api user --jq .login` โ€” fast, no repo context needed. #[tauri::command] pub(crate) fn gh_current_user() -> Result { + // Settings-managed token present โ†’ resolve via REST (no `gh` needed). + if let Some(tok) = crate::commands::github_api::settings_github_token() { + return crate::commands::github_api::rest_current_user(&tok); + } // GH_TOKEN propagation: centralized in `hidden_cmd` (cf. git/cmd.rs). let output = hidden_cmd("gh") .args(["api", "user", "--jq", ".login"]) @@ -2690,6 +2694,9 @@ pub(crate) fn gh_current_user() -> Result { // readers don't think it's missing the prefix by accident. #[tauri::command] pub(crate) fn pr_files(cwd: String, number: i64) -> Result, String> { + if let Some(tok) = crate::commands::github_api::settings_github_token() { + return crate::commands::github_api::rest_pr_files(&cwd, number, &tok); + } let output = hidden_cmd("gh") .args([ "pr", "view", &number.to_string(), @@ -2731,6 +2738,33 @@ pub(crate) fn git_merge_base(cwd: String, ref1: String, ref2: String) -> Result< Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +// โ”€โ”€โ”€ Open a URL in the system browser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// +// The webview's `window.open` is a no-op in Tauri, so external links must be +// handed to the OS default handler. Restricted to http(s) to avoid opening +// arbitrary schemes (file://, etc.). + +#[tauri::command] +pub(crate) fn open_url(url: String) -> Result<(), String> { + if !(url.starts_with("https://") || url.starts_with("http://")) { + return Err("Refusing to open non-http(s) URL".to_string()); + } + #[cfg(target_os = "macos")] + let mut cmd = hidden_cmd("open"); + #[cfg(target_os = "linux")] + let mut cmd = hidden_cmd("xdg-open"); + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = hidden_cmd("cmd"); + c.args(["/C", "start", ""]); + c + }; + cmd.arg(&url) + .spawn() + .map_err(|e| format!("Failed to open URL: {}", e))?; + Ok(()) +} + // โ”€โ”€โ”€ Open in external editor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ #[tauri::command] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ae9af778..43ea60f8 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -303,6 +303,7 @@ pub fn run() { commands::ops::git_stash, commands::ops::git_stash_pop, commands::ops::open_in_editor, + commands::ops::open_url, commands::ops::set_git_config, commands::ops::read_gitwandrc, commands::read::preview_merge, @@ -327,6 +328,9 @@ pub fn run() { commands::gh::gh_pr_diff, commands::gh::gh_pr_checks, commands::gh::gh_pr_ready, + commands::github_api::github_device_start, + commands::github_api::github_device_poll, + commands::github_api::github_token_present, commands::ops::git_exec, commands::ops::git_autocomplete, commands::ops::git_get_user, diff --git a/apps/desktop/src-tauri/src/types.rs b/apps/desktop/src-tauri/src/types.rs index 10a16377..0acdd0ff 100644 --- a/apps/desktop/src-tauri/src/types.rs +++ b/apps/desktop/src-tauri/src/types.rs @@ -476,6 +476,33 @@ pub struct PullRequestDetail { pub checks_status: String, } +// โ”€โ”€โ”€ GitHub OAuth device flow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Returned by `github_device_start` โ€” the user-facing code + the +/// `device_code` the frontend polls with. +#[derive(Serialize)] +pub struct GithubDeviceCode { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + /// `verification_uri` with the user code pre-filled โ€” opening this skips the + /// manual code-entry step. Empty if GitHub omits it. + pub verification_uri_complete: String, + pub expires_in: i64, + /// Minimum seconds between polls (GitHub-mandated, floored at 5). + pub interval: i64, +} + +/// Returned by `github_device_poll`. `status` โˆˆ +/// `"pending" | "slow_down" | "success" | "error"`. The token itself is never +/// returned โ€” it is stored directly in the OS keychain on success. +#[derive(Serialize)] +pub struct GithubDevicePoll { + pub status: String, + pub login: String, + pub error: String, +} + // โ”€โ”€โ”€ CI Check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ #[derive(Serialize)] diff --git a/apps/desktop/src/components/SettingsAccountsTab.vue b/apps/desktop/src/components/SettingsAccountsTab.vue index 125a0c24..82fc06dc 100644 --- a/apps/desktop/src/components/SettingsAccountsTab.vue +++ b/apps/desktop/src/components/SettingsAccountsTab.vue @@ -5,12 +5,18 @@ * Settings > Accounts tab โ€” v2.10 ยง4.2. */ -import { ref, computed } from "vue"; +import { ref, computed, watch } from "vue"; import { useI18n } from "../composables/useI18n"; import { useAccounts } from "../composables/useAccounts"; import { useCredentials } from "../composables/useCredentials"; +import { useGithubAuth, GITHUB_TOKEN_KEY } from "../composables/useGithubAuth"; +import { isTauri } from "../utils/backend"; import type { ForgeName } from "../composables/forge/types"; +// In dev:web there is no Rust backend โ€” the GitHub flow is a fake mock that +// cannot actually authenticate. Flag it so the UI says so plainly. +const isDevMock = !isTauri(); + const { t } = useI18n(); const { accounts, @@ -28,6 +34,17 @@ const { removeCredential, } = useCredentials(); +// GitHub OAuth device flow โ€” auto-unwrapped refs for template ergonomics. +const { + phase: ghPhase, + userCode: ghUserCode, + error: ghError, + login: ghLogin, + start: ghStart, + openVerification: ghOpen, + reset: ghReset, +} = useGithubAuth(); + // โ”€โ”€โ”€ Add-account form โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const showForm = ref(false); @@ -47,13 +64,40 @@ function openForm() { formToken.value = ""; formError.value = null; formSuccess.value = false; + ghReset(); showForm.value = true; } function cancelForm() { + ghReset(); showForm.value = false; } +// โ”€โ”€โ”€ GitHub OAuth device flow โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function startGithubLogin() { + formError.value = null; + ghStart(); +} + +// On successful sign-in, register the account. The token is already in the OS +// keychain (stored by the Rust poll command) under GITHUB_TOKEN_KEY. +watch(ghPhase, (p) => { + if (p !== "success") return; + addAccount({ + forge: "github", + label: formLabel.value.trim() || ghLogin.value, + username: ghLogin.value, + tokenKey: GITHUB_TOKEN_KEY, + }); + formSuccess.value = true; + setTimeout(() => { + showForm.value = false; + formSuccess.value = false; + ghReset(); + }, 1200); +}); + async function submitForm() { formError.value = null; formSuccess.value = false; @@ -175,15 +219,37 @@ const totalAccounts = computed(() => accounts.value.length); -
-