diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f97a5c..60e7b8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,18 @@ cache the same queries run ~4.7× faster (e.g. `length` 966 ms → drivers so the zero-file rule lives once. - The emscripten (playground) build keeps the sequential engine — no threads there. +## `toolpath-github`: the caller supplies the API token — 2026-08-14 + +- **`toolpath-github`** (0.7.0): breaking. Removed: `resolve_token`. + `DeriveConfig.token` is the only token input, so the caller owns + token resolution. The crate reads no environment variable and runs no + subprocess. +- **`path-cli`** (unreleased): `Config` reads `$GITHUB_TOKEN`. + `providers::github_token` returns that value when it is set and not + empty, and falls back to `gh auth token`. `p import github` and + `p list github` take the token from there, so both keep their + behavior for CLI users. + ## `toolpath-pi`: the caller supplies the home directory — 2026-08-14 - **`toolpath-pi`** (0.7.0): breaking. `PathResolver::new(home)` takes diff --git a/Cargo.lock b/Cargo.lock index fd7bf1a6..d8cb2be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4334,7 +4334,7 @@ dependencies = [ [[package]] name = "toolpath-github" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index f9716b44..922d1e92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ toolpath-codex = { version = "0.7.0", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.2.0", path = "crates/toolpath-copilot" } toolpath-opencode = { version = "0.6.0", path = "crates/toolpath-opencode" } toolpath-cursor = { version = "0.3.0", path = "crates/toolpath-cursor" } -toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } +toolpath-github = { version = "0.7.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.7.0", path = "crates/toolpath-pi" } diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 18e771c8..4b2a5dfe 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -282,7 +282,7 @@ fn derive(source: ImportSource, config: &Config) -> Result> { pr, no_ci, no_comments, - } => derive_github(url, repo, pr, no_ci, no_comments), + } => derive_github(url, repo, pr, no_ci, no_comments, config), ImportSource::Claude { project, session, @@ -386,10 +386,11 @@ fn derive_github( pr: Option, no_ci: bool, no_comments: bool, + config: &Config, ) -> Result> { #[cfg(target_os = "emscripten")] { - let _ = (url, repo, pr, no_ci, no_comments); + let _ = (url, repo, pr, no_ci, no_comments, config); anyhow::bail!("'path import github' requires a native environment with network access"); } @@ -413,15 +414,15 @@ fn derive_github( ); }; - let token = toolpath_github::resolve_token()?; - let config = toolpath_github::DeriveConfig { - token, + let derive_config = toolpath_github::DeriveConfig { + token: providers::github_token(config)?, include_ci: !no_ci, include_comments: !no_comments, ..Default::default() }; - let path = toolpath_github::derive_pull_request(&owner, &repo_name, pr_number, &config)?; + let path = + toolpath_github::derive_pull_request(&owner, &repo_name, pr_number, &derive_config)?; let doc = Graph::from_path(path); let cache_id = make_id("github", &format!("{owner}_{repo_name}-{pr_number}")); Ok(vec![DerivedDoc { diff --git a/crates/path-cli/src/cmd_list.rs b/crates/path-cli/src/cmd_list.rs index 71e252b1..2488f41d 100644 --- a/crates/path-cli/src/cmd_list.rs +++ b/crates/path-cli/src/cmd_list.rs @@ -110,7 +110,7 @@ pub fn run( let fmt = resolve_format(format, json_flag); match source { ListSource::Git { repo, remote } => run_git(repo, remote, fmt), - ListSource::Github { repo } => run_github(repo, fmt), + ListSource::Github { repo } => run_github(repo, fmt, config), ListSource::Claude { project } => run_claude(project, fmt, config), ListSource::Gemini { project } => run_gemini(project, fmt, config), ListSource::Codex {} => run_codex(fmt, config), @@ -197,10 +197,10 @@ fn run_git(repo_path: PathBuf, remote: String, fmt: ListFormat) -> Result<()> { // ── GitHub ────────────────────────────────────────────────────────────────── -fn run_github(repo: String, fmt: ListFormat) -> Result<()> { +fn run_github(repo: String, fmt: ListFormat, config: &Config) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (repo, fmt); + let _ = (repo, fmt, config); anyhow::bail!("'path list github' requires a native environment with network access"); } @@ -210,13 +210,12 @@ fn run_github(repo: String, fmt: ListFormat) -> Result<()> { .split_once('/') .ok_or_else(|| anyhow::anyhow!("Repository must be in owner/repo format"))?; - let token = toolpath_github::resolve_token()?; - let config = toolpath_github::DeriveConfig { - token, + let derive_config = toolpath_github::DeriveConfig { + token: providers::github_token(config)?, ..Default::default() }; - let prs = toolpath_github::list_pull_requests(owner, repo_name, &config)?; + let prs = toolpath_github::list_pull_requests(owner, repo_name, &derive_config)?; match fmt { ListFormat::Json => { diff --git a/crates/path-cli/src/config.rs b/crates/path-cli/src/config.rs index 252be69b..9e5f6456 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -67,6 +67,8 @@ pub struct Config { pub(crate) copilot_events_strict: Option, /// `$COPILOT_HOME`: Copilot CLI session root override. pub(crate) copilot_home: Option, + /// `$GITHUB_TOKEN`: GitHub API token (see `providers`). + pub(crate) github_token: Option, /// `$HOME`: config-root fallback and the harness resolvers' root. pub(crate) home: Option, /// `$PATHBASE_URL`: Pathbase server override (see `cmd_pathbase`). @@ -116,6 +118,7 @@ impl Config { ("CODEX_ROLLOUT_STRICT", "codex_rollout_strict"), ("COPILOT_EVENTS_STRICT", "copilot_events_strict"), ("COPILOT_HOME", "copilot_home"), + ("GITHUB_TOKEN", "github_token"), ("HOME", "home"), (PATHBASE_URL_ENV, "pathbase_url"), (CONFIG_DIR_ENV, "toolpath_config_dir"), @@ -212,6 +215,7 @@ mod tests { jail.set_env("XDG_DATA_HOME", "/home/jailed/.local/share"); jail.set_env("COPILOT_EVENTS_STRICT", "1"); jail.set_env("COPILOT_HOME", "/home/jailed/.copilot"); + jail.set_env("GITHUB_TOKEN", "gh-jailed-token"); jail.set_env("APPDATA", "/home/jailed/appdata"); jail.set_env(PATHBASE_URL_ENV, "https://pathbase.test"); jail.set_env("TOOLPATH_QUERY_EXPLAIN", "1"); @@ -225,6 +229,7 @@ mod tests { codex_rollout_strict: Some("1".to_string()), copilot_events_strict: Some("1".to_string()), copilot_home: Some(PathBuf::from("/home/jailed/.copilot")), + github_token: Some("gh-jailed-token".to_string()), home: Some(PathBuf::from("/home/jailed")), pathbase_url: Some("https://pathbase.test".to_string()), toolpath_config_dir: Some(PathBuf::from("/tmp/cfg-root")), diff --git a/crates/path-cli/src/providers.rs b/crates/path-cli/src/providers.rs index d090911d..5515ce91 100644 --- a/crates/path-cli/src/providers.rs +++ b/crates/path-cli/src/providers.rs @@ -20,11 +20,15 @@ //! cursor takes `$APPDATA` as an argument next to the home directory. //! Only its Windows default user-data directory consults the value, so //! the injection is gated to Windows. +//! +//! GitHub is a remote source: it takes an API token instead of a +//! resolver, and [`github_token`] builds it here. use crate::config::Config; #[cfg(not(target_os = "emscripten"))] use crate::harness::HarnessBundle; - +#[cfg(not(target_os = "emscripten"))] +use anyhow::{Context, bail}; use anyhow::{Result, anyhow}; fn missing_home(harness: &str) -> anyhow::Error { @@ -140,6 +144,47 @@ pub(crate) fn require_pi_resolver(config: &Config) -> Result Result { + github_token_or_else(config, gh_auth_token) +} + +#[cfg(not(target_os = "emscripten"))] +fn github_token_or_else( + config: &Config, + fallback: impl FnOnce() -> Result, +) -> Result { + match config.github_token.as_deref() { + Some(token) if !token.is_empty() => Ok(token.to_string()), + _ => fallback(), + } +} + +/// Read the token out of the GitHub CLI. +#[cfg(not(target_os = "emscripten"))] +fn gh_auth_token() -> Result { + let output = std::process::Command::new("gh") + .args(["auth", "token"]) + .output() + .context( + "Failed to run 'gh auth token'. Set GITHUB_TOKEN or install the GitHub CLI (gh).", + )?; + + if output.status.success() { + let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !token.is_empty() { + return Ok(token); + } + } + + bail!( + "No GitHub token found. Set GITHUB_TOKEN environment variable \ + or authenticate with 'gh auth login'." + ) +} + /// The production [`HarnessBundle`], every provider built from /// `config`. A provider whose resolver needs a home directory is /// present only when `config` carries one; consumers skip the ones @@ -369,6 +414,30 @@ mod tests { ); } + #[test] + fn github_token_prefers_the_configured_value() { + let config = Config { + github_token: Some("configured".to_string()), + ..Config::default() + }; + let token = github_token_or_else(&config, || Ok("fallback".to_string())).unwrap(); + assert_eq!(token, "configured"); + } + + #[test] + fn github_token_falls_back_when_unset_or_empty() { + let fallback = || Ok("fallback".to_string()); + assert_eq!( + github_token_or_else(&Config::default(), fallback).unwrap(), + "fallback" + ); + let config = Config { + github_token: Some(String::new()), + ..Config::default() + }; + assert_eq!(github_token_or_else(&config, fallback).unwrap(), "fallback"); + } + #[test] fn pi_resolver_roots_at_config_home() { let resolver = pi_resolver(&config_with_home()).unwrap(); diff --git a/crates/toolpath-github/Cargo.toml b/crates/toolpath-github/Cargo.toml index 08dd7f5d..d4030e2b 100644 --- a/crates/toolpath-github/Cargo.toml +++ b/crates/toolpath-github/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-github" -version = "0.6.0" +version = "0.7.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-github/README.md b/crates/toolpath-github/README.md index ee5712e5..bc1bd8ca 100644 --- a/crates/toolpath-github/README.md +++ b/crates/toolpath-github/README.md @@ -15,10 +15,12 @@ are changes to `review://` artifacts; CI checks are changes to `ci://` artifacts ## Usage +The caller supplies the API token. + ```rust,no_run -use toolpath_github::{derive_pull_request, resolve_token, DeriveConfig}; +use toolpath_github::{derive_pull_request, DeriveConfig}; -let token = resolve_token()?; +let token = std::env::var("GITHUB_TOKEN")?; let config = DeriveConfig { token, include_ci: true, @@ -58,7 +60,6 @@ to GitLab MRs, Gerrit, Phabricator, etc. | Function | Description | |---|---| -| `resolve_token()` | Resolve GitHub token from `GITHUB_TOKEN` or `gh auth token` | | `derive_pull_request(owner, repo, pr, config)` | Derive a Path from a PR | | `list_pull_requests(owner, repo, config)` | List PRs with summary info | | `extract_issue_refs(body)` | Parse "Fixes #N" / "Closes #N" from text | diff --git a/crates/toolpath-github/src/lib.rs b/crates/toolpath-github/src/lib.rs index a55c129c..5416e6d8 100644 --- a/crates/toolpath-github/src/lib.rs +++ b/crates/toolpath-github/src/lib.rs @@ -154,41 +154,6 @@ mod native { use super::{DeriveConfig, PullRequestInfo, extract_issue_refs}; - // ==================================================================== - // Auth - // ==================================================================== - - /// Resolve a GitHub API token. - /// - /// Checks `GITHUB_TOKEN` environment variable first, then falls back to - /// `gh auth token` subprocess. Returns an error if neither works. - pub fn resolve_token() -> Result { - if let Ok(token) = std::env::var("GITHUB_TOKEN") - && !token.is_empty() - { - return Ok(token); - } - - let output = std::process::Command::new("gh") - .args(["auth", "token"]) - .output() - .context( - "Failed to run 'gh auth token'. Set GITHUB_TOKEN or install the GitHub CLI (gh).", - )?; - - if output.status.success() { - let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !token.is_empty() { - return Ok(token); - } - } - - bail!( - "No GitHub token found. Set GITHUB_TOKEN environment variable \ - or authenticate with 'gh auth login'." - ) - } - // ==================================================================== // API Client // ==================================================================== @@ -1685,7 +1650,7 @@ mod native { // Re-export native-only functions at crate root for API compatibility #[cfg(not(target_os = "emscripten"))] -pub use native::{derive_pull_request, list_pull_requests, resolve_token}; +pub use native::{derive_pull_request, list_pull_requests}; #[cfg(test)] mod tests { diff --git a/site/_data/crates.json b/site/_data/crates.json index 4f43706a..438279c8 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -25,7 +25,7 @@ }, { "name": "toolpath-github", - "version": "0.6.0", + "version": "0.7.0", "description": "Derive from GitHub pull requests", "docs": "https://docs.rs/toolpath-github", "crate": "https://crates.io/crates/toolpath-github",