1use 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#[derive(Debug, Clone)]
22pub enum EntryPoint {
23 Commit(String), IssueNumber(u64), PullRequestNumber(u64), FilePath { branch: String, path: String }, Tag(String), }
29
30#[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 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#[derive(Debug, Clone)]
61pub struct EnrichedInfo {
62 pub entry_point: EntryPoint,
63
64 pub commit: Option<CommitInfo>,
66
67 pub pr: Option<PullRequestInfo>,
69
70 pub issue: Option<IssueInfo>,
72
73 pub release: Option<TagInfo>,
75}
76
77#[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#[derive(Debug, Clone)]
88pub enum ChangesSource {
89 GitHubRelease,
91 Changelog,
93 Commits { previous_tag: String },
95}
96
97#[derive(Debug, Clone)]
99pub struct TagResult {
100 pub tag_info: TagInfo,
101 pub github_url: Option<String>,
102 pub changes: Option<String>,
104 pub changes_source: Option<ChangesSource>,
106 pub truncated_lines: usize,
108 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
119pub async fn resolve(
128 backend: &dyn Backend,
129 query: &Query,
130 filter: &ReleaseFilter,
131) -> WtgResult<IdentifiedThing> {
132 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 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
160async 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
181async 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
212async 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 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 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 let commit = match effective_backend.find_commit(merge_sha).await {
236 Ok(c) => Some(effective_backend.enrich_commit(c).await),
237 Err(e) => {
238 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 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
288async 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 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
321async 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 let best_source = match (release_body, changelog_content) {
335 (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 (Some(release), None) if !release.trim().is_empty() => {
345 Some((release.to_string(), ChangesSource::GitHubRelease))
346 }
347 (None, Some(changelog)) if !changelog.trim().is_empty() => {
349 Some((changelog.to_string(), ChangesSource::Changelog))
350 }
351 _ => 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 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 (None, None, 0, Vec::new())
384}
385
386async fn resolve_tag(backend: &dyn Backend, name: &str) -> WtgResult<IdentifiedThing> {
388 let tag = backend.find_tag(name).await?;
389
390 let changelog_content = backend.changelog_for_version(name).await;
392
393 let release_body = if tag.is_release {
395 backend.fetch_release_body(name).await
396 } else {
397 None
398 };
399
400 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}