wtg-cli 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/Cargo.lock +2 -2
  2. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/Cargo.toml +2 -3
  3. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/PKG-INFO +1 -1
  4. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/backend/combined_backend.rs +4 -0
  5. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/backend/github_backend.rs +5 -1
  6. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/backend/mod.rs +9 -0
  7. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/error.rs +23 -1
  8. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/github.rs +155 -51
  9. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/notice.rs +16 -0
  10. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/output.rs +68 -9
  11. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/resolution.rs +21 -5
  12. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/integration.rs +57 -0
  13. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/LICENSE +0 -0
  14. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/README.md +0 -0
  15. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/Cargo.toml +0 -0
  16. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/README.md +0 -0
  17. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/backend/git_backend.rs +0 -0
  18. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/changelog.rs +0 -0
  19. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/cli.rs +0 -0
  20. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/constants.rs +0 -0
  21. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/git.rs +0 -0
  22. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/help.rs +0 -0
  23. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/lib.rs +0 -0
  24. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/main.rs +0 -0
  25. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/parse_input.rs +0 -0
  26. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/release_filter.rs +0 -0
  27. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/remote.rs +0 -0
  28. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/semver.rs +0 -0
  29. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/common/mod.rs +0 -0
  30. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/fixtures/test-repo.zip +0 -0
  31. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/offline.rs +0 -0
  32. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/snapshots/integration__integration_identify_file.snap +0 -0
  33. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/snapshots/integration__integration_identify_recent_commit.snap +0 -0
  34. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/snapshots/integration__integration_identify_tag.snap +0 -0
  35. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg-python/Cargo.toml +0 -0
  36. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg-python/src/lib.rs +0 -0
  37. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/pyproject.toml +0 -0
  38. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/src/wtg_cli/__init__.py +0 -0
  39. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/src/wtg_cli/__main__.py +0 -0
  40. {wtg_cli-0.3.0 → wtg_cli-0.3.1}/src/wtg_cli/cli.py +0 -0
@@ -3179,7 +3179,7 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
3179
3179
 
3180
3180
  [[package]]
3181
3181
  name = "wtg-cli"
3182
- version = "0.3.0"
3182
+ version = "0.3.1"
3183
3183
  dependencies = [
3184
3184
  "async-trait",
3185
3185
  "base64",
@@ -3207,7 +3207,7 @@ dependencies = [
3207
3207
 
3208
3208
  [[package]]
3209
3209
  name = "wtg-python"
3210
- version = "0.3.0"
3210
+ version = "0.3.1"
3211
3211
  dependencies = [
3212
3212
  "pyo3",
3213
3213
  "wtg-cli",
@@ -1,10 +1,9 @@
1
1
  [workspace]
2
2
  resolver = "2"
3
- members = ["crates/wtg-python"]
4
- default-members = ["crates/wtg"]
3
+ members = ["crates/wtg", "crates/wtg-python"]
5
4
 
6
5
  [workspace.package]
7
- version = "0.3.0"
6
+ version = "0.3.1"
8
7
  edition = "2024"
9
8
  description = "A snarky but helpful CLI tool to identify git commits, issues, PRs and file changes, and tell you which release they shipped in"
10
9
  authors = ["Mike Perlov"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wtg-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -250,6 +250,10 @@ impl CombinedBackend {
250
250
 
251
251
  #[async_trait]
252
252
  impl Backend for CombinedBackend {
253
+ fn emit_notice(&self, notice: Notice) {
254
+ self.emit(notice);
255
+ }
256
+
253
257
  async fn backend_for_pr(&self, pr: &PullRequestInfo) -> Option<Box<dyn Backend>> {
254
258
  let pr_repo = pr.repo_info.as_ref()?;
255
259
  let our_repo = self.github.repo_info();
@@ -13,7 +13,7 @@ use crate::changelog;
13
13
  use crate::error::{WtgError, WtgResult};
14
14
  use crate::git::{CommitInfo, TagInfo, looks_like_commit_hash};
15
15
  use crate::github::{ExtendedIssueInfo, GhRepoInfo, GitHubClient, PullRequestInfo};
16
- use crate::notice::NoticeCallback;
16
+ use crate::notice::{Notice, NoticeCallback};
17
17
  use crate::parse_input::{ParsedQuery, Query};
18
18
  use crate::release_filter::ReleaseFilter;
19
19
 
@@ -122,6 +122,10 @@ impl GitHubBackend {
122
122
 
123
123
  #[async_trait]
124
124
  impl Backend for GitHubBackend {
125
+ fn emit_notice(&self, notice: Notice) {
126
+ self.client.emit(notice);
127
+ }
128
+
125
129
  async fn backend_for_pr(&self, pr: &PullRequestInfo) -> Option<Box<dyn Backend>> {
126
130
  let pr_repo = pr.repo_info.as_ref()?;
127
131
 
@@ -33,6 +33,14 @@ use crate::remote::{RemoteHost, RemoteInfo};
33
33
  /// return `WtgError::Unsupported` for operations not available.
34
34
  #[async_trait]
35
35
  pub trait Backend: Send + Sync {
36
+ // ============================================
37
+ // Notice emission (default: no-op)
38
+ // ============================================
39
+
40
+ /// Emit a notice to the registered callback.
41
+ /// Used by resolution code to surface warnings to the user.
42
+ fn emit_notice(&self, _notice: Notice) {}
43
+
36
44
  // ============================================
37
45
  // Cross-project support (default: not supported)
38
46
  // ============================================
@@ -227,6 +235,7 @@ pub fn resolve_backend_with_notices(
227
235
  } else {
228
236
  // Can't access git locally, use pure API (soft notice)
229
237
  notice_cb(Notice::ApiOnly);
238
+ github.set_notice_callback(notice_cb);
230
239
  Ok(Box::new(github))
231
240
  }
232
241
  } else {
@@ -16,6 +16,7 @@ pub enum WtgError {
16
16
  GhConnectionLost,
17
17
  GhRateLimit(OctoError),
18
18
  GhSaml(OctoError),
19
+ GhBadCredentials(OctoError),
19
20
  GitHub(OctoError),
20
21
  MultipleMatches(Vec<String>),
21
22
  Io(std::io::Error),
@@ -142,6 +143,26 @@ impl fmt::Display for WtgError {
142
143
  "Try authenticating your GITHUB_TOKEN with SAML first!".red()
143
144
  )
144
145
  }
146
+ Self::GhBadCredentials(_) => {
147
+ writeln!(
148
+ f,
149
+ "{}",
150
+ "🔑 Nope! GitHub doesn't recognize that token."
151
+ .yellow()
152
+ .bold()
153
+ )?;
154
+ writeln!(f)?;
155
+ writeln!(
156
+ f,
157
+ " {}",
158
+ "Expired? Revoked? Typo? It happens to the best of us. 🤷".yellow()
159
+ )?;
160
+ writeln!(
161
+ f,
162
+ " {}",
163
+ "Check your GITHUB_TOKEN and try again!".yellow()
164
+ )
165
+ }
145
166
  Self::GitHub(e) => write!(f, "GitHub error: {e}"),
146
167
  Self::MultipleMatches(types) => {
147
168
  writeln!(f, "{}", "💥 OH MY, YOU BLEW ME UP!".red().bold())?;
@@ -273,11 +294,12 @@ impl From<OctoError> for WtgError {
273
294
  fn from(err: OctoError) -> Self {
274
295
  if let OctoError::GitHub { ref source, .. } = err {
275
296
  match source.status_code {
297
+ StatusCode::UNAUTHORIZED => return Self::GhBadCredentials(err),
276
298
  StatusCode::TOO_MANY_REQUESTS => return Self::GhRateLimit(err),
277
299
  StatusCode::FORBIDDEN => {
278
300
  let msg_lower = source.message.to_ascii_lowercase();
279
301
 
280
- if msg_lower.to_ascii_lowercase().contains("saml") {
302
+ if msg_lower.contains("saml") {
281
303
  return Self::GhSaml(err);
282
304
  }
283
305
 
@@ -98,6 +98,32 @@ impl GhRepoInfo {
98
98
  }
99
99
  }
100
100
 
101
+ /// Describes which client served an API call and why.
102
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
103
+ enum ClientSelection {
104
+ /// The main (possibly authenticated) client succeeded directly.
105
+ Main,
106
+ /// The backup (anonymous) client was used after the main client failed.
107
+ Fallback(FallbackReason),
108
+ }
109
+
110
+ /// Why the main client failed, causing fallback to the backup client.
111
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
112
+ enum FallbackReason {
113
+ /// SAML SSO enforcement blocked the authenticated request (403).
114
+ Saml,
115
+ /// Token was invalid, expired, or revoked (401).
116
+ BadCredentials,
117
+ }
118
+
119
+ /// Result from [`GitHubClient::call_api_and_get_client`] containing the API
120
+ /// response, the client that served it, and how the client was selected.
121
+ struct ApiCallResult<'a, T> {
122
+ value: T,
123
+ client: &'a Octocrab,
124
+ selection: ClientSelection,
125
+ }
126
+
101
127
  /// GitHub API client wrapper.
102
128
  ///
103
129
  /// - Provides a simplified interface for common GitHub operations used in wtg over direct octocrab usage.
@@ -167,6 +193,9 @@ pub struct ExtendedIssueInfo {
167
193
  pub author_url: Option<String>,
168
194
  pub closing_prs: Vec<PullRequestInfo>, // PRs that closed this issue (may be cross-repo)
169
195
  pub created_at: Option<DateTime<Utc>>, // When the issue was created
196
+ /// Timeline data may be incomplete due to SAML-restricted org access.
197
+ /// When true, closing PRs may exist but aren't visible without SSO auth.
198
+ pub timeline_may_be_incomplete: bool,
170
199
  }
171
200
 
172
201
  impl TryFrom<octocrab::models::issues::Issue> for ExtendedIssueInfo {
@@ -192,6 +221,7 @@ impl TryFrom<octocrab::models::issues::Issue> for ExtendedIssueInfo {
192
221
  author_url,
193
222
  closing_prs: Vec::new(), // Will be populated by caller if needed
194
223
  created_at,
224
+ timeline_may_be_incomplete: false,
195
225
  })
196
226
  }
197
227
  }
@@ -237,6 +267,35 @@ impl GitHubClient {
237
267
  })
238
268
  }
239
269
 
270
+ /// Create a GitHub client with a specific token.
271
+ ///
272
+ /// Builds an authenticated client using the given token, with an anonymous
273
+ /// backup for fallback on SAML or bad credentials errors.
274
+ /// Returns `None` if the client cannot be built.
275
+ #[must_use]
276
+ pub fn new_with_token(token: String) -> Option<Self> {
277
+ if token.trim().is_empty() {
278
+ return None;
279
+ }
280
+
281
+ let connect_timeout = Some(Self::connect_timeout());
282
+ let read_timeout = Some(Self::read_timeout());
283
+
284
+ let auth = OctocrabBuilder::new()
285
+ .personal_token(token)
286
+ .set_connect_timeout(connect_timeout)
287
+ .set_read_timeout(read_timeout)
288
+ .build()
289
+ .ok()?;
290
+
291
+ Some(Self {
292
+ main_client: auth,
293
+ backup_client: LazyLock::new(Self::build_anonymous_client),
294
+ is_authenticated: true,
295
+ notice_callback: OnceLock::new(),
296
+ })
297
+ }
298
+
240
299
  /// Set the notice callback for this client.
241
300
  /// Can be called even when client is behind an `Arc`.
242
301
  /// First call wins - subsequent calls are ignored.
@@ -246,20 +305,29 @@ impl GitHubClient {
246
305
  }
247
306
 
248
307
  /// Emit a notice via the callback, if one is set.
249
- fn emit(&self, notice: Notice) {
308
+ pub(crate) fn emit(&self, notice: Notice) {
250
309
  if let Some(cb) = self.notice_callback.get() {
251
310
  (cb)(notice);
252
311
  }
253
312
  }
254
313
 
255
- /// Build an authenticated octocrab client
314
+ /// Build an authenticated octocrab client.
315
+ /// Returns `None` if `WTG_GH_NO_AUTH` is set or no valid token is found.
256
316
  fn build_auth_client() -> Option<Octocrab> {
317
+ // Undocumented: skip all authentication when WTG_GH_NO_AUTH is set
318
+ if env::var("WTG_GH_NO_AUTH").is_ok() {
319
+ log::debug!("WTG_GH_NO_AUTH set, skipping GitHub authentication");
320
+ return None;
321
+ }
322
+
257
323
  // Set reasonable timeouts: 5s connect, 30s read/write
258
324
  let connect_timeout = Some(Self::connect_timeout());
259
325
  let read_timeout = Some(Self::read_timeout());
260
326
 
261
- // Try GITHUB_TOKEN env var first
262
- if let Ok(token) = env::var("GITHUB_TOKEN") {
327
+ // Try GITHUB_TOKEN env var first (skip empty/whitespace-only values)
328
+ if let Ok(token) = env::var("GITHUB_TOKEN")
329
+ && !token.trim().is_empty()
330
+ {
263
331
  return OctocrabBuilder::new()
264
332
  .personal_token(token)
265
333
  .set_connect_timeout(connect_timeout)
@@ -305,6 +373,7 @@ impl GitHubClient {
305
373
  if let Ok(content) = fs::read_to_string(&xdg_path)
306
374
  && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
307
375
  && let Some(token) = config.github_com.oauth_token
376
+ && !token.trim().is_empty()
308
377
  {
309
378
  return Some(token);
310
379
  }
@@ -319,7 +388,10 @@ impl GitHubClient {
319
388
  if let Ok(content) = fs::read_to_string(&config_path)
320
389
  && let Ok(config) = serde_yaml::from_str::<GhConfig>(&content)
321
390
  {
322
- return config.github_com.oauth_token;
391
+ return config
392
+ .github_com
393
+ .oauth_token
394
+ .filter(|t| !t.trim().is_empty());
323
395
  }
324
396
  }
325
397
 
@@ -406,26 +478,29 @@ impl GitHubClient {
406
478
 
407
479
  // Only fetch timeline for closed issues (open issues can't have closing PRs)
408
480
  if matches!(issue_info.state, octocrab::models::IssueState::Closed) {
409
- issue_info.closing_prs = self.find_closing_prs(repo_info, issue_info.number).await;
481
+ let (closing_prs, timeline_may_be_incomplete) =
482
+ self.find_closing_prs(repo_info, issue_info.number).await;
483
+ issue_info.closing_prs = closing_prs;
484
+ issue_info.timeline_may_be_incomplete = timeline_may_be_incomplete;
410
485
  }
411
486
 
412
487
  Some(issue_info)
413
488
  }
414
489
 
415
- /// Find closing PRs for an issue by examining timeline events
416
- /// Returns list of PR references (may be from different repositories)
417
- /// Priority:
418
- /// 1. Closed events with `commit_id` (clearly indicate the PR/commit that closed the issue)
419
- /// 2. CrossReferenced/Referenced events (fallback, but only merged PRs)
490
+ /// Find closing PRs for an issue by examining timeline events.
491
+ /// Returns `(prs, timeline_may_be_incomplete)` where the flag is true when
492
+ /// the timeline was fetched via an anonymous fallback client (after a SAML
493
+ /// or bad-credentials error) or the API call failed entirely, meaning
494
+ /// cross-project PR references may be missing from the response.
420
495
  async fn find_closing_prs(
421
496
  &self,
422
497
  repo_info: &GhRepoInfo,
423
498
  issue_number: u64,
424
- ) -> Vec<PullRequestInfo> {
499
+ ) -> (Vec<PullRequestInfo>, bool) {
425
500
  let mut closing_prs = Vec::new();
426
501
 
427
502
  // Try to get first page with auth client, fallback to anonymous
428
- let Ok((mut current_page, client)) = self
503
+ let Ok(result) = self
429
504
  .call_api_and_get_client(move |client| {
430
505
  let repo_info = repo_info.clone();
431
506
  Box::pin(async move {
@@ -439,11 +514,19 @@ impl GitHubClient {
439
514
  })
440
515
  .await
441
516
  else {
442
- return Vec::new();
517
+ // API call failed entirely -- timeline is definitely incomplete
518
+ return (Vec::new(), true);
443
519
  };
444
520
 
521
+ let mut current_page = result.value;
522
+ let client = result.client;
523
+ let timeline_may_be_incomplete = matches!(
524
+ result.selection,
525
+ ClientSelection::Fallback(FallbackReason::Saml | FallbackReason::BadCredentials)
526
+ );
527
+
445
528
  // Collect all timeline events to get closing commits and referenced PRs
446
- loop {
529
+ 'pagination: loop {
447
530
  for event in &current_page.items {
448
531
  // Collect candidate PRs from cross-references
449
532
  if let Some(source) = event.source.as_ref() {
@@ -466,7 +549,7 @@ impl GitHubClient {
466
549
  if matches!(event.event, TimelineEventType::Closed) {
467
550
  // If it's a Closed event, assume this is the closing PR
468
551
  closing_prs.push(pr_info);
469
- break; // No need to check further events
552
+ break 'pagination; // No need to check further events or pages
470
553
  }
471
554
 
472
555
  // Otherwise, only consider CrossReferenced/Referenced events
@@ -507,7 +590,7 @@ impl GitHubClient {
507
590
  }
508
591
  }
509
592
 
510
- closing_prs
593
+ (closing_prs, timeline_may_be_incomplete)
511
594
  }
512
595
 
513
596
  /// Fetch releases from GitHub, optionally filtered by date
@@ -524,7 +607,7 @@ impl GitHubClient {
524
607
  let per_page = 100u8; // Max allowed by GitHub API
525
608
 
526
609
  // Try to get first page with auth client, fallback to anonymous
527
- let Ok((mut current_page, client)) = self
610
+ let Ok(result) = self
528
611
  .call_api_and_get_client(move |client| {
529
612
  let repo_info = repo_info.clone();
530
613
  Box::pin(async move {
@@ -542,6 +625,8 @@ impl GitHubClient {
542
625
  else {
543
626
  return releases;
544
627
  };
628
+ let mut current_page = result.value;
629
+ let client = result.client;
545
630
 
546
631
  'pagintaion: loop {
547
632
  if current_page.items.is_empty() {
@@ -883,52 +968,68 @@ impl GitHubClient {
883
968
  where
884
969
  for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
885
970
  {
886
- let (result, _client) = self.call_api_and_get_client(api_call).await?;
887
- Ok(result)
971
+ let result = self.call_api_and_get_client(api_call).await?;
972
+ Ok(result.value)
888
973
  }
889
974
 
890
- /// Call a GitHub API with fallback to backup client on SAML errors.
891
- /// Returns results & the client used, or error.
975
+ /// Call a GitHub API with fallback to backup client on SAML or bad-credentials errors.
976
+ /// Returns an [`ApiCallResult`] containing the value, the client used, and how the
977
+ /// client was selected (see [`ClientSelection`]).
892
978
  /// Emits `Notice::GhRateLimitHit` if rate limit is detected.
893
- async fn call_api_and_get_client<F, T>(&self, api_call: F) -> WtgResult<(T, &Octocrab)>
979
+ async fn call_api_and_get_client<F, T>(&self, api_call: F) -> WtgResult<ApiCallResult<'_, T>>
894
980
  where
895
981
  for<'a> F: Fn(&'a Octocrab) -> Pin<Box<dyn Future<Output = OctoResult<T>> + Send + 'a>>,
896
982
  {
897
983
  // Try with main client first
898
- let main_error = match Self::await_with_timeout_and_error(api_call(&self.main_client)).await
899
- {
900
- Ok(result) => return Ok((result, &self.main_client)),
901
- Err(e) if e.is_gh_rate_limit() => {
902
- log::debug!(
903
- "GitHub API rate limit hit (authenticated={}): {:?}",
904
- self.is_authenticated,
905
- e
906
- );
907
- self.emit(Notice::GhRateLimitHit {
908
- authenticated: self.is_authenticated,
909
- });
910
- return Err(e);
911
- }
912
- Err(e) if e.is_gh_saml() && self.is_authenticated => {
913
- // SAML error with authenticated client - fall through to try backup
914
- e
915
- }
916
- Err(e) => {
917
- // Non-SAML error, or SAML with anonymous client (no fallback possible)
918
- log::debug!("GitHub API error: {e:?}");
919
- return Err(e);
920
- }
921
- };
984
+ let (main_error, fallback_reason) =
985
+ match Self::await_with_timeout_and_error(api_call(&self.main_client)).await {
986
+ Ok(result) => {
987
+ return Ok(ApiCallResult {
988
+ value: result,
989
+ client: &self.main_client,
990
+ selection: ClientSelection::Main,
991
+ });
992
+ }
993
+ Err(e) if e.is_gh_rate_limit() => {
994
+ log::debug!(
995
+ "GitHub API rate limit hit (authenticated={}): {:?}",
996
+ self.is_authenticated,
997
+ e
998
+ );
999
+ self.emit(Notice::GhRateLimitHit {
1000
+ authenticated: self.is_authenticated,
1001
+ });
1002
+ return Err(e);
1003
+ }
1004
+ Err(e) if e.is_gh_saml() && self.is_authenticated => {
1005
+ // SAML error with authenticated client - fall through to try backup
1006
+ (e, FallbackReason::Saml)
1007
+ }
1008
+ Err(e) if e.is_gh_bad_credentials() && self.is_authenticated => {
1009
+ // Bad credentials (401) - token is invalid/expired, fall through to try backup
1010
+ log::debug!("GitHub API bad credentials, falling back to anonymous client");
1011
+ (e, FallbackReason::BadCredentials)
1012
+ }
1013
+ Err(e) => {
1014
+ // Non-recoverable error, or unauthenticated client (no fallback possible)
1015
+ log::debug!("GitHub API error: {e:?}");
1016
+ return Err(e);
1017
+ }
1018
+ };
922
1019
 
923
- // Try with backup client on SAML error (only reached if authenticated)
1020
+ // Try with backup client (only reached if authenticated and main failed with SAML or bad creds)
924
1021
  let Some(backup) = self.backup_client.as_ref() else {
925
1022
  // Backup client failed to build - connection was lost between auth and now
926
1023
  return Err(WtgError::GhConnectionLost);
927
1024
  };
928
1025
 
929
- // Try the backup, but if it also fails with SAML, return original error
1026
+ // Try the backup; if it also fails, return the appropriate error
930
1027
  match Self::await_with_timeout_and_error(api_call(backup)).await {
931
- Ok(result) => Ok((result, backup)),
1028
+ Ok(result) => Ok(ApiCallResult {
1029
+ value: result,
1030
+ client: backup,
1031
+ selection: ClientSelection::Fallback(fallback_reason),
1032
+ }),
932
1033
  Err(e) if e.is_gh_rate_limit() => {
933
1034
  log::debug!("GitHub API rate limit hit on backup client: {e:?}");
934
1035
  // Emit notice for anonymous fallback (authenticated was true to reach here,
@@ -938,9 +1039,12 @@ impl GitHubClient {
938
1039
  });
939
1040
  Err(e)
940
1041
  }
941
- Err(e) if e.is_gh_saml() => Err(main_error), // Return original SAML error
1042
+ Err(e) if e.is_gh_saml() => Err(main_error), // Return original main-client error
942
1043
  Err(e) => {
943
1044
  log::debug!("GitHub API error on backup client: {e:?}");
1045
+ self.emit(Notice::GhAnonymousFallbackFailed {
1046
+ error: format!("{e}"),
1047
+ });
944
1048
  Err(e)
945
1049
  }
946
1050
  }
@@ -60,6 +60,22 @@ pub enum Notice {
60
60
  /// Whether the client was authenticated or anonymous
61
61
  authenticated: bool,
62
62
  },
63
+ /// Anonymous fallback was attempted but failed with a non-rate-limit error
64
+ GhAnonymousFallbackFailed {
65
+ /// The error message from the failed anonymous request
66
+ error: String,
67
+ },
68
+ /// Cross-project PR commit fetch failed (e.g., due to rate limits or auth issues)
69
+ CrossProjectPrFetchFailed {
70
+ /// Owner of the cross-project repo
71
+ owner: String,
72
+ /// Repo name of the cross-project repo
73
+ repo: String,
74
+ /// PR number
75
+ pr_number: u64,
76
+ /// Error message
77
+ error: String,
78
+ },
63
79
  }
64
80
 
65
81
  /// Callback for emitting notices during operations.
@@ -361,6 +361,28 @@ fn display_missing_info(info: &EnrichedInfo) {
361
361
  "🤷 No PR found for this issue... still hunting for the fix!"
362
362
  };
363
363
  println!("{}", message.yellow().italic());
364
+ if issue.timeline_may_be_incomplete {
365
+ println!(
366
+ "{}",
367
+ " (Timeline data may be incomplete - we might be missing cross-project refs. Check your token access!)"
368
+ .yellow()
369
+ .italic()
370
+ );
371
+ }
372
+ println!();
373
+ }
374
+
375
+ // Warning: timeline may be incomplete even when a PR was found
376
+ if let Some(issue) = info.issue.as_ref()
377
+ && info.pr.is_some()
378
+ && issue.timeline_may_be_incomplete
379
+ {
380
+ println!(
381
+ "{}",
382
+ "⚠️ Timeline data may be incomplete - we might be missing cross-project refs. Check your token access!"
383
+ .yellow()
384
+ .italic()
385
+ );
364
386
  println!();
365
387
  }
366
388
 
@@ -612,7 +634,7 @@ fn display_release_info(release: Option<TagInfo>, filter: &ReleaseFilter) {
612
634
  fn display_unsupported_host(remote: &RemoteInfo) {
613
635
  match remote.host {
614
636
  Some(RemoteHost::GitLab) => {
615
- println!(
637
+ eprintln!(
616
638
  "{}",
617
639
  "🦊 GitLab spotted! Living that self-hosted life, I see..."
618
640
  .yellow()
@@ -620,7 +642,7 @@ fn display_unsupported_host(remote: &RemoteInfo) {
620
642
  );
621
643
  }
622
644
  Some(RemoteHost::Bitbucket) => {
623
- println!(
645
+ eprintln!(
624
646
  "{}",
625
647
  "🪣 Bitbucket, eh? Taking the scenic route!"
626
648
  .yellow()
@@ -632,7 +654,7 @@ fn display_unsupported_host(remote: &RemoteInfo) {
632
654
  return;
633
655
  }
634
656
  None => {
635
- println!(
657
+ eprintln!(
636
658
  "{}",
637
659
  "🌐 A custom git remote? Look at you being all independent!"
638
660
  .yellow()
@@ -641,13 +663,13 @@ fn display_unsupported_host(remote: &RemoteInfo) {
641
663
  }
642
664
  }
643
665
 
644
- println!(
666
+ eprintln!(
645
667
  "{}",
646
668
  " (I can only do GitHub API stuff, but let me show you local git info...)"
647
669
  .yellow()
648
670
  .italic()
649
671
  );
650
- println!();
672
+ eprintln!();
651
673
  }
652
674
 
653
675
  fn display_mixed_remotes(hosts: &[RemoteHost], count: usize) {
@@ -660,7 +682,7 @@ fn display_mixed_remotes(hosts: &[RemoteHost], count: usize) {
660
682
  })
661
683
  .collect();
662
684
 
663
- println!(
685
+ eprintln!(
664
686
  "{}",
665
687
  format!(
666
688
  "🤯 Whoa, {} remotes pointing to {}? I'm getting dizzy!",
@@ -670,19 +692,19 @@ fn display_mixed_remotes(hosts: &[RemoteHost], count: usize) {
670
692
  .yellow()
671
693
  .italic()
672
694
  );
673
- println!(
695
+ eprintln!(
674
696
  "{}",
675
697
  " (You've got quite the multi-cloud setup going on here...)"
676
698
  .yellow()
677
699
  .italic()
678
700
  );
679
- println!(
701
+ eprintln!(
680
702
  "{}",
681
703
  " (I can only do GitHub API stuff, but let me show you local git info...)"
682
704
  .yellow()
683
705
  .italic()
684
706
  );
685
- println!();
707
+ eprintln!();
686
708
  }
687
709
 
688
710
  /// Print a notice to stderr.
@@ -812,5 +834,42 @@ pub fn print_notice(notice: Notice) {
812
834
  );
813
835
  }
814
836
  }
837
+ Notice::GhAnonymousFallbackFailed { error } => {
838
+ eprintln!(
839
+ "{}",
840
+ "🔑 Tried anonymous fallback, but GitHub wasn't having it..."
841
+ .yellow()
842
+ .italic()
843
+ );
844
+ eprintln!("{}", format!(" ({error})").yellow().italic());
845
+ eprintln!(
846
+ "{}",
847
+ " (Set GITHUB_TOKEN for more reliable access!)"
848
+ .yellow()
849
+ .italic()
850
+ );
851
+ }
852
+ Notice::CrossProjectPrFetchFailed {
853
+ owner,
854
+ repo,
855
+ pr_number,
856
+ error,
857
+ } => {
858
+ eprintln!(
859
+ "{}",
860
+ format!(
861
+ "🔍 Spotted PR #{pr_number} in {owner}/{repo}, but it's playing hard to get..."
862
+ )
863
+ .yellow()
864
+ .italic()
865
+ );
866
+ eprintln!("{}", format!(" ({error})").yellow().italic());
867
+ eprintln!(
868
+ "{}",
869
+ " (Cross-project sleuthing needs a GITHUB_TOKEN with access!)"
870
+ .yellow()
871
+ .italic()
872
+ );
873
+ }
815
874
  }
816
875
  }
@@ -9,6 +9,7 @@ use crate::changelog;
9
9
  use crate::error::{WtgError, WtgResult};
10
10
  use crate::git::{CommitInfo, FileInfo, TagInfo};
11
11
  use crate::github::{ExtendedIssueInfo, PullRequestInfo};
12
+ use crate::notice::Notice;
12
13
  use crate::parse_input::Query;
13
14
  use crate::release_filter::ReleaseFilter;
14
15
 
@@ -36,6 +37,8 @@ pub struct IssueInfo {
36
37
  pub url: String,
37
38
  pub author: Option<String>,
38
39
  pub author_url: Option<String>,
40
+ /// Timeline data may be incomplete due to SAML-restricted org access.
41
+ pub timeline_may_be_incomplete: bool,
39
42
  }
40
43
 
41
44
  impl From<&ExtendedIssueInfo> for IssueInfo {
@@ -48,6 +51,7 @@ impl From<&ExtendedIssueInfo> for IssueInfo {
48
51
  url: ext_info.url.clone(),
49
52
  author: ext_info.author.clone(),
50
53
  author_url: ext_info.author_url.clone(),
54
+ timeline_may_be_incomplete: ext_info.timeline_may_be_incomplete,
51
55
  }
52
56
  }
53
57
  }
@@ -223,20 +227,32 @@ async fn resolve_issue(
223
227
  if let Some(merge_sha) = &pr.merge_commit_sha {
224
228
  // Get backend for PR (returns cross-project backend if needed, None if same repo)
225
229
  let cross_backend = backend.backend_for_pr(pr).await;
230
+ let is_cross_project = cross_backend.is_some();
226
231
  let effective_backend: &dyn Backend =
227
232
  cross_backend.as_ref().map_or(backend, |b| b.as_ref());
228
233
 
229
- let commit = effective_backend.find_commit(merge_sha).await.ok();
230
- let commit = match commit {
231
- Some(c) => Some(effective_backend.enrich_commit(c).await),
232
- None => None,
234
+ // Try to fetch the commit, emit notice if cross-project fetch fails
235
+ let commit = match effective_backend.find_commit(merge_sha).await {
236
+ Ok(c) => Some(effective_backend.enrich_commit(c).await),
237
+ Err(e) => {
238
+ // Emit notice if this was a cross-project fetch failure
239
+ if is_cross_project && let Some(repo_info) = pr.repo_info.as_ref() {
240
+ backend.emit_notice(Notice::CrossProjectPrFetchFailed {
241
+ owner: repo_info.owner().to_string(),
242
+ repo: repo_info.repo().to_string(),
243
+ pr_number: pr.number,
244
+ error: e.to_string(),
245
+ });
246
+ }
247
+ None
248
+ }
233
249
  };
234
250
 
235
251
  let release = if let Some(ref c) = commit {
236
252
  let hash = &c.hash;
237
253
  let date = Some(c.date);
238
254
  // Try issue's repo first, fall back to PR's repo for releases
239
- if cross_backend.is_some() {
255
+ if is_cross_project {
240
256
  match backend.find_release_for_commit(hash, date, filter).await {
241
257
  Some(r) => Some(r),
242
258
  None => {
@@ -327,6 +327,63 @@ async fn integration_specific_tag_found() {
327
327
  );
328
328
  }
329
329
 
330
+ /// Test that an invalid `GITHUB_TOKEN` doesn't panic and attempts anonymous fallback.
331
+ /// The anonymous fallback may itself fail (e.g., rate limits on shared CI runners),
332
+ /// so this test only asserts the result when the fallback succeeds. When the fallback
333
+ /// returns None, we verify a fallback notice was emitted (rate-limit or anonymous
334
+ /// fallback failure) to confirm fallback was actually attempted (rather than silently
335
+ /// accepting a regression).
336
+ #[tokio::test]
337
+ async fn integration_invalid_github_token_falls_back_to_anonymous() {
338
+ use std::sync::{Arc, Mutex};
339
+ use wtg_cli::github::{GhRepoInfo, GitHubClient};
340
+ use wtg_cli::notice::Notice;
341
+
342
+ // Build a client with an invalid token to force 401 Bad Credentials
343
+ let client = GitHubClient::new_with_token("ghp_clearly_not_a_real_token_000000000".to_string())
344
+ .expect("Failed to create GitHub client");
345
+
346
+ // Track notices to verify fallback behavior even when the result is None
347
+ let notices: Arc<Mutex<Vec<Notice>>> = Arc::new(Mutex::new(Vec::new()));
348
+ let notices_clone = Arc::clone(&notices);
349
+ client.set_notice_callback(Arc::new(move |notice| {
350
+ notices_clone.lock().unwrap().push(notice);
351
+ }));
352
+
353
+ // Fetch a known commit via a single API call. The bad token triggers fallback
354
+ // to the anonymous backup client. If anonymous rate limit is exhausted
355
+ // (common on shared CI runners), the result is None - that's acceptable
356
+ // only if a rate-limit notice confirms the fallback was actually attempted.
357
+ let repo_info = GhRepoInfo::new("go-task".to_string(), "task".to_string());
358
+ let commit = client.fetch_commit_full_info(&repo_info, "15b7e3c").await;
359
+
360
+ if let Some(commit) = commit {
361
+ assert!(
362
+ commit.hash.starts_with("15b7e3c"),
363
+ "Expected commit hash starting with 15b7e3c, got {}",
364
+ commit.hash
365
+ );
366
+ } else {
367
+ let captured = notices.lock().unwrap();
368
+ let got_fallback_notice = captured.iter().any(|n| {
369
+ matches!(
370
+ n,
371
+ Notice::GhRateLimitHit {
372
+ authenticated: false
373
+ } | Notice::GhAnonymousFallbackFailed { .. }
374
+ )
375
+ });
376
+ let captured_notices: Vec<String> = captured.iter().map(|n| format!("{n:?}")).collect();
377
+ drop(captured);
378
+ assert!(
379
+ got_fallback_notice,
380
+ "Fallback returned None but no anonymous fallback notice was emitted; \
381
+ this suggests the fallback mechanism may be broken. \
382
+ Captured notices: {captured_notices:?}"
383
+ );
384
+ }
385
+ }
386
+
330
387
  /// Convert `IdentifiedThing` to a consistent snapshot structure
331
388
  fn to_snapshot(result: &IdentifiedThing) -> IntegrationSnapshot {
332
389
  match result {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes