Skip to main content

wtg_cli/
resolution.rs

1//! Query resolution logic.
2//!
3//! This module contains the orchestration layer that resolves user queries
4//! to identified information using backend implementations. It also defines
5//! the types for representing resolved information.
6
7use crate::backend::Backend;
8use crate::changelog;
9use crate::error::{WtgError, WtgResult};
10use crate::git::{CommitInfo, FileInfo, TagInfo};
11use crate::github::{ExtendedIssueInfo, PullRequestInfo};
12use crate::notice::Notice;
13use crate::parse_input::Query;
14use crate::release_filter::ReleaseFilter;
15
16// ============================================
17// Result types
18// ============================================
19
20/// What the user entered to search for
21#[derive(Debug, Clone)]
22pub enum EntryPoint {
23    Commit(String),                            // Hash they entered
24    IssueNumber(u64),                          // Issue # they entered
25    PullRequestNumber(u64),                    // PR # they entered
26    FilePath { branch: String, path: String }, // File path they entered
27    Tag(String),                               // Tag they entered
28}
29
30/// Information about an Issue
31#[derive(Debug, Clone)]
32pub struct IssueInfo {
33    pub number: u64,
34    pub title: String,
35    pub body: Option<String>,
36    pub state: octocrab::models::IssueState,
37    pub url: String,
38    pub author: Option<String>,
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,
42}
43
44impl From<&ExtendedIssueInfo> for IssueInfo {
45    fn from(ext_info: &ExtendedIssueInfo) -> Self {
46        Self {
47            number: ext_info.number,
48            title: ext_info.title.clone(),
49            body: ext_info.body.clone(),
50            state: ext_info.state.clone(),
51            url: ext_info.url.clone(),
52            author: ext_info.author.clone(),
53            author_url: ext_info.author_url.clone(),
54            timeline_may_be_incomplete: ext_info.timeline_may_be_incomplete,
55        }
56    }
57}
58
59/// The enriched result of identification - progressively accumulates data
60#[derive(Debug, Clone)]
61pub struct EnrichedInfo {
62    pub entry_point: EntryPoint,
63
64    // Core - the commit (always present for complete results)
65    pub commit: Option<CommitInfo>,
66
67    // Enrichment Layer 1: PR (if this commit came from a PR)
68    pub pr: Option<PullRequestInfo>,
69
70    // Enrichment Layer 2: Issue (if this PR was fixing an issue)
71    pub issue: Option<IssueInfo>,
72
73    // Metadata
74    pub release: Option<TagInfo>,
75}
76
77/// For file results (special case with blame history)
78#[derive(Debug, Clone)]
79pub struct FileResult {
80    pub file_info: FileInfo,
81    pub commit_url: Option<String>,
82    pub author_urls: Vec<Option<String>>,
83    pub release: Option<TagInfo>,
84}
85
86/// Source of changes information for a tag
87#[derive(Debug, Clone)]
88pub enum ChangesSource {
89    /// From GitHub release description
90    GitHubRelease,
91    /// From CHANGELOG.md
92    Changelog,
93    /// From commits since previous tag
94    Commits { previous_tag: String },
95}
96
97/// Enriched tag result with changes information
98#[derive(Debug, Clone)]
99pub struct TagResult {
100    pub tag_info: TagInfo,
101    pub github_url: Option<String>,
102    /// Changes content (release notes, changelog section, or commit list)
103    pub changes: Option<String>,
104    /// Where the changes came from
105    pub changes_source: Option<ChangesSource>,
106    /// Number of lines truncated (0 if not truncated)
107    pub truncated_lines: usize,
108    /// Commits between this tag and previous (when source is Commits)
109    pub commits: Vec<CommitInfo>,
110}
111
112#[derive(Debug, Clone)]
113pub enum IdentifiedThing {
114    Enriched(Box<EnrichedInfo>),
115    File(Box<FileResult>),
116    Tag(Box<TagResult>),
117}
118
119// ============================================
120// Resolution logic
121// ============================================
122
123/// Resolve a query to identified information using the provided backend.
124///
125/// The `filter` parameter controls which releases are considered when finding
126/// the release that contains a commit.
127pub async fn resolve(
128    backend: &dyn Backend,
129    query: &Query,
130    filter: &ReleaseFilter,
131) -> WtgResult<IdentifiedThing> {
132    // Validate specific tag exists before doing any work
133    if let Some(tag_name) = filter.specific_tag()
134        && backend.find_tag(tag_name).await.is_err()
135    {
136        return Err(WtgError::TagNotFound(tag_name.to_string()));
137    }
138
139    match query {
140        Query::GitCommit(hash) => resolve_commit(backend, hash, filter).await,
141        Query::Pr(number) => resolve_pr(backend, *number, filter).await,
142        Query::Issue(number) => resolve_issue(backend, *number, filter).await,
143        Query::IssueOrPr(number) => {
144            // Try PR first, then issue
145            if let Ok(result) = resolve_pr(backend, *number, filter).await {
146                return Ok(result);
147            }
148            if let Ok(result) = resolve_issue(backend, *number, filter).await {
149                return Ok(result);
150            }
151            Err(WtgError::NotFound(format!("#{number}")))
152        }
153        Query::FilePath { branch, path } => {
154            resolve_file(backend, branch, &path.to_string_lossy(), filter).await
155        }
156        Query::Tag(tag) => resolve_tag(backend, tag).await,
157    }
158}
159
160/// Resolve a commit hash to `IdentifiedThing`.
161async fn resolve_commit(
162    backend: &dyn Backend,
163    hash: &str,
164    filter: &ReleaseFilter,
165) -> WtgResult<IdentifiedThing> {
166    let commit = backend.find_commit(hash).await?;
167    let commit = backend.enrich_commit(commit).await;
168    let release = backend
169        .find_release_for_commit(&commit.hash, Some(commit.date), filter)
170        .await;
171
172    Ok(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
173        entry_point: EntryPoint::Commit(hash.to_string()),
174        commit: Some(commit),
175        pr: None,
176        issue: None,
177        release,
178    })))
179}
180
181/// Resolve a PR number to `IdentifiedThing`.
182async fn resolve_pr(
183    backend: &dyn Backend,
184    number: u64,
185    filter: &ReleaseFilter,
186) -> WtgResult<IdentifiedThing> {
187    let pr = backend.fetch_pr(number).await?;
188
189    let commit = backend.find_commit_for_pr(&pr).await.ok();
190    let commit = match commit {
191        Some(c) => Some(backend.enrich_commit(c).await),
192        None => None,
193    };
194
195    let release = if let Some(ref c) = commit {
196        backend
197            .find_release_for_commit(&c.hash, Some(c.date), filter)
198            .await
199    } else {
200        None
201    };
202
203    Ok(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
204        entry_point: EntryPoint::PullRequestNumber(number),
205        commit,
206        pr: Some(pr),
207        issue: None,
208        release,
209    })))
210}
211
212/// Resolve an issue number to `IdentifiedThing`.
213///
214/// Handles cross-project PRs by spawning a backend for the PR's repository.
215async fn resolve_issue(
216    backend: &dyn Backend,
217    number: u64,
218    filter: &ReleaseFilter,
219) -> WtgResult<IdentifiedThing> {
220    let ext_issue = backend.fetch_issue(number).await?;
221    let display_issue = (&ext_issue).into();
222
223    // Try to find closing PR info
224    let closing_pr = ext_issue.closing_prs.into_iter().next();
225
226    let (commit, release) = if let Some(ref pr) = closing_pr {
227        if let Some(merge_sha) = &pr.merge_commit_sha {
228            // Get backend for PR (returns cross-project backend if needed, None if same repo)
229            let cross_backend = backend.backend_for_pr(pr).await;
230            let is_cross_project = cross_backend.is_some();
231            let effective_backend: &dyn Backend =
232                cross_backend.as_ref().map_or(backend, |b| b.as_ref());
233
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                }
249            };
250
251            let release = if let Some(ref c) = commit {
252                let hash = &c.hash;
253                let date = Some(c.date);
254                // Try issue's repo first, fall back to PR's repo for releases
255                if is_cross_project {
256                    match backend.find_release_for_commit(hash, date, filter).await {
257                        Some(r) => Some(r),
258                        None => {
259                            effective_backend
260                                .find_release_for_commit(hash, date, filter)
261                                .await
262                        }
263                    }
264                } else {
265                    backend.find_release_for_commit(hash, date, filter).await
266                }
267            } else {
268                None
269            };
270
271            (commit, release)
272        } else {
273            (None, None)
274        }
275    } else {
276        (None, None)
277    };
278
279    Ok(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
280        entry_point: EntryPoint::IssueNumber(number),
281        commit,
282        pr: closing_pr,
283        issue: Some(display_issue),
284        release,
285    })))
286}
287
288/// Resolve a file path to `IdentifiedThing`.
289async fn resolve_file(
290    backend: &dyn Backend,
291    branch: &str,
292    path: &str,
293    filter: &ReleaseFilter,
294) -> WtgResult<IdentifiedThing> {
295    let file_info = backend.find_file(branch, path).await?;
296    let commit_url = backend.commit_url(&file_info.last_commit.hash);
297
298    // Generate author URLs from emails
299    let author_urls: Vec<Option<String>> = file_info
300        .previous_authors
301        .iter()
302        .map(|(_, _, email)| backend.author_url_from_email(email))
303        .collect();
304
305    let release = backend
306        .find_release_for_commit(
307            &file_info.last_commit.hash,
308            Some(file_info.last_commit.date),
309            filter,
310        )
311        .await;
312
313    Ok(IdentifiedThing::File(Box::new(FileResult {
314        file_info,
315        commit_url,
316        author_urls,
317        release,
318    })))
319}
320
321/// Select the best changes source, falling back to commits if needed.
322async fn select_best_changes(
323    backend: &dyn Backend,
324    tag_name: &str,
325    release_body: Option<&str>,
326    changelog_content: Option<&str>,
327) -> (
328    Option<String>,
329    Option<ChangesSource>,
330    usize,
331    Vec<CommitInfo>,
332) {
333    // Select the best source: prefer longer content, ties go to release
334    let best_source = match (release_body, changelog_content) {
335        // Both available: prefer the longer one, tie goes to release
336        (Some(release), Some(changelog)) => {
337            if release.trim().len() >= changelog.trim().len() {
338                Some((release.to_string(), ChangesSource::GitHubRelease))
339            } else {
340                Some((changelog.to_string(), ChangesSource::Changelog))
341            }
342        }
343        // Only release available
344        (Some(release), None) if !release.trim().is_empty() => {
345            Some((release.to_string(), ChangesSource::GitHubRelease))
346        }
347        // Only changelog available
348        (None, Some(changelog)) if !changelog.trim().is_empty() => {
349            Some((changelog.to_string(), ChangesSource::Changelog))
350        }
351        // Neither available or both empty
352        _ => None,
353    };
354
355    if let Some((content, source)) = best_source {
356        let (truncated_content, remaining) = changelog::truncate_content(&content);
357        return (
358            Some(truncated_content.to_string()),
359            Some(source),
360            remaining,
361            Vec::new(),
362        );
363    }
364
365    // Fall back to commits
366    if let Ok(Some(prev_tag)) = backend.find_previous_tag(tag_name).await
367        && let Ok(commits) = backend
368            .commits_between_tags(&prev_tag.name, tag_name, 5)
369            .await
370        && !commits.is_empty()
371    {
372        return (
373            None,
374            Some(ChangesSource::Commits {
375                previous_tag: prev_tag.name,
376            }),
377            0,
378            commits,
379        );
380    }
381
382    // No changes available
383    (None, None, 0, Vec::new())
384}
385
386/// Resolve a tag name to `IdentifiedThing`.
387async fn resolve_tag(backend: &dyn Backend, name: &str) -> WtgResult<IdentifiedThing> {
388    let tag = backend.find_tag(name).await?;
389
390    // Try to get changelog section via backend
391    let changelog_content = backend.changelog_for_version(name).await;
392
393    // Try to get release body from GitHub (only if it's a release)
394    let release_body = if tag.is_release {
395        backend.fetch_release_body(name).await
396    } else {
397        None
398    };
399
400    // Determine best source and get commits if needed
401    let (changes, source, truncated, commits) = select_best_changes(
402        backend,
403        name,
404        release_body.as_deref(),
405        changelog_content.as_deref(),
406    )
407    .await;
408
409    Ok(IdentifiedThing::Tag(Box::new(TagResult {
410        tag_info: tag.clone(),
411        github_url: tag.tag_url,
412        changes,
413        changes_source: source,
414        truncated_lines: truncated,
415        commits,
416    })))
417}