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) = Box::pin(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 = github.map(|gh| gh.commit_url(&commit_info.hash));
104
105 let commit_author_github_url =
107 resolve_commit_author_profile_url(github, &commit_info.author_email, &commit_info.hash)
108 .await;
109
110 let commit_date = commit_info.date_rfc3339();
111 let release =
112 resolve_release_for_commit(git, github, &commit_info.hash, Some(&commit_date)).await;
113
114 IdentifiedThing::Enriched(Box::new(EnrichedInfo {
115 entry_point,
116 commit: Some(commit_info),
117 commit_url,
118 commit_author_github_url,
119 pr: None,
120 issue: None,
121 release,
122 }))
123}
124
125async fn resolve_number(
127 number: u64,
128 git: &GitRepo,
129 github: Option<&GitHubClient>,
130) -> Option<IdentifiedThing> {
131 let gh = github?;
132
133 if let Some(pr_info) = gh.fetch_pr(number).await {
134 if let Some(merge_sha) = &pr_info.merge_commit_sha
135 && let Some(commit_info) = git.find_commit(merge_sha)
136 {
137 let commit_url = Some(gh.commit_url(&commit_info.hash));
138
139 let commit_author_github_url = resolve_commit_author_profile_url(
140 Some(gh),
141 &commit_info.author_email,
142 &commit_info.hash,
143 )
144 .await;
145
146 let commit_date = commit_info.date_rfc3339();
147 let release =
148 resolve_release_for_commit(git, Some(gh), merge_sha, Some(&commit_date)).await;
149
150 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
151 entry_point: EntryPoint::PullRequestNumber(number),
152 commit: Some(commit_info),
153 commit_url,
154 commit_author_github_url,
155 pr: Some(pr_info),
156 issue: None,
157 release,
158 })));
159 }
160
161 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
162 entry_point: EntryPoint::PullRequestNumber(number),
163 commit: None,
164 commit_url: None,
165 commit_author_github_url: None,
166 pr: Some(pr_info),
167 issue: None,
168 release: None,
169 })));
170 }
171
172 if let Some(issue_info) = gh.fetch_issue(number).await {
173 if let Some(&first_pr_number) = issue_info.closing_prs.first()
174 && let Some(pr_info) = gh.fetch_pr(first_pr_number).await
175 {
176 if let Some(merge_sha) = &pr_info.merge_commit_sha
177 && let Some(commit_info) = git.find_commit(merge_sha)
178 {
179 let commit_url = Some(gh.commit_url(&commit_info.hash));
180
181 let commit_author_github_url = resolve_commit_author_profile_url(
182 Some(gh),
183 &commit_info.author_email,
184 &commit_info.hash,
185 )
186 .await;
187
188 let commit_date = commit_info.date_rfc3339();
189 let release =
190 resolve_release_for_commit(git, Some(gh), merge_sha, Some(&commit_date)).await;
191
192 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
193 entry_point: EntryPoint::IssueNumber(number),
194 commit: Some(commit_info),
195 commit_url,
196 commit_author_github_url,
197 pr: Some(pr_info),
198 issue: Some(issue_info),
199 release,
200 })));
201 }
202
203 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
204 entry_point: EntryPoint::IssueNumber(number),
205 commit: None,
206 commit_url: None,
207 commit_author_github_url: None,
208 pr: Some(pr_info),
209 issue: Some(issue_info),
210 release: None,
211 })));
212 }
213
214 return Some(IdentifiedThing::Enriched(Box::new(EnrichedInfo {
215 entry_point: EntryPoint::IssueNumber(number),
216 commit: None,
217 commit_url: None,
218 commit_author_github_url: None,
219 pr: None,
220 issue: Some(issue_info),
221 release: None,
222 })));
223 }
224
225 None
226}
227
228async fn resolve_release_for_commit(
229 git: &GitRepo,
230 github: Option<&GitHubClient>,
231 commit_hash: &str,
232 fallback_since: Option<&str>,
233) -> Option<TagInfo> {
234 let candidates = collect_tag_candidates(git, commit_hash);
235 let has_semver = candidates.iter().any(|candidate| candidate.info.is_semver);
236
237 let targeted_releases = if let Some(gh) = github {
238 let target_names: Vec<_> = if has_semver {
239 candidates
240 .iter()
241 .filter(|candidate| candidate.info.is_semver)
242 .map(|candidate| candidate.info.name.clone())
243 .collect()
244 } else {
245 candidates
246 .iter()
247 .map(|candidate| candidate.info.name.clone())
248 .collect()
249 };
250
251 let mut releases = Vec::new();
252 for tag_name in target_names {
253 if let Some(release) = gh.fetch_release_by_tag(&tag_name).await {
254 releases.push(release);
255 }
256 }
257 releases
258 } else {
259 Vec::new()
260 };
261
262 let fallback_releases = if !candidates.is_empty() {
265 Vec::new()
266 } else if let (Some(gh), Some(since)) = (github, fallback_since) {
267 gh.fetch_releases_since(Some(since)).await
268 } else {
269 Vec::new()
270 };
271
272 resolve_release_from_data(
273 git,
274 candidates,
275 &targeted_releases,
276 if fallback_releases.is_empty() {
277 None
278 } else {
279 Some(&fallback_releases)
280 },
281 commit_hash,
282 has_semver,
283 )
284}
285
286struct TagCandidate {
287 info: TagInfo,
288 timestamp: i64,
289}
290
291fn collect_tag_candidates(git: &GitRepo, commit_hash: &str) -> Vec<TagCandidate> {
292 git.tags_containing_commit(commit_hash)
293 .into_iter()
294 .map(|tag| {
295 let timestamp = git.get_commit_timestamp(&tag.commit_hash);
296 TagCandidate {
297 info: tag,
298 timestamp,
299 }
300 })
301 .collect()
302}
303
304fn resolve_release_from_data(
305 git: &GitRepo,
306 mut candidates: Vec<TagCandidate>,
307 targeted_releases: &[ReleaseInfo],
308 fallback_releases: Option<&[ReleaseInfo]>,
309 commit_hash: &str,
310 had_semver: bool,
311) -> Option<TagInfo> {
312 apply_release_metadata(&mut candidates, targeted_releases);
313
314 let local_best = pick_best_tag(&candidates);
315
316 if had_semver {
317 return local_best;
318 }
319
320 let fallback_best = fallback_releases.and_then(|releases| {
321 let remote_candidates = releases
322 .iter()
323 .filter_map(|release| {
324 git.tag_from_release(release).and_then(|tag| {
325 if git.tag_contains_commit(&tag.commit_hash, commit_hash) {
326 let timestamp = git.get_commit_timestamp(&tag.commit_hash);
327 Some(TagCandidate {
328 info: tag,
329 timestamp,
330 })
331 } else {
332 None
333 }
334 })
335 })
336 .collect::<Vec<_>>();
337
338 pick_best_tag(&remote_candidates)
339 });
340
341 fallback_best.or(local_best)
342}
343
344fn apply_release_metadata(candidates: &mut [TagCandidate], releases: &[ReleaseInfo]) {
345 let release_map: HashMap<&str, &ReleaseInfo> = releases
346 .iter()
347 .map(|release| (release.tag_name.as_str(), release))
348 .collect();
349
350 for candidate in candidates {
351 if let Some(release) = release_map.get(candidate.info.name.as_str()) {
352 candidate.info.is_release = true;
353 candidate.info.release_name = release.name.clone();
354 candidate.info.release_url = Some(release.url.clone());
355 candidate.info.published_at = release.published_at.clone();
356 }
357 }
358}
359
360fn pick_best_tag(candidates: &[TagCandidate]) -> Option<TagInfo> {
361 fn select_with_pred<F>(candidates: &[TagCandidate], predicate: F) -> Option<TagInfo>
362 where
363 F: Fn(&TagCandidate) -> bool,
364 {
365 candidates
366 .iter()
367 .filter(|candidate| predicate(candidate))
368 .min_by_key(|candidate| candidate.timestamp)
369 .map(|candidate| candidate.info.clone())
370 }
371
372 select_with_pred(candidates, |c| c.info.is_release && c.info.is_semver)
373 .or_else(|| select_with_pred(candidates, |c| !c.info.is_release && c.info.is_semver))
374 .or_else(|| select_with_pred(candidates, |c| c.info.is_release && !c.info.is_semver))
375 .or_else(|| select_with_pred(candidates, |c| !c.info.is_release && !c.info.is_semver))
376}
377
378async fn resolve_file(
380 file_info: FileInfo,
381 git: &GitRepo,
382 github: Option<&GitHubClient>,
383) -> IdentifiedThing {
384 let commit_date = file_info.last_commit.date_rfc3339();
385 let release =
386 resolve_release_for_commit(git, github, &file_info.last_commit.hash, Some(&commit_date))
387 .await;
388
389 let (commit_url, author_urls) = if let Some(gh) = github {
390 let url = Some(gh.commit_url(&file_info.last_commit.hash));
391 let urls: Vec<Option<String>> = file_info
392 .previous_authors
393 .iter()
394 .map(|(_, _, email)| {
395 extract_github_username(email).map(|u| GitHubClient::profile_url(&u))
396 })
397 .collect();
398 (url, urls)
399 } else {
400 (None, vec![])
401 };
402
403 IdentifiedThing::File(Box::new(FileResult {
404 file_info,
405 commit_url,
406 author_urls,
407 release,
408 }))
409}
410
411async fn resolve_commit_author_profile_url(
412 github: Option<&GitHubClient>,
413 email: &str,
414 commit_hash: &str,
415) -> Option<String> {
416 if let Some(username) = extract_github_username(email) {
417 return Some(GitHubClient::profile_url(&username));
418 }
419
420 let gh = github?;
421 gh.fetch_commit_author(commit_hash)
422 .await
423 .map(|login| GitHubClient::profile_url(&login))
424}
425
426fn extract_github_username(email: &str) -> Option<String> {
428 if email.ends_with("@users.noreply.github.com") {
431 let parts: Vec<&str> = email.split('@').collect();
432 if let Some(user_part) = parts.first() {
433 if let Some(username) = user_part.split('+').next_back() {
435 return Some(username.to_string());
436 }
437 }
438 }
439
440 None
441}