1use crate::error::{Result, WtgError};
2use crate::git::{CommitInfo, FileInfo, GitRepo, TagInfo};
3use crate::github::{GitHubClient, IssueInfo, PullRequestInfo, ReleaseInfo};
4use std::collections::HashMap;
5use std::sync::Arc;
6
7#[derive(Debug, Clone)]
9pub enum EntryPoint {
10 Commit(String), IssueNumber(u64), PullRequestNumber(u64), FilePath(String), Tag(String), }
16
17#[derive(Debug, Clone)]
19pub struct EnrichedInfo {
20 pub entry_point: EntryPoint,
21
22 pub commit: Option<CommitInfo>,
24 pub commit_url: Option<String>,
25 pub commit_author_github_url: Option<String>,
26
27 pub pr: Option<PullRequestInfo>,
29
30 pub issue: Option<IssueInfo>,
32
33 pub release: Option<TagInfo>,
35}
36
37#[derive(Debug, Clone)]
39pub struct FileResult {
40 pub file_info: FileInfo,
41 pub commit_url: Option<String>,
42 pub author_urls: Vec<Option<String>>,
43 pub release: Option<TagInfo>,
44}
45
46#[derive(Debug, Clone)]
47pub enum IdentifiedThing {
48 Enriched(Box<EnrichedInfo>),
49 File(Box<FileResult>),
50 TagOnly(Box<TagInfo>, Option<String>), }
52
53pub async fn identify(input: &str, git: GitRepo) -> Result<IdentifiedThing> {
54 let github = git
55 .github_remote()
56 .map(|(owner, repo)| Arc::new(GitHubClient::new(owner, repo)));
57
58 if let Some(commit_info) = git.find_commit(input) {
60 return Ok(resolve_commit(
61 EntryPoint::Commit(input.to_string()),
62 commit_info,
63 &git,
64 github.as_deref(),
65 )
66 .await);
67 }
68
69 let number_str = input.strip_prefix('#').unwrap_or(input);
71 if let Ok(number) = number_str.parse::<u64>()
72 && let Some(result) = resolve_number(number, &git, github.as_deref()).await
73 {
74 return Ok(result);
75 }
76
77 if let Some(file_info) = git.find_file(input) {
79 return Ok(resolve_file(file_info, &git, github.as_deref()).await);
80 }
81
82 let tags = git.get_tags();
84 if let Some(tag_info) = tags.iter().find(|t| t.name == input) {
85 let github_url = github.as_deref().map(|gh| gh.tag_url(&tag_info.name));
86 return Ok(IdentifiedThing::TagOnly(
87 Box::new(tag_info.clone()),
88 github_url,
89 ));
90 }
91
92 Err(WtgError::NotFound(input.to_string()))
94}
95
96async fn resolve_commit(
98 entry_point: EntryPoint,
99 commit_info: CommitInfo,
100 git: &GitRepo,
101 github: Option<&GitHubClient>,
102) -> IdentifiedThing {
103 let (commit_url, commit_author_github_url) =
104 resolve_commit_urls(github, &commit_info.author_email, &commit_info.hash).await;
105
106 let commit_date = commit_info.date_rfc3339();
107 let release =
108 resolve_release_for_commit(git, github, &commit_info.hash, Some(&commit_date)).await;
109
110 IdentifiedThing::Enriched(Box::new(EnrichedInfo {
111 entry_point,
112 commit: Some(commit_info),
113 commit_url,
114 commit_author_github_url,
115 pr: None,
116 issue: None,
117 release,
118 }))
119}
120
121async fn resolve_number(
123 number: u64,
124 git: &GitRepo,
125 github: Option<&GitHubClient>,
126) -> Option<IdentifiedThing> {
127 let gh = github?;
128
129 if let Some(pr_info) = Box::pin(gh.fetch_pr(number)).await {
130 if let Some(merge_sha) = &pr_info.merge_commit_sha
131 && let Some(commit_info) = git.find_commit(merge_sha)
132 {
133 let (commit_url, commit_author_github_url) =
134 resolve_commit_urls(Some(gh), &commit_info.author_email, &commit_info.hash).await;
135
136 let commit_date = commit_info.date_rfc3339();
137 let release =
138 resolve_release_for_commit(git, Some(gh), merge_sha, Some(&commit_date)).await;
139
140 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
141 entry_point: EntryPoint::PullRequestNumber(number),
142 commit: Some(commit_info),
143 commit_url,
144 commit_author_github_url,
145 pr: Some(pr_info),
146 issue: None,
147 release,
148 })));
149 }
150
151 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
152 entry_point: EntryPoint::PullRequestNumber(number),
153 commit: None,
154 commit_url: None,
155 commit_author_github_url: None,
156 pr: Some(pr_info),
157 issue: None,
158 release: None,
159 })));
160 }
161
162 if let Some(issue_info) = Box::pin(gh.fetch_issue(number)).await {
163 if let Some(&first_pr_number) = issue_info.closing_prs.first()
164 && let Some(pr_info) = Box::pin(gh.fetch_pr(first_pr_number)).await
165 {
166 if let Some(merge_sha) = &pr_info.merge_commit_sha
167 && let Some(commit_info) = git.find_commit(merge_sha)
168 {
169 let (commit_url, commit_author_github_url) =
170 resolve_commit_urls(Some(gh), &commit_info.author_email, &commit_info.hash)
171 .await;
172
173 let commit_date = commit_info.date_rfc3339();
174 let release =
175 resolve_release_for_commit(git, Some(gh), merge_sha, Some(&commit_date)).await;
176
177 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
178 entry_point: EntryPoint::IssueNumber(number),
179 commit: Some(commit_info),
180 commit_url,
181 commit_author_github_url,
182 pr: Some(pr_info),
183 issue: Some(issue_info),
184 release,
185 })));
186 }
187
188 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
189 entry_point: EntryPoint::IssueNumber(number),
190 commit: None,
191 commit_url: None,
192 commit_author_github_url: None,
193 pr: Some(pr_info),
194 issue: Some(issue_info),
195 release: None,
196 })));
197 }
198
199 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
200 entry_point: EntryPoint::IssueNumber(number),
201 commit: None,
202 commit_url: None,
203 commit_author_github_url: None,
204 pr: None,
205 issue: Some(issue_info),
206 release: None,
207 })));
208 }
209
210 None
211}
212
213async fn resolve_release_for_commit(
214 git: &GitRepo,
215 github: Option<&GitHubClient>,
216 commit_hash: &str,
217 fallback_since: Option<&str>,
218) -> Option<TagInfo> {
219 let candidates = collect_tag_candidates(git, commit_hash);
220 let has_semver = candidates.iter().any(|candidate| candidate.info.is_semver);
221
222 let targeted_releases = if let Some(gh) = github {
223 let target_names: Vec<_> = if has_semver {
224 candidates
225 .iter()
226 .filter(|candidate| candidate.info.is_semver)
227 .map(|candidate| candidate.info.name.clone())
228 .collect()
229 } else {
230 candidates
231 .iter()
232 .map(|candidate| candidate.info.name.clone())
233 .collect()
234 };
235
236 let mut releases = Vec::new();
237 for tag_name in target_names {
238 if let Some(release) = gh.fetch_release_by_tag(&tag_name).await {
239 releases.push(release);
240 }
241 }
242 releases
243 } else {
244 Vec::new()
245 };
246
247 let fallback_releases = if !candidates.is_empty() {
250 Vec::new()
251 } else if let (Some(gh), Some(since)) = (github, fallback_since) {
252 gh.fetch_releases_since(Some(since)).await
253 } else {
254 Vec::new()
255 };
256
257 resolve_release_from_data(
258 git,
259 candidates,
260 &targeted_releases,
261 if fallback_releases.is_empty() {
262 None
263 } else {
264 Some(&fallback_releases)
265 },
266 commit_hash,
267 has_semver,
268 )
269}
270
271struct TagCandidate {
272 info: TagInfo,
273 timestamp: i64,
274}
275
276fn collect_tag_candidates(git: &GitRepo, commit_hash: &str) -> Vec<TagCandidate> {
277 git.tags_containing_commit(commit_hash)
278 .into_iter()
279 .map(|tag| {
280 let timestamp = git.get_commit_timestamp(&tag.commit_hash);
281 TagCandidate {
282 info: tag,
283 timestamp,
284 }
285 })
286 .collect()
287}
288
289fn resolve_release_from_data(
290 git: &GitRepo,
291 mut candidates: Vec<TagCandidate>,
292 targeted_releases: &[ReleaseInfo],
293 fallback_releases: Option<&[ReleaseInfo]>,
294 commit_hash: &str,
295 had_semver: bool,
296) -> Option<TagInfo> {
297 apply_release_metadata(&mut candidates, targeted_releases);
298
299 let local_best = pick_best_tag(&candidates);
300
301 if had_semver {
302 return local_best;
303 }
304
305 let fallback_best = fallback_releases.and_then(|releases| {
306 let remote_candidates = releases
307 .iter()
308 .filter_map(|release| {
309 git.tag_from_release(release).and_then(|tag| {
310 if git.tag_contains_commit(&tag.commit_hash, commit_hash) {
311 let timestamp = git.get_commit_timestamp(&tag.commit_hash);
312 Some(TagCandidate {
313 info: tag,
314 timestamp,
315 })
316 } else {
317 None
318 }
319 })
320 })
321 .collect::<Vec<_>>();
322
323 pick_best_tag(&remote_candidates)
324 });
325
326 fallback_best.or(local_best)
327}
328
329fn apply_release_metadata(candidates: &mut [TagCandidate], releases: &[ReleaseInfo]) {
330 let release_map: HashMap<&str, &ReleaseInfo> = releases
331 .iter()
332 .map(|release| (release.tag_name.as_str(), release))
333 .collect();
334
335 for candidate in candidates {
336 if let Some(release) = release_map.get(candidate.info.name.as_str()) {
337 candidate.info.is_release = true;
338 candidate.info.release_name = release.name.clone();
339 candidate.info.release_url = Some(release.url.clone());
340 candidate.info.published_at = release.published_at.clone();
341 }
342 }
343}
344
345fn pick_best_tag(candidates: &[TagCandidate]) -> Option<TagInfo> {
346 fn select_with_pred<F>(candidates: &[TagCandidate], predicate: F) -> Option<TagInfo>
347 where
348 F: Fn(&TagCandidate) -> bool,
349 {
350 candidates
351 .iter()
352 .filter(|candidate| predicate(candidate))
353 .min_by_key(|candidate| candidate.timestamp)
354 .map(|candidate| candidate.info.clone())
355 }
356
357 select_with_pred(candidates, |c| c.info.is_release && c.info.is_semver)
358 .or_else(|| select_with_pred(candidates, |c| !c.info.is_release && c.info.is_semver))
359 .or_else(|| select_with_pred(candidates, |c| c.info.is_release && !c.info.is_semver))
360 .or_else(|| select_with_pred(candidates, |c| !c.info.is_release && !c.info.is_semver))
361}
362
363async fn resolve_file(
365 file_info: FileInfo,
366 git: &GitRepo,
367 github: Option<&GitHubClient>,
368) -> IdentifiedThing {
369 let commit_date = file_info.last_commit.date_rfc3339();
370 let release =
371 resolve_release_for_commit(git, github, &file_info.last_commit.hash, Some(&commit_date))
372 .await;
373
374 let (commit_url, author_urls) = if let Some(gh) = github {
375 let url = Some(gh.commit_url(&file_info.last_commit.hash));
376 let urls: Vec<Option<String>> = file_info
377 .previous_authors
378 .iter()
379 .map(|(_, _, email)| {
380 extract_github_username(email).map(|u| GitHubClient::profile_url(&u))
381 })
382 .collect();
383 (url, urls)
384 } else {
385 (None, vec![])
386 };
387
388 IdentifiedThing::File(Box::new(FileResult {
389 file_info,
390 commit_url,
391 author_urls,
392 release,
393 }))
394}
395
396async fn resolve_commit_urls(
399 github: Option<&GitHubClient>,
400 email: &str,
401 commit_hash: &str,
402) -> (Option<String>, Option<String>) {
403 if let Some(username) = extract_github_username(email) {
405 let commit_url = github.map(|gh| gh.commit_url(commit_hash));
407 return (commit_url, Some(GitHubClient::profile_url(&username)));
408 }
409
410 if let Some(gh) = github {
412 if let Some((_hash, commit_url, author_info)) = gh.fetch_commit_info(commit_hash).await {
413 let author_url = author_info.map(|(_login, url)| url);
414 return (Some(commit_url), author_url);
415 }
416 return (Some(gh.commit_url(commit_hash)), None);
418 }
419
420 (None, None)
421}
422
423fn extract_github_username(email: &str) -> Option<String> {
425 if email.ends_with("@users.noreply.github.com") {
428 let parts: Vec<&str> = email.split('@').collect();
429 if let Some(user_part) = parts.first() {
430 if let Some(username) = user_part.split('+').next_back() {
432 return Some(username.to_string());
433 }
434 }
435 }
436
437 None
438}