Skip to content

Commit ae1a2d3

Browse files
committed
fix(ship): fall back to gh auth token (parity with parsec doctor) (#281)
parsec ship 이 PARSEC_GITHUB_TOKEN / GITHUB_TOKEN / GH_TOKEN env var 모두 비어있을 때 PR 생성을 거부했지만 parsec doctor 와 tracker 레이어는 이미 `gh auth token` fallback 적용. parity 깨져 사용자가 수동 `gh pr create` 로 추가 작업 필요. 해결: - src/env.rs: github_token() 의 4번째 우선순위로 gh_auth_token() 추가. 신규 gh_auth_token() helper 는 `gh auth token` shell out — 실패 시 None (binary 없음 / exit code != 0 / non-UTF8 / 빈 출력 모두 graceful). - src/github/mod.rs: resolve_github_token 의 env-var/gh fallback 을 GitHub host (github.com / *.ghe.com / *.github.* GHE) 에만 적용. 신규 is_github_host() helper. Bitbucket / GitLab remote 가 `gh auth login` 한 환경에서 GitHub 토큰을 잘못 픽업하지 않도록 가드. - src/cli/commands/doctor.rs: 중복 `gh auth token` shell-out 코드 제거 → env::gh_auth_token() 공통 helper 호출. parity at the helper level. 신규 테스트 (src/env.rs): github_token_priority_order — PARSEC > GITHUB > GH 우선순위 + 빈값 fallback 4 시나리오 sequential 검사. EnvGuard 로 process-wide env 보존+복원. gh_auth_token_returns_option_string_or_none — gh binary 가용성에 무관하게 trim 보장. github_token_returns_none_when_all_missing_and_gh_fails — env 모두 미설정 시 None 또는 valid Some 모두 허용 (CI 와 dev 환경 양립). 검증: - cargo test: 79 tests PASS (env tests 6 + integration 73) - cargo clippy --all-targets -- -D warnings: clean - cargo fmt --check: clean - bitbucket integration tests: 5/5 PASS (이전 발견된 host-gated 이슈 해결) 회귀 위험: 매우 낮음 - 환경에 gh CLI 미로그인 / 미설치 → 기존과 동일 (None 반환) - 환경에 gh 로그인됨 + GitHub remote → 신규 fallback 활용 (issue #281 의도) - 환경에 gh 로그인됨 + Bitbucket/GitLab remote → is_github_host 로 차단, 기존과 동일 Closes #281
1 parent c5008c3 commit ae1a2d3

3 files changed

Lines changed: 173 additions & 23 deletions

File tree

src/cli/commands/doctor.rs

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,14 @@ pub async fn doctor(repo: &Path, mode: Mode) -> Result<()> {
8686
// ------------------------------------------------------------------
8787
{
8888
let config_result = crate::config::ParsecConfig::load();
89+
// issue #281: gh auth token fallback 은 lib (`crate::env::gh_auth_token`) 에서
90+
// 단일 정의 — `ship` / tracker 와 parity. doctor 는 SOURCE 를 사람이 읽기 위한
91+
// 진단 메시지로 분기하므로 별도 매핑 유지.
92+
let from_gh = crate::env::gh_auth_token().is_some();
93+
let from_env = std::env::var("GITHUB_TOKEN").is_ok();
8994
let github_token_found = match &config_result {
9095
Ok(cfg) => {
9196
let from_config = cfg.github.values().any(|h| h.token.is_some());
92-
let from_env = std::env::var("GITHUB_TOKEN").is_ok();
93-
let from_gh = StdCommand::new("gh")
94-
.args(["auth", "token"])
95-
.output()
96-
.map(|o| o.status.success())
97-
.unwrap_or(false);
9897
if from_config {
9998
Some("config file")
10099
} else if from_env {
@@ -106,12 +105,6 @@ pub async fn doctor(repo: &Path, mode: Mode) -> Result<()> {
106105
}
107106
}
108107
Err(_) => {
109-
let from_env = std::env::var("GITHUB_TOKEN").is_ok();
110-
let from_gh = StdCommand::new("gh")
111-
.args(["auth", "token"])
112-
.output()
113-
.map(|o| o.status.success())
114-
.unwrap_or(false);
115108
if from_env {
116109
Some("GITHUB_TOKEN env var")
117110
} else if from_gh {

src/env.rs

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ pub fn jira_token(config_token: Option<&str>) -> Option<String> {
4747
.map(|t| t.to_string())
4848
}
4949

50-
/// Resolve GitHub token. Priority: PARSEC_GITHUB_TOKEN > GITHUB_TOKEN > GH_TOKEN
50+
/// Resolve GitHub token. Priority:
51+
/// 1. `PARSEC_GITHUB_TOKEN`
52+
/// 2. `GITHUB_TOKEN`
53+
/// 3. `GH_TOKEN`
54+
/// 4. `gh auth token` shell fallback (issue #281 — parity with `parsec doctor` /
55+
/// tracker layer; `parsec ship` previously rejected this path)
5156
pub fn github_token() -> Option<String> {
5257
for var in [PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN] {
5358
if let Ok(token) = std::env::var(var) {
@@ -56,7 +61,30 @@ pub fn github_token() -> Option<String> {
5661
}
5762
}
5863
}
59-
None
64+
gh_auth_token()
65+
}
66+
67+
/// Shell out to `gh auth token` and capture stdout. Returns `None` on failure:
68+
/// binary not found, exit code != 0, non-UTF8 stdout, or empty token.
69+
///
70+
/// Used as the final fallback in [`github_token`] (issue #281 — parity with
71+
/// `parsec doctor` and the tracker layer). Cross-platform: relies on `gh`
72+
/// being on PATH; failures are silent so callers present a unified "no token
73+
/// found" message.
74+
pub fn gh_auth_token() -> Option<String> {
75+
let out = std::process::Command::new("gh")
76+
.args(["auth", "token"])
77+
.output()
78+
.ok()?;
79+
if !out.status.success() {
80+
return None;
81+
}
82+
let token = String::from_utf8(out.stdout).ok()?.trim().to_string();
83+
if token.is_empty() {
84+
None
85+
} else {
86+
Some(token)
87+
}
6088
}
6189

6290
/// Resolve GitLab token. Priority: PARSEC_GITLAB_TOKEN > GITLAB_TOKEN
@@ -113,3 +141,122 @@ pub fn is_offline() -> bool {
113141
.map(|v| v == "1" || v == "true")
114142
.unwrap_or(false)
115143
}
144+
145+
// ---------------------------------------------------------------------------
146+
// Tests
147+
// ---------------------------------------------------------------------------
148+
#[cfg(test)]
149+
mod tests {
150+
use super::*;
151+
152+
/// Helper: snapshot/clear env vars affecting github_token, then restore.
153+
/// std::env::set_var/remove_var is unsafe in Rust 2024; test isolation is
154+
/// per-process. Tests in this module assume serial execution (default for
155+
/// `cargo test` with `--test-threads=1` not required, but env vars are
156+
/// shared so we save+restore.
157+
struct EnvGuard {
158+
orig: Vec<(&'static str, Option<String>)>,
159+
}
160+
impl EnvGuard {
161+
fn new(vars: &[&'static str]) -> Self {
162+
let orig = vars.iter().map(|v| (*v, std::env::var(v).ok())).collect();
163+
for v in vars {
164+
// SAFETY: tests run serially within a module by default in Rust 2024.
165+
#[allow(unused_unsafe)]
166+
unsafe {
167+
std::env::remove_var(v)
168+
};
169+
}
170+
Self { orig }
171+
}
172+
fn set(&self, key: &str, val: &str) {
173+
#[allow(unused_unsafe)]
174+
unsafe {
175+
std::env::set_var(key, val)
176+
};
177+
}
178+
}
179+
impl Drop for EnvGuard {
180+
fn drop(&mut self) {
181+
for (k, v) in &self.orig {
182+
#[allow(unused_unsafe)]
183+
unsafe {
184+
if let Some(val) = v {
185+
std::env::set_var(k, val);
186+
} else {
187+
std::env::remove_var(k);
188+
}
189+
}
190+
}
191+
}
192+
}
193+
194+
/// 우선순위 + 빈값 fallback 4 시나리오를 한 함수에서 sequential 검사.
195+
/// (env vars 는 process-wide 라 cargo test 병렬 실행 시 race. 단일 테스트로 합쳐
196+
/// EnvGuard 의 새로 만들기·복원 루틴 안에서 안전하게 순서 검사.)
197+
#[test]
198+
fn github_token_priority_order() {
199+
// 1. PARSEC_GITHUB_TOKEN 우선
200+
{
201+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
202+
g.set(PARSEC_GITHUB_TOKEN, "p");
203+
g.set(GITHUB_TOKEN, "g");
204+
g.set(GH_TOKEN, "h");
205+
assert_eq!(github_token().as_deref(), Some("p"));
206+
drop(g);
207+
}
208+
// 2. PARSEC_GITHUB_TOKEN 미설정 → GITHUB_TOKEN
209+
{
210+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
211+
g.set(GITHUB_TOKEN, "g");
212+
g.set(GH_TOKEN, "h");
213+
assert_eq!(github_token().as_deref(), Some("g"));
214+
drop(g);
215+
}
216+
// 3. PARSEC_GITHUB_TOKEN / GITHUB_TOKEN 미설정 → GH_TOKEN
217+
{
218+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
219+
g.set(GH_TOKEN, "h");
220+
assert_eq!(github_token().as_deref(), Some("h"));
221+
drop(g);
222+
}
223+
// 4. 빈 PARSEC_GITHUB_TOKEN 은 무시 → GITHUB_TOKEN
224+
{
225+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
226+
g.set(PARSEC_GITHUB_TOKEN, "");
227+
g.set(GITHUB_TOKEN, "g");
228+
assert_eq!(github_token().as_deref(), Some("g"));
229+
drop(g);
230+
}
231+
}
232+
233+
#[test]
234+
fn github_token_returns_none_when_all_missing_and_gh_fails() {
235+
// 환경상 `gh` binary 가 없거나 인증 안돼있으면 None. CI/test env 에서 이게 일반.
236+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
237+
// gh binary 가 PATH 에 있고 로그인까지 돼있으면 본 테스트는 Some 을 반환할 수
238+
// 있음. CI 에서는 로그인 X 가 일반이라 None 기대. local dev 에서는 Some 가능.
239+
// 따라서 None OR gh 환경에서 정상 token 모두 허용.
240+
match github_token() {
241+
None => {}
242+
Some(t) => assert!(
243+
!t.is_empty(),
244+
"if gh auth token is available, it must not be empty"
245+
),
246+
}
247+
drop(g);
248+
}
249+
250+
#[test]
251+
fn gh_auth_token_returns_option_string_or_none() {
252+
// 외부 gh binary 에 의존 — CI 환경 (로그인 X) 에서는 None 기대.
253+
// local dev 에서 gh auth login 돼있으면 Some(token). 둘 다 허용 (smoke check only).
254+
match gh_auth_token() {
255+
None => {}
256+
Some(t) => {
257+
assert!(!t.is_empty());
258+
assert!(!t.contains('\n'), "trimmed");
259+
}
260+
}
261+
}
262+
}

src/github/mod.rs

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,25 @@ pub fn parse_github_remote(url: &str) -> Option<GitHubRemote> {
190190
})
191191
}
192192

193+
/// Returns true when `host` looks like a GitHub host. github.com and any host
194+
/// with `.github.` (GHE) substring qualifies. Used to gate env-var and
195+
/// `gh auth token` fallbacks so they don't leak into other forges.
196+
pub fn is_github_host(host: &str) -> bool {
197+
let h = host.trim().to_ascii_lowercase();
198+
h == "github.com" || h.contains(".github.") || h.ends_with(".ghe.com")
199+
}
200+
193201
/// Resolve a GitHub token for the given host.
194202
///
195203
/// Resolution priority:
196-
/// 1. `config.github.<host>.token` — host-specific config
197-
/// 2. `PARSEC_GITHUB_TOKEN` env var — explicit override
198-
/// 3. `GITHUB_TOKEN` / `GH_TOKEN` — generic fallback
204+
/// 1. `config.github.<host>.token` — host-specific config (any host)
205+
/// 2. `PARSEC_GITHUB_TOKEN` / `GITHUB_TOKEN` / `GH_TOKEN` env vars (GitHub host only)
206+
/// 3. `gh auth token` shell fallback (GitHub host only) — issue #281 parity
207+
///
208+
/// 2 & 3 are gated on host being a GitHub host so that bitbucket / gitlab remotes
209+
/// don't accidentally pick up a GitHub token via `gh auth login`.
199210
pub fn resolve_github_token(host: &str, config: &ParsecConfig) -> Option<String> {
200-
// 1. Host-specific config token
211+
// 1. Host-specific config token (any host — opt-in via config)
201212
if let Some(host_cfg) = config.github.get(host) {
202213
if let Some(ref token) = host_cfg.token {
203214
if !token.is_empty() {
@@ -206,12 +217,11 @@ pub fn resolve_github_token(host: &str, config: &ParsecConfig) -> Option<String>
206217
}
207218
}
208219

209-
// 2 & 3. Environment variables (PARSEC_GITHUB_TOKEN > GITHUB_TOKEN > GH_TOKEN)
210-
if let Some(token) = crate::env::github_token() {
211-
return Some(token);
220+
// 2 & 3: env / gh CLI fallback — only for actual GitHub hosts.
221+
if !is_github_host(host) {
222+
return None;
212223
}
213-
214-
None
224+
crate::env::github_token()
215225
}
216226

217227
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)