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.
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/Cargo.lock +2 -2
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/Cargo.toml +2 -3
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/PKG-INFO +1 -1
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/backend/combined_backend.rs +4 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/backend/github_backend.rs +5 -1
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/backend/mod.rs +9 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/error.rs +23 -1
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/github.rs +155 -51
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/notice.rs +16 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/output.rs +68 -9
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/resolution.rs +21 -5
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/integration.rs +57 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/LICENSE +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/README.md +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/Cargo.toml +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/README.md +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/backend/git_backend.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/changelog.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/cli.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/constants.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/git.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/help.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/lib.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/main.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/parse_input.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/release_filter.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/remote.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/src/semver.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/common/mod.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/fixtures/test-repo.zip +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/offline.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/snapshots/integration__integration_identify_file.snap +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/snapshots/integration__integration_identify_recent_commit.snap +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg/tests/snapshots/integration__integration_identify_tag.snap +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg-python/Cargo.toml +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/crates/wtg-python/src/lib.rs +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/pyproject.toml +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/src/wtg_cli/__init__.py +0 -0
- {wtg_cli-0.3.0 → wtg_cli-0.3.1}/src/wtg_cli/__main__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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"]
|
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
417
|
-
///
|
|
418
|
-
///
|
|
419
|
-
///
|
|
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(
|
|
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
|
-
|
|
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 ¤t_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(
|
|
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
|
|
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
|
|
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<
|
|
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 =
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
let commit = match
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
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(¬ices);
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|