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