Skip to content

Commit 3847e57

Browse files
authored
chore(v0.5): roadmap section + ship gh auth token fallback (#281) (#289)
* 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 * docs(v0.5): open roadmap milestone — visualization release vision v0.5 마일스톤 공식 출발 마커. README 와 CHANGELOG 양쪽에 향후 비전 명시. README.md (## Roadmap 섹션 신설, ## Why use it 과 ## Install 사이): - vision tagline: "parsec = AI agents + human devs both — worktree-native git CLI" - 4단계 milestone: · v0.4.0 ✅ Released (2026-05-04): Multi-forge + multi-tracker foundation · v0.5 🚧 Next — _The visualization release_: smartlog · TUI dashboard · speculative merge · parsec test · AI PR descriptions · v1.0 🔜 — _AI-Native Standard_: MCP server signature, Claude/Cursor/Copilot 가 parsec 을 first-class tool 로 invoke · v2.0+ 🔮 — _Ecosystem Hub_: plugins · VS Code extension · Linear tracker - v0.5 milestone link (github.com/erishforG/git-parsec/milestone/3) CHANGELOG.md (## [Unreleased] 확장): - ### Added: v0.5 milestone opened, README Roadmap 참조 - ### Fixed: #281 ship gh auth token fallback 노트 (별 commit ae1a2d3 와 동일 entry) 회귀 위험: 0 (문서 변경만) * fix(env): serialize env-touching tests via process-wide mutex (Windows CI) PR #289 Windows CI 1건 fail. macOS / Ubuntu Test 통과, Windows Test 만 실패. 원인: - env::tests 의 github_token_priority_order 와 github_token_returns_none_when_all_ missing_and_gh_fails 가 cargo test 병렬 실행 시 process-wide env vars 를 race. - priority_order 가 PARSEC=p / GITHUB=g / GH=h 셋업 후 assert 사이에 sibling 테스트의 EnvGuard::new() 가 모든 env 를 clear → assert 가 PARSEC 못 보고 GH=h 반환. - macOS/Ubuntu 는 timing 우연히 안전, Windows 는 다른 thread scheduling 으로 race 발현 (Some("h") vs Some("p") at src/env.rs:205). 수정: - std::sync::OnceLock<Mutex<()>> 의 env_lock() 신규 — env 만지는 테스트들 직렬화. std 만 사용 (외부 deps 추가 X 제약 준수). - github_token_priority_order 와 github_token_returns_none_when_all_missing_and_gh_fails 를 단일 함수 github_token_priority_order_and_fallback 로 통합 + env_lock() 의 Mutex guard 획득. 5 시나리오 (PARSEC 우선 / GITHUB / GH / 빈값 / 모두 미설정) 직렬 실행. - gh_auth_token_returns_option_string_or_none 은 env 미터치라 lock 불필요 — 그대로 유지. - production 로직 (env::github_token / env::gh_auth_token) 변경 0. 검증: - 로컬 cargo test 78 PASS (env tests 5 + integration 73), clippy clean, fmt clean. - Windows CI 검증은 force-push 후 PR #289 워크플로우에서 확인.
1 parent c5008c3 commit 3847e57

5 files changed

Lines changed: 205 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **v0.5 milestone opened** — see Roadmap in README. Themes: smartlog · TUI
12+
dashboard · speculative merge · `parsec test` · AI PR descriptions.
13+
14+
### Fixed
15+
- `parsec ship` falls back to `gh auth token` when `PARSEC_GITHUB_TOKEN` /
16+
`GITHUB_TOKEN` / `GH_TOKEN` env vars are absent — parity with `parsec
17+
doctor` and the tracker layer (#281). The fallback is restricted to
18+
GitHub hosts so Bitbucket / GitLab remotes are unaffected.
19+
1020
## [0.4.0] - 2026-05-04
1121

1222
### Added

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,21 @@ That's the whole loop. Plain `git worktree` doesn't track state, doesn't talk to
2929

3030
---
3131

32+
## Roadmap
33+
34+
> **Vision**: parsec = AI agents + human devs both — worktree-native git CLI.
35+
36+
| Milestone | Status | Theme |
37+
|---|---|---|
38+
| **v0.4.0** | ✅ Released (2026-05-04) | Multi-forge + multi-tracker foundation (GitHub / GitLab / Bitbucket; Jira / Linear) |
39+
| **v0.5**_The visualization release_ | 🚧 Next | smartlog · TUI dashboard · speculative merge · `parsec test` · AI PR descriptions |
40+
| **v1.0**_AI-Native Standard_ | 🔜 | MCP server signature — Claude / Cursor / Copilot invoke parsec as a first-class tool |
41+
| **v2.0+**_Ecosystem Hub_ | 🔮 | Plugins · VS Code extension · Linear-native tracker · org-scale workflows |
42+
43+
Open issues for v0.5 are tracked under the [`v0.5` milestone](https://github.com/erishforG/git-parsec/milestone/3).
44+
45+
---
46+
3247
## Install
3348

3449
```bash

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: 156 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,129 @@ 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+
use std::sync::{Mutex, OnceLock};
152+
153+
/// Process-wide mutex to serialize env-touching tests. cargo test runs
154+
/// tests in parallel by default, so any test that mutates env vars must
155+
/// hold this lock — otherwise sibling tests racing through `set_var` /
156+
/// `remove_var` clobber each other (Windows CI hit this with priority_order
157+
/// reading PARSEC=p but seeing GH=h because another test cleared PARSEC
158+
/// mid-assertion).
159+
fn env_lock() -> &'static Mutex<()> {
160+
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
161+
LOCK.get_or_init(|| Mutex::new(()))
162+
}
163+
164+
/// Helper: snapshot/clear env vars affecting github_token, then restore.
165+
/// std::env::set_var/remove_var is unsafe in Rust 2024. Tests holding
166+
/// `env_lock()` only run serially, so the snapshot+restore is sufficient.
167+
struct EnvGuard {
168+
orig: Vec<(&'static str, Option<String>)>,
169+
}
170+
impl EnvGuard {
171+
fn new(vars: &[&'static str]) -> Self {
172+
let orig = vars.iter().map(|v| (*v, std::env::var(v).ok())).collect();
173+
for v in vars {
174+
// SAFETY: tests run serially within a module by default in Rust 2024.
175+
#[allow(unused_unsafe)]
176+
unsafe {
177+
std::env::remove_var(v)
178+
};
179+
}
180+
Self { orig }
181+
}
182+
fn set(&self, key: &str, val: &str) {
183+
#[allow(unused_unsafe)]
184+
unsafe {
185+
std::env::set_var(key, val)
186+
};
187+
}
188+
}
189+
impl Drop for EnvGuard {
190+
fn drop(&mut self) {
191+
for (k, v) in &self.orig {
192+
#[allow(unused_unsafe)]
193+
unsafe {
194+
if let Some(val) = v {
195+
std::env::set_var(k, val);
196+
} else {
197+
std::env::remove_var(k);
198+
}
199+
}
200+
}
201+
}
202+
}
203+
204+
/// 우선순위 + 빈값 fallback + 모두 미설정 시나리오를 한 함수에서 sequential 검사.
205+
/// `env_lock()` 으로 process-wide 직렬화 (cargo test 병렬 실행 환경에서 sibling
206+
/// 테스트가 env 를 클로버하지 않도록). Windows CI 에서 race 발견 (#289).
207+
#[test]
208+
fn github_token_priority_order_and_fallback() {
209+
let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
210+
// 1. PARSEC_GITHUB_TOKEN 우선
211+
{
212+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
213+
g.set(PARSEC_GITHUB_TOKEN, "p");
214+
g.set(GITHUB_TOKEN, "g");
215+
g.set(GH_TOKEN, "h");
216+
assert_eq!(github_token().as_deref(), Some("p"));
217+
drop(g);
218+
}
219+
// 2. PARSEC_GITHUB_TOKEN 미설정 → GITHUB_TOKEN
220+
{
221+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
222+
g.set(GITHUB_TOKEN, "g");
223+
g.set(GH_TOKEN, "h");
224+
assert_eq!(github_token().as_deref(), Some("g"));
225+
drop(g);
226+
}
227+
// 3. PARSEC_GITHUB_TOKEN / GITHUB_TOKEN 미설정 → GH_TOKEN
228+
{
229+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
230+
g.set(GH_TOKEN, "h");
231+
assert_eq!(github_token().as_deref(), Some("h"));
232+
drop(g);
233+
}
234+
// 4. 빈 PARSEC_GITHUB_TOKEN 은 무시 → GITHUB_TOKEN
235+
{
236+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
237+
g.set(PARSEC_GITHUB_TOKEN, "");
238+
g.set(GITHUB_TOKEN, "g");
239+
assert_eq!(github_token().as_deref(), Some("g"));
240+
drop(g);
241+
}
242+
// 5. 모두 미설정 + gh 실패 → None. CI 환경 (gh 로그인 X) 이 일반.
243+
// local dev 에서 gh auth login 돼있으면 Some(token) 도 허용 (smoke).
244+
{
245+
let g = EnvGuard::new(&[PARSEC_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN]);
246+
match github_token() {
247+
None => {}
248+
Some(t) => assert!(
249+
!t.is_empty(),
250+
"if gh auth token is available, it must not be empty"
251+
),
252+
}
253+
drop(g);
254+
}
255+
}
256+
257+
#[test]
258+
fn gh_auth_token_returns_option_string_or_none() {
259+
// 외부 gh binary 에 의존 — CI 환경 (로그인 X) 에서는 None 기대.
260+
// local dev 에서 gh auth login 돼있으면 Some(token). 둘 다 허용 (smoke check only).
261+
match gh_auth_token() {
262+
None => {}
263+
Some(t) => {
264+
assert!(!t.is_empty());
265+
assert!(!t.contains('\n'), "trimmed");
266+
}
267+
}
268+
}
269+
}

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)