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::error::{WtgError, WtgResult};
9use crate::git::{CommitInfo, FileInfo, TagInfo};
10use crate::github::{ExtendedIssueInfo, PullRequestInfo};
11use crate::parse_input::Query;
12
13// ============================================
14// Result types
15// ============================================
16
17/// What the user entered to search for
18#[derive(Debug, Clone)]
19pub enum EntryPoint {
20    Commit(String),                            // Hash they entered
21    IssueNumber(u64),                          // Issue # they entered
22    PullRequestNumber(u64),                    // PR # they entered
23    FilePath { branch: String, path: String }, // File path they entered
24    Tag(String),                               // Tag they entered
25}
26
27/// Information about an Issue
28#[derive(Debug, Clone)]
29pub struct IssueInfo {
30    pub number: u64,
31    pub title: String,
32    pub body: Option<String>,
33    pub state: octocrab::models::IssueState,
34    pub url: String,
35    pub author: Option<String>,
36    pub author_url: Option<String>,
37}
38
39impl From<&ExtendedIssueInfo> for IssueInfo {
40    fn from(ext_info: &ExtendedIssueInfo) -> Self {
41        Self {
42            number: ext_info.number,
43            title: ext_info.title.clone(),
44            body: ext_info.body.clone(),
45            state: ext_info.state.clone(),
46            url: ext_info.url.clone(),
47            author: ext_info.author.clone(),
48            author_url: ext_info.author_url.clone(),
49        }
50    }
51}
52
53/// The enriched result of identification - progressively accumulates data
54#[derive(Debug, Clone)]
55pub struct EnrichedInfo {
56    pub entry_point: EntryPoint,
57
58    // Core - the commit (always present for complete results)
59    pub commit: Option<CommitInfo>,
60
61    // Enrichment Layer 1: PR (if this commit came from a PR)
62    pub pr: Option<PullRequestInfo>,
63
64    // Enrichment Layer 2: Issue (if this PR was fixing an issue)
65    pub issue: Option<IssueInfo>,
66
67    // Metadata
68    pub release: Option<TagInfo>,
69}
70
71/// For file results (special case with blame history)
72#[derive(Debug, Clone)]
73pub struct FileResult {
74    pub file_info: FileInfo,
75    pub commit_url: Option<String>,
76    pub author_urls: Vec<Option<String>>,
77    pub release: Option<TagInfo>,
78}
79
80#[derive(Debug, Clone)]
81pub enum IdentifiedThing {
82    Enriched(Box<EnrichedInfo>),
83    File(Box<FileResult>),
84    TagOnly(Box<TagInfo>, Option<String>), // Just a tag, no commit yet
85}
86
87// ============================================
88// Resolution logic
89// ============================================
90
91/// Resolve a query to identified information using the provided backend.
92pub async fn resolve(backend: &dyn Backend, query: &Query) -> WtgResult<IdentifiedThing> {
93    match query {
94        Query::GitCommit(hash) => resolve_commit(backend, hash).await,
95        Query::Pr(number) => resolve_pr(backend, *number).await,
96        Query::Issue(number) => resolve_issue(backend, *number).await,
97        Query::IssueOrPr(number) => {
98            // Try PR first, then issue
99            if let Ok(result) = resolve_pr(backend, *number).await {
100                return Ok(result);
101            }
102            if let Ok(result) = resolve_issue(backend, *number).await {
103                return Ok(result);
104            }
105            Err(WtgError::NotFound(format!("#{number}")))
106        }
107        Query::FilePath { branch, path } => {
108            resolve_file(backend, branch, &path.to_string_lossy()).await
109        }
110        Query::Tag(tag) => resolve_tag(backend, tag).await,
111    }
112}
113
114/// Resolve a commit hash to `IdentifiedThing`.
115async fn resolve_commit(backend: &dyn Backend, hash: &str) -> WtgResult<IdentifiedThing> {
116    let commit = backend.find_commit(hash).await?;
117    let commit = backend.enrich_commit(commit).await;
118    let release = backend
119        .find_release_for_commit(&commit.hash, Some(commit.date))
120        .await;
121
122    Ok(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
123        entry_point: EntryPoint::Commit(hash.to_string()),
124        commit: Some(commit),
125        pr: None,
126        issue: None,
127        release,
128    })))
129}
130
131/// Resolve a PR number to `IdentifiedThing`.
132async fn resolve_pr(backend: &dyn Backend, number: u64) -> WtgResult<IdentifiedThing> {
133    let pr = backend.fetch_pr(number).await?;
134
135    let commit = backend.find_commit_for_pr(&pr).await.ok();
136    let commit = match commit {
137        Some(c) => Some(backend.enrich_commit(c).await),
138        None => None,
139    };
140
141    let release = if let Some(ref c) = commit {
142        backend.find_release_for_commit(&c.hash, Some(c.date)).await
143    } else {
144        None
145    };
146
147    Ok(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
148        entry_point: EntryPoint::PullRequestNumber(number),
149        commit,
150        pr: Some(pr),
151        issue: None,
152        release,
153    })))
154}
155
156/// Resolve an issue number to `IdentifiedThing`.
157///
158/// Handles cross-project PRs by spawning a backend for the PR's repository.
159async fn resolve_issue(backend: &dyn Backend, number: u64) -> WtgResult<IdentifiedThing> {
160    let ext_issue = backend.fetch_issue(number).await?;
161    let display_issue = (&ext_issue).into();
162
163    // Try to find closing PR info
164    let closing_pr = ext_issue.closing_prs.into_iter().next();
165
166    let (commit, release) = if let Some(ref pr) = closing_pr {
167        if let Some(merge_sha) = &pr.merge_commit_sha {
168            // Get backend for PR (returns cross-project backend if needed, None if same repo)
169            let cross_backend = backend.backend_for_pr(pr).await;
170            let effective_backend: &dyn Backend =
171                cross_backend.as_ref().map_or(backend, |b| b.as_ref());
172
173            let commit = effective_backend.find_commit(merge_sha).await.ok();
174            let commit = match commit {
175                Some(c) => Some(effective_backend.enrich_commit(c).await),
176                None => None,
177            };
178
179            let release = if let Some(ref c) = commit {
180                let hash = &c.hash;
181                let date = Some(c.date);
182                // Try issue's repo first, fall back to PR's repo for releases
183                if cross_backend.is_some() {
184                    match backend.find_release_for_commit(hash, date).await {
185                        Some(r) => Some(r),
186                        None => effective_backend.find_release_for_commit(hash, date).await,
187                    }
188                } else {
189                    backend.find_release_for_commit(hash, date).await
190                }
191            } else {
192                None
193            };
194
195            (commit, release)
196        } else {
197            (None, None)
198        }
199    } else {
200        (None, None)
201    };
202
203    Ok(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
204        entry_point: EntryPoint::IssueNumber(number),
205        commit,
206        pr: closing_pr,
207        issue: Some(display_issue),
208        release,
209    })))
210}
211
212/// Resolve a file path to `IdentifiedThing`.
213async fn resolve_file(
214    backend: &dyn Backend,
215    branch: &str,
216    path: &str,
217) -> WtgResult<IdentifiedThing> {
218    let file_info = backend.find_file(branch, path).await?;
219    let commit_url = backend.commit_url(&file_info.last_commit.hash);
220
221    // Generate author URLs from emails
222    let author_urls: Vec<Option<String>> = file_info
223        .previous_authors
224        .iter()
225        .map(|(_, _, email)| backend.author_url_from_email(email))
226        .collect();
227
228    let release = backend
229        .find_release_for_commit(
230            &file_info.last_commit.hash,
231            Some(file_info.last_commit.date),
232        )
233        .await;
234
235    Ok(IdentifiedThing::File(Box::new(FileResult {
236        file_info,
237        commit_url,
238        author_urls,
239        release,
240    })))
241}
242
243/// Resolve a tag name to `IdentifiedThing`.
244async fn resolve_tag(backend: &dyn Backend, name: &str) -> WtgResult<IdentifiedThing> {
245    let tag = backend.find_tag(name).await?;
246    let url = backend.tag_url(name);
247    Ok(IdentifiedThing::TagOnly(Box::new(tag), url))
248}