Skip to main content

wtg_cli/
output.rs

1use std::collections::HashSet;
2
3use crossterm::style::Stylize;
4use octocrab::models::IssueState;
5
6use crate::error::WtgResult;
7use crate::git::{CommitInfo, TagInfo};
8use crate::github::PullRequestInfo;
9use crate::notice::Notice;
10use crate::release_filter::ReleaseFilter;
11use crate::remote::{RemoteHost, RemoteInfo};
12use crate::resolution::{
13    ChangesSource, EnrichedInfo, EntryPoint, FileResult, IdentifiedThing, IssueInfo, TagResult,
14};
15
16pub fn display(thing: IdentifiedThing, filter: &ReleaseFilter) -> WtgResult<()> {
17    match thing {
18        IdentifiedThing::Enriched(info) => display_enriched(*info, filter),
19        IdentifiedThing::File(file_result) => display_file(*file_result, filter),
20        IdentifiedThing::Tag(tag_result) => display_tag(&tag_result),
21    }
22
23    Ok(())
24}
25
26/// Display tag information with changes from best available source
27fn display_tag(result: &TagResult) {
28    let tag = &result.tag_info;
29
30    // Header
31    println!("{} {}", "🏷️  Tag:".green().bold(), tag.name.as_str().cyan());
32    println!(
33        "{} {}",
34        "📅 Created:".yellow(),
35        tag.created_at.format("%Y-%m-%d").to_string().dark_grey()
36    );
37
38    // URL - prefer release URL if available
39    if let Some(url) = tag.release_url.as_ref().or(result.github_url.as_ref()) {
40        println!(
41            "{} {}",
42            "🔗 Release:".blue(),
43            url.as_str().blue().underlined()
44        );
45    }
46
47    println!();
48
49    // Changes section
50    if let Some(source) = &result.changes_source {
51        let source_label = match source {
52            ChangesSource::GitHubRelease => "(from GitHub release)".to_string(),
53            ChangesSource::Changelog => "(from CHANGELOG)".to_string(),
54            ChangesSource::Commits { previous_tag } => {
55                format!("(commits since {previous_tag})")
56            }
57        };
58
59        println!(
60            "{} {}",
61            "Changes".magenta().bold(),
62            source_label.as_str().dark_grey()
63        );
64
65        match source {
66            ChangesSource::Commits { .. } => {
67                // Display commits as bullet list
68                for commit in &result.commits {
69                    println!(
70                        "• {} {}",
71                        commit.short_hash.as_str().cyan(),
72                        commit.message.as_str().white()
73                    );
74                }
75            }
76            _ => {
77                // Display text content
78                if let Some(content) = &result.changes {
79                    for line in content.lines() {
80                        println!("{line}");
81                    }
82                }
83            }
84        }
85
86        // Truncation notice
87        if result.truncated_lines > 0 {
88            if let Some(url) = tag.release_url.as_ref().or(result.github_url.as_ref()) {
89                println!(
90                    "{}",
91                    format!(
92                        "... {} more lines (see full release at {})",
93                        result.truncated_lines, url
94                    )
95                    .as_str()
96                    .dark_grey()
97                    .italic()
98                );
99            } else {
100                println!(
101                    "{}",
102                    format!("... {} more lines", result.truncated_lines)
103                        .as_str()
104                        .dark_grey()
105                        .italic()
106                );
107            }
108        }
109    } else {
110        // No changes available - just show the tag exists
111        println!(
112            "{}",
113            "No release notes, changelog entry, or previous tag found."
114                .dark_grey()
115                .italic()
116        );
117    }
118}
119
120/// Display enriched info - the main display logic
121/// Order depends on what the user searched for
122fn display_enriched(info: EnrichedInfo, filter: &ReleaseFilter) {
123    match &info.entry_point {
124        EntryPoint::IssueNumber(_) => {
125            // User searched for issue - lead with issue
126            display_identification(&info.entry_point);
127            println!();
128
129            if let Some(issue) = &info.issue {
130                display_issue_section(issue);
131                println!();
132            }
133
134            if let Some(pr) = &info.pr {
135                display_pr_section(pr, true); // true = show as "the fix"
136                println!();
137            }
138
139            if let Some(commit_info) = info.commit.as_ref() {
140                display_commit_section(commit_info, info.pr.as_ref());
141                println!();
142            }
143
144            display_missing_info(&info);
145
146            if info.commit.is_some() {
147                display_release_info(info.release, filter);
148            }
149        }
150        EntryPoint::PullRequestNumber(_) => {
151            // User searched for PR - lead with PR
152            display_identification(&info.entry_point);
153            println!();
154
155            if let Some(pr) = &info.pr {
156                display_pr_section(pr, false); // false = not a fix, just a PR
157                println!();
158            }
159
160            if let Some(commit_info) = info.commit.as_ref() {
161                display_commit_section(commit_info, info.pr.as_ref());
162                println!();
163            }
164
165            display_missing_info(&info);
166
167            if info.commit.is_some() {
168                display_release_info(info.release, filter);
169            }
170        }
171        _ => {
172            // User searched for commit or something else - lead with commit
173            display_identification(&info.entry_point);
174            println!();
175
176            if let Some(commit_info) = info.commit.as_ref() {
177                display_commit_section(commit_info, info.pr.as_ref());
178                println!();
179            }
180
181            if let Some(pr) = &info.pr {
182                display_pr_section(pr, false);
183                println!();
184            }
185
186            if let Some(issue) = &info.issue {
187                display_issue_section(issue);
188                println!();
189            }
190
191            display_missing_info(&info);
192
193            if info.commit.is_some() {
194                display_release_info(info.release, filter);
195            }
196        }
197    }
198}
199
200/// Display what the user searched for
201fn display_identification(entry_point: &EntryPoint) {
202    match entry_point {
203        EntryPoint::Commit(hash) => {
204            println!(
205                "{} {}",
206                "🔍 Found commit:".green().bold(),
207                hash.as_str().cyan()
208            );
209        }
210        EntryPoint::PullRequestNumber(num) => {
211            println!(
212                "{} #{}",
213                "🔀 Found PR:".green().bold(),
214                num.to_string().cyan()
215            );
216        }
217        EntryPoint::IssueNumber(num) => {
218            println!(
219                "{} #{}",
220                "🐛 Found issue:".green().bold(),
221                num.to_string().cyan()
222            );
223        }
224        EntryPoint::FilePath { branch, path } => {
225            println!(
226                "{} {}@{}",
227                "📄 Found file:".green().bold(),
228                path.as_str().cyan(),
229                branch.clone().cyan()
230            );
231        }
232        EntryPoint::Tag(tag) => {
233            println!(
234                "{} {}",
235                "🏷️  Found tag:".green().bold(),
236                tag.as_str().cyan()
237            );
238        }
239    }
240}
241
242/// Display commit information (the core section, always present when resolved)
243fn display_commit_section(commit_info: &CommitInfo, pr: Option<&PullRequestInfo>) {
244    let commit_url = commit_info.commit_url.as_deref();
245    let author_url = commit_info.author_url.as_deref();
246
247    println!("{}", "💻 The Commit:".cyan().bold());
248    println!(
249        "   {} {}",
250        "Hash:".yellow(),
251        commit_info.short_hash.as_str().cyan()
252    );
253
254    // Show commit author
255    print_author_subsection(
256        "Who wrote this gem:",
257        &commit_info.author_name,
258        commit_info
259            .author_login
260            .as_deref()
261            .or(commit_info.author_email.as_deref()),
262        author_url,
263    );
264
265    // Show commit message if not a PR
266    if pr.is_none() {
267        print_message_with_essay_joke(&commit_info.message, None, commit_info.message_lines);
268    }
269
270    println!(
271        "   {} {}",
272        "📅".yellow(),
273        commit_info
274            .date
275            .format("%Y-%m-%d %H:%M:%S")
276            .to_string()
277            .dark_grey()
278    );
279
280    if let Some(url) = commit_url {
281        print_link(url);
282    }
283}
284
285/// Display PR information (enrichment layer 1)
286fn display_pr_section(pr: &PullRequestInfo, is_fix: bool) {
287    println!("{}", "🔀 The Pull Request:".magenta().bold());
288    println!(
289        "   {} #{}",
290        "Number:".yellow(),
291        pr.number.to_string().cyan()
292    );
293
294    // PR author - different wording if this is shown as "the fix" for an issue
295    if let Some(author) = &pr.author {
296        let header = if is_fix {
297            "Who's brave:"
298        } else {
299            "Who merged this beauty:"
300        };
301        print_author_subsection(header, author, None, pr.author_url.as_deref());
302    }
303
304    // PR description (overrides commit message)
305    print_message_with_essay_joke(&pr.title, pr.body.as_deref(), pr.title.lines().count());
306
307    // Merge status
308    if let Some(merge_sha) = &pr.merge_commit_sha {
309        println!("   {} {}", "✅ Merged:".green(), merge_sha[..7].cyan());
310    } else {
311        println!("   {}", "❌ Not merged yet".yellow().italic());
312    }
313
314    print_link(&pr.url);
315}
316
317/// Display issue information (enrichment layer 2)
318fn display_issue_section(issue: &IssueInfo) {
319    println!("{}", "🐛 The Issue:".red().bold());
320    println!(
321        "   {} #{}",
322        "Number:".yellow(),
323        issue.number.to_string().cyan()
324    );
325
326    // Issue author
327    if let Some(author) = &issue.author {
328        print_author_subsection(
329            "Who spotted the trouble:",
330            author,
331            None,
332            issue.author_url.as_deref(),
333        );
334    }
335
336    // Issue description
337    print_message_with_essay_joke(
338        &issue.title,
339        issue.body.as_deref(),
340        issue.title.lines().count(),
341    );
342
343    print_link(&issue.url);
344}
345
346/// Display missing information (graceful degradation)
347fn display_missing_info(info: &EnrichedInfo) {
348    // Issue without PR
349    if let Some(issue) = info.issue.as_ref()
350        && info.pr.is_none()
351    {
352        let message = if info.commit.is_none() {
353            if issue.state == IssueState::Closed {
354                "🔍 Issue closed, but the trail's cold. Some stealthy hero dropped a fix and vanished without a PR."
355            } else {
356                "🔍 Couldn't trace this issue, still open. Waiting for a brave soul to pick it up..."
357            }
358        } else if issue.state == IssueState::Closed {
359            "🤷 Issue closed, but no PR found... Some stealthy hero dropped a fix and vanished without a PR."
360        } else {
361            "🤷 No PR found for this issue... still hunting for the fix!"
362        };
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        );
386        println!();
387    }
388
389    // PR without commit (either not merged, or merged but from a cross-project ref we do not have access to)
390    if let Some(pr_info) = info.pr.as_ref()
391        && info.commit.is_none()
392    {
393        if pr_info.merged {
394            println!(
395                "{}",
396                "⏳ PR merged, but alas, the commit is out of reach!"
397                    .yellow()
398                    .italic()
399            );
400        } else {
401            println!(
402                "{}",
403                "⏳ This PR hasn't been merged yet, too scared to commit!"
404                    .yellow()
405                    .italic()
406            );
407        }
408        println!();
409    }
410}
411
412// Helper functions for consistent formatting
413
414/// Print a clickable URL with consistent styling
415fn print_link(url: &str) {
416    println!("   {} {}", "🔗".blue(), url.blue().underlined());
417}
418
419/// Print author information as a subsection (indented)
420fn print_author_subsection(
421    header: &str,
422    name: &str,
423    email_or_username: Option<&str>,
424    profile_url: Option<&str>,
425) {
426    println!("   {} {}", "👤".yellow(), header.dark_grey());
427
428    if let Some(email_or_username) = email_or_username {
429        println!("      {} ({})", name.cyan(), email_or_username.dark_grey());
430    } else {
431        println!("      {}", name.cyan());
432    }
433
434    if let Some(url) = profile_url {
435        println!("      {} {}", "🔗".blue(), url.blue().underlined());
436    }
437}
438
439/// Print a message/description with essay joke if it's long
440fn print_message_with_essay_joke(first_line: &str, full_text: Option<&str>, line_count: usize) {
441    println!("   {} {}", "📝".yellow(), first_line.white().bold());
442
443    // Check if we should show the essay joke
444    if let Some(text) = full_text {
445        let char_count = text.len();
446
447        // Show essay joke if >100 chars or multi-line
448        if char_count > 100 || line_count > 1 {
449            let extra_lines = line_count.saturating_sub(1);
450            let message = if extra_lines > 0 {
451                format!(
452                    "Someone likes to write essays... {} more line{}",
453                    extra_lines,
454                    if extra_lines == 1 { "" } else { "s" }
455                )
456            } else {
457                format!("Someone likes to write essays... {char_count} characters")
458            };
459
460            println!("      {} {}", "📚".yellow(), message.dark_grey().italic());
461        }
462    }
463}
464
465/// Display file information (special case)
466fn display_file(file_result: FileResult, filter: &ReleaseFilter) {
467    let info = file_result.file_info;
468
469    println!("{} {}", "📄 Found file:".green().bold(), info.path.cyan());
470    println!();
471
472    // Display the commit section (consistent with PR/issue flow)
473    display_commit_section(
474        &info.last_commit,
475        None, // Files don't have associated PRs
476    );
477
478    // Count how many times the last commit author appears in previous commits
479    let last_author_name = &info.last_commit.author_name;
480    let repeat_count = info
481        .previous_authors
482        .iter()
483        .filter(|(_, name, _)| name == last_author_name)
484        .count();
485
486    // Add snarky comment if they're a repeat offender
487    if repeat_count > 0 {
488        let joke = match repeat_count {
489            1 => format!(
490                "   💀 {} can't stop touching this file... {} more time before this!",
491                last_author_name.as_str().cyan(),
492                repeat_count
493            ),
494            2 => format!(
495                "   💀 {} really loves this file... {} more times before this!",
496                last_author_name.as_str().cyan(),
497                repeat_count
498            ),
499            3 => format!(
500                "   💀 {} is obsessed... {} more times before this!",
501                last_author_name.as_str().cyan(),
502                repeat_count
503            ),
504            _ => format!(
505                "   💀 {} REALLY needs to leave this alone... {} more times before this!",
506                last_author_name.as_str().cyan(),
507                repeat_count
508            ),
509        };
510        println!("{}", joke.dark_grey().italic());
511    }
512
513    println!();
514
515    // Previous authors - snarky hall of shame (deduplicated)
516    if !info.previous_authors.is_empty() {
517        // Deduplicate authors - track who we've seen
518        let mut seen_authors = HashSet::new();
519        seen_authors.insert(last_author_name.clone()); // Skip the last commit author
520
521        let unique_authors: Vec<_> = info
522            .previous_authors
523            .iter()
524            .enumerate()
525            .filter(|(_, (_, name, _))| seen_authors.insert(name.clone()))
526            .collect();
527
528        if !unique_authors.is_empty() {
529            let count = unique_authors.len();
530            let header = if count == 1 {
531                "👻 One ghost from the past:"
532            } else {
533                "👻 The usual suspects (who else touched this):"
534            };
535            println!("{}", header.yellow().bold());
536
537            for (original_idx, (hash, name, _email)) in unique_authors {
538                print!("   → {} • {}", hash.as_str().cyan(), name.as_str().cyan());
539
540                if let Some(Some(url)) = file_result.author_urls.get(original_idx) {
541                    print!(" {} {}", "🔗".blue(), url.as_str().blue().underlined());
542                }
543
544                println!();
545            }
546
547            println!();
548        }
549    }
550
551    // Release info
552    display_release_info(file_result.release, filter);
553}
554
555fn display_release_info(release: Option<TagInfo>, filter: &ReleaseFilter) {
556    // Special messaging when checking a specific release
557    if let Some(tag_name) = filter.specific_tag() {
558        println!("{}", "📦 Release check:".magenta().bold());
559        if let Some(tag) = release {
560            println!(
561                "   {} {}",
562                "✅".green(),
563                format!(
564                    "Yep, it's in there! {} has got your commit covered.",
565                    tag.name
566                )
567                .green()
568                .bold()
569            );
570            // Use pre-computed tag_url (release URL for releases, tree URL for plain tags)
571            if let Some(url) = &tag.tag_url {
572                print_link(url);
573            }
574        } else {
575            println!(
576                "   {} {}",
577                "❌".red(),
578                format!("Nope! That commit missed the {tag_name} party.")
579                    .yellow()
580                    .italic()
581            );
582        }
583        return;
584    }
585
586    // Standard release info display
587    println!("{}", "📦 First shipped in:".magenta().bold());
588
589    match release {
590        Some(tag) => {
591            // Display tag name (or release name if it's a GitHub release)
592            if tag.is_release {
593                if let Some(release_name) = &tag.release_name {
594                    println!(
595                        "   {} {} {}",
596                        "🎉".yellow(),
597                        release_name.as_str().cyan().bold(),
598                        format!("({})", tag.name).as_str().dark_grey()
599                    );
600                } else {
601                    println!("   {} {}", "🎉".yellow(), tag.name.as_str().cyan().bold());
602                }
603
604                // Show published date if available, fallback to tag date
605                let published_or_created = tag.published_at.unwrap_or(tag.created_at);
606
607                let date_part = published_or_created.format("%Y-%m-%d").to_string();
608                println!("   {} {}", "📅".dark_grey(), date_part.dark_grey());
609            } else {
610                // Plain git tag
611                println!("   {} {}", "🏷️ ".yellow(), tag.name.as_str().cyan().bold());
612            }
613
614            // Use pre-computed tag_url (release URL for releases, tree URL for plain tags)
615            if let Some(url) = &tag.tag_url {
616                print_link(url);
617            }
618        }
619        None => {
620            println!(
621                "   {}",
622                "🔥 Not shipped yet, still cooking in main!"
623                    .yellow()
624                    .italic()
625            );
626        }
627    }
628}
629
630// ============================================
631// Notice display
632// ============================================
633
634fn display_unsupported_host(remote: &RemoteInfo) {
635    match remote.host {
636        Some(RemoteHost::GitLab) => {
637            eprintln!(
638                "{}",
639                "🦊 GitLab spotted! Living that self-hosted life, I see..."
640                    .yellow()
641                    .italic()
642            );
643        }
644        Some(RemoteHost::Bitbucket) => {
645            eprintln!(
646                "{}",
647                "🪣 Bitbucket, eh? Taking the scenic route!"
648                    .yellow()
649                    .italic()
650            );
651        }
652        Some(RemoteHost::GitHub) => {
653            // Shouldn't happen, but handle gracefully
654            return;
655        }
656        None => {
657            eprintln!(
658                "{}",
659                "🌐 A custom git remote? Look at you being all independent!"
660                    .yellow()
661                    .italic()
662            );
663        }
664    }
665
666    eprintln!(
667        "{}",
668        "   (I can only do GitHub API stuff, but let me show you local git info...)"
669            .yellow()
670            .italic()
671    );
672    eprintln!();
673}
674
675fn display_mixed_remotes(hosts: &[RemoteHost], count: usize) {
676    let host_names: Vec<&str> = hosts
677        .iter()
678        .map(|h| match h {
679            RemoteHost::GitHub => "GitHub",
680            RemoteHost::GitLab => "GitLab",
681            RemoteHost::Bitbucket => "Bitbucket",
682        })
683        .collect();
684
685    eprintln!(
686        "{}",
687        format!(
688            "🤯 Whoa, {} remotes pointing to {}? I'm getting dizzy!",
689            count,
690            host_names.join(", ")
691        )
692        .yellow()
693        .italic()
694    );
695    eprintln!(
696        "{}",
697        "   (You've got quite the multi-cloud setup going on here...)"
698            .yellow()
699            .italic()
700    );
701    eprintln!(
702        "{}",
703        "   (I can only do GitHub API stuff, but let me show you local git info...)"
704            .yellow()
705            .italic()
706    );
707    eprintln!();
708}
709
710/// Print a notice to stderr.
711/// All notices (both capability warnings and operational info) go through this function.
712#[allow(clippy::too_many_lines)]
713pub fn print_notice(notice: Notice) {
714    match notice {
715        // --- Backend capability notices ---
716        Notice::NoRemotes => {
717            eprintln!(
718                "{}",
719                "🤐 No remotes configured - what are you hiding?"
720                    .yellow()
721                    .italic()
722            );
723            eprintln!(
724                "{}",
725                "   (Or maybe... go do some OSS? 👀)".yellow().italic()
726            );
727            eprintln!();
728        }
729        Notice::UnsupportedHost { ref best_remote } => {
730            display_unsupported_host(best_remote);
731        }
732        Notice::MixedRemotes { ref hosts, count } => {
733            display_mixed_remotes(hosts, count);
734        }
735        Notice::UnreachableGitHub { ref remote } => {
736            eprintln!(
737                "{}",
738                "🔑 Found a GitHub remote, but can't talk to the API..."
739                    .yellow()
740                    .italic()
741            );
742            eprintln!(
743                "{}",
744                format!(
745                    "   Remote '{}' points to GitHub, but no luck connecting.",
746                    remote.name
747                )
748                .yellow()
749                .italic()
750            );
751            eprintln!(
752                "{}",
753                "   (Missing token? Network hiccup? I'll work with what I've got!)"
754                    .yellow()
755                    .italic()
756            );
757            eprintln!();
758        }
759        Notice::ApiOnly => {
760            eprintln!(
761                "{}",
762                "📡 Using GitHub API only (local git unavailable)"
763                    .yellow()
764                    .italic()
765            );
766            eprintln!(
767                "{}",
768                "   (Some operations may be slower or limited)"
769                    .yellow()
770                    .italic()
771            );
772            eprintln!();
773        }
774
775        // --- Operational notices ---
776        Notice::CloningRepo { url } => {
777            eprintln!("🔄 Cloning remote repository {url}...");
778        }
779        Notice::CloneSucceeded { used_filter } => {
780            if used_filter {
781                eprintln!("✅ Repository cloned successfully (using filter)");
782            } else {
783                eprintln!("✅ Repository cloned successfully (using bare clone)");
784            }
785        }
786        Notice::CloneFallbackToBare { error } => {
787            eprintln!("⚠️  Filter clone failed ({error}), falling back to bare clone...");
788        }
789        Notice::CacheUpdateFailed { error } => {
790            eprintln!("⚠️  Failed to update cached repo: {error}");
791        }
792        Notice::ShallowRepoDetected => {
793            eprintln!(
794                "⚠️  Shallow repository detected: using API for commit lookup (use --fetch to override)"
795            );
796        }
797        Notice::UpdatingCache => {
798            eprintln!("🔄 Updating cached repository...");
799        }
800        Notice::CacheUpdated => {
801            eprintln!("✅ Repository updated");
802        }
803        Notice::CrossProjectFallbackToApi { owner, repo, error } => {
804            eprintln!(
805                "⚠️  Cannot access git for {owner}/{repo}: {error}. Using API only for cross-project refs."
806            );
807        }
808        Notice::GhRateLimitHit { authenticated } => {
809            if authenticated {
810                eprintln!(
811                    "{}",
812                    "🐌 Whoa there, speedster! GitHub says slow down..."
813                        .yellow()
814                        .italic()
815                );
816                eprintln!(
817                    "{}",
818                    "   (Even with a token, there are limits. Take a breather!)"
819                        .yellow()
820                        .italic()
821                );
822            } else {
823                eprintln!(
824                    "{}",
825                    "🐌 GitHub's giving us the silent treatment (60 req/hr for strangers)..."
826                        .yellow()
827                        .italic()
828                );
829                eprintln!(
830                    "{}",
831                    "   (Set GITHUB_TOKEN and they'll be way more chatty - 5000 req/hr!)"
832                        .yellow()
833                        .italic()
834                );
835            }
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        }
874    }
875}