1use anyhow::{Context, Result};
8use percent_encoding::{NON_ALPHANUMERIC, percent_encode};
9use std::collections::HashSet;
10use tracing::{debug, instrument};
11
12use crate::ai::types::PrSummary;
13
14#[derive(serde::Deserialize)]
15struct RefResponse {
16 object: RefObject,
17}
18
19#[derive(serde::Deserialize)]
20struct RefObject {
21 sha: String,
22 #[serde(rename = "type")]
23 r#type: String,
24}
25
26#[derive(serde::Deserialize)]
27struct TagObject {
28 object: GitObject,
29}
30
31#[derive(serde::Deserialize)]
32struct GitObject {
33 sha: String,
34}
35
36#[instrument(skip(client))]
50pub async fn fetch_prs_between_refs(
51 client: &octocrab::Octocrab,
52 owner: &str,
53 repo: &str,
54 from_ref: &str,
55 to_ref: &str,
56) -> Result<Vec<PrSummary>> {
57 let from_sha = resolve_ref_to_sha(client, owner, repo, from_ref).await?;
59 let to_sha = resolve_ref_to_sha(client, owner, repo, to_ref).await?;
60
61 let commit_shas = fetch_commits_between_refs(client, owner, repo, &from_sha, &to_sha).await?;
63
64 let mut prs = Vec::new();
66 let mut page = 1u32;
67
68 loop {
69 let pulls = client
70 .pulls(owner, repo)
71 .list()
72 .state(octocrab::params::State::Closed)
73 .per_page(100)
74 .page(page)
75 .send()
76 .await
77 .context("Failed to fetch PRs from GitHub")?;
78
79 if pulls.items.is_empty() {
80 break;
81 }
82
83 for pr in &pulls.items {
84 if pr.merged_at.is_none() {
86 continue;
87 }
88
89 if let Some(merge_commit) = &pr.merge_commit_sha
91 && commit_shas.contains(merge_commit)
92 {
93 prs.push(PrSummary {
94 number: pr.number,
95 title: pr.title.clone().unwrap_or_default(),
96 body: pr.body.clone().unwrap_or_default(),
97 author: pr
98 .user
99 .as_ref()
100 .map_or_else(|| "unknown".to_string(), |u| u.login.clone()),
101 merged_at: pr.merged_at.map(|dt| dt.to_rfc3339()),
102 });
103 }
104 }
105
106 if pulls.items.len() < 100 {
108 break;
109 }
110
111 page += 1;
112 }
113
114 Ok(prs)
115}
116
117#[instrument(skip(client))]
130async fn resolve_ref_to_sha(
131 client: &octocrab::Octocrab,
132 owner: &str,
133 repo: &str,
134 ref_name: &str,
135) -> Result<String> {
136 match super::graphql::resolve_tag_to_commit_sha(client, owner, repo, ref_name).await? {
138 Some(sha) => Ok(sha),
139 None => {
140 match resolve_tag_via_rest(client, owner, repo, ref_name).await {
143 Ok(sha) => Ok(sha),
144 Err(e) => {
145 debug!(
147 error = ?e,
148 tag = %ref_name,
149 "REST API fallback failed, treating input as literal SHA"
150 );
151 Ok(ref_name.to_string())
152 }
153 }
154 }
155 }
156}
157
158#[instrument(skip(client))]
171async fn resolve_tag_via_rest(
172 client: &octocrab::Octocrab,
173 owner: &str,
174 repo: &str,
175 tag_name: &str,
176) -> Result<String> {
177 let encoded_tag = percent_encode(tag_name.as_bytes(), NON_ALPHANUMERIC).to_string();
179 let route = format!("/repos/{owner}/{repo}/git/refs/tags/{encoded_tag}");
180
181 let response: RefResponse = client
182 .get::<RefResponse, &str, ()>(&route, None::<&()>)
183 .await
184 .context(format!("Failed to resolve tag {tag_name} via REST API"))?;
185
186 if response.object.r#type == "tag" {
188 let tag_route = format!("/repos/{owner}/{repo}/git/tags/{}", response.object.sha);
191 let tag_obj: TagObject = client
192 .get::<TagObject, &str, ()>(&tag_route, None::<&()>)
193 .await
194 .context(format!(
195 "Failed to dereference annotated tag {tag_name} to commit SHA"
196 ))?;
197
198 Ok(tag_obj.object.sha)
199 } else {
200 Ok(response.object.sha)
202 }
203}
204
205#[instrument(skip(client))]
219async fn fetch_commits_between_refs(
220 client: &octocrab::Octocrab,
221 owner: &str,
222 repo: &str,
223 from_sha: &str,
224 to_sha: &str,
225) -> Result<HashSet<String>> {
226 #[derive(serde::Deserialize)]
227 struct CompareResponse {
228 commits: Vec<CommitInfo>,
229 }
230
231 #[derive(serde::Deserialize)]
232 struct CommitInfo {
233 sha: String,
234 }
235
236 let mut commit_shas = HashSet::new();
237 let mut page = 1u32;
238
239 loop {
240 let route =
243 format!("/repos/{owner}/{repo}/compare/{from_sha}...{to_sha}?per_page=100&page={page}");
244
245 let comparison: CompareResponse = client
246 .get(&route, None::<&()>)
247 .await
248 .context("Failed to compare commits")?;
249
250 let count = comparison.commits.len();
251 commit_shas.extend(comparison.commits.into_iter().map(|c| c.sha));
252
253 if count < 100 {
255 break;
256 }
257
258 page += 1;
259 }
260
261 Ok(commit_shas)
262}
263
264#[instrument(skip(client))]
276pub async fn get_latest_tag(
277 client: &octocrab::Octocrab,
278 owner: &str,
279 repo: &str,
280) -> Result<Option<(String, String)>> {
281 let releases = client
282 .repos(owner, repo)
283 .releases()
284 .list()
285 .per_page(1)
286 .send()
287 .await
288 .context("Failed to fetch releases from GitHub")?;
289
290 if releases.items.is_empty() {
291 return Ok(None);
292 }
293
294 let latest = &releases.items[0];
295 let tag_name = latest.tag_name.clone();
296
297 match super::graphql::resolve_tag_to_commit_sha(client, owner, repo, &tag_name).await? {
299 Some(sha) => Ok(Some((tag_name, sha))),
300 None => anyhow::bail!("Failed to resolve tag {tag_name} to commit SHA"),
301 }
302}
303
304#[instrument(skip(client))]
326pub async fn get_previous_tag(
327 client: &octocrab::Octocrab,
328 owner: &str,
329 repo: &str,
330 target_tag: &str,
331) -> Result<Option<(String, String)>> {
332 #[derive(serde::Deserialize)]
333 struct TagInfo {
334 name: String,
335 commit: CommitRef,
336 }
337
338 #[derive(serde::Deserialize)]
339 struct CommitRef {
340 sha: String,
341 }
342
343 #[derive(serde::Deserialize)]
344 struct CommitDetail {
345 commit: CommitData,
346 }
347
348 #[derive(serde::Deserialize)]
349 struct CommitData {
350 author: CommitAuthor,
351 }
352
353 #[derive(serde::Deserialize)]
354 struct CommitAuthor {
355 date: String,
356 }
357
358 let mut all_tags = Vec::new();
360 let mut page = 1u32;
361
362 loop {
363 let route = format!("/repos/{owner}/{repo}/tags?per_page=100&page={page}");
364 let tags: Vec<TagInfo> = client
365 .get(&route, None::<&()>)
366 .await
367 .context("Failed to fetch tags from GitHub")?;
368
369 if tags.is_empty() {
370 break;
371 }
372
373 all_tags.extend(tags);
374
375 if all_tags.len() < (page as usize * 100) {
376 break;
377 }
378
379 page += 1;
380 }
381
382 if all_tags.is_empty() {
383 return Ok(None);
384 }
385
386 let mut tags_with_timestamps = Vec::new();
388
389 for tag in all_tags {
390 let commit_route = format!("/repos/{owner}/{repo}/commits/{}", tag.commit.sha);
392 match client
393 .get::<CommitDetail, &str, ()>(&commit_route, None::<&()>)
394 .await
395 {
396 Ok(commit_detail) => {
397 tags_with_timestamps.push((
398 tag.name.clone(),
399 tag.commit.sha.clone(),
400 commit_detail.commit.author.date.clone(),
401 ));
402 }
403 Err(e) => {
404 debug!(
405 tag = %tag.name,
406 error = ?e,
407 "Failed to resolve tag to commit timestamp, skipping"
408 );
409 }
410 }
411 }
412
413 tags_with_timestamps.sort_by(|a, b| a.2.cmp(&b.2));
415
416 for i in 0..tags_with_timestamps.len() {
418 if tags_with_timestamps[i].0 == target_tag {
419 if i > 0 {
420 let prev = &tags_with_timestamps[i - 1];
421 return Ok(Some((prev.0.clone(), prev.1.clone())));
422 }
423 return Ok(None);
425 }
426 }
427
428 debug!(target_tag = %target_tag, "Target tag not found in repository");
430 Ok(None)
431}
432
433#[instrument(skip(client))]
448pub async fn get_root_commit(
449 client: &octocrab::Octocrab,
450 owner: &str,
451 repo: &str,
452) -> Result<String> {
453 const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
455
456 let route = format!("/repos/{owner}/{repo}/compare/{EMPTY_TREE_SHA}...HEAD");
460
461 #[derive(serde::Deserialize)]
462 struct CompareResponse {
463 commits: Vec<CommitInfo>,
464 }
465
466 #[derive(serde::Deserialize)]
467 struct CommitInfo {
468 sha: String,
469 }
470
471 let comparison: CompareResponse = client
472 .get(&route, None::<&()>)
473 .await
474 .context("Failed to fetch commits from GitHub")?;
475
476 if comparison.commits.is_empty() {
477 anyhow::bail!("Repository has no commits");
478 }
479
480 let root_commit = &comparison.commits[0];
482 Ok(root_commit.sha.clone())
483}
484
485#[must_use]
497pub fn parse_tag_reference(tag: &str) -> String {
498 let version = tag
500 .strip_prefix("release-")
501 .or_else(|| tag.strip_prefix("v-"))
502 .or_else(|| tag.strip_prefix('v'))
503 .unwrap_or(tag);
504
505 version.to_string()
506}
507
508#[instrument(skip(client))]
525pub async fn post_release_notes(
526 client: &octocrab::Octocrab,
527 owner: &str,
528 repo: &str,
529 tag: &str,
530 body: &str,
531) -> Result<String> {
532 let repo_handle = client.repos(owner, repo);
533 let releases = repo_handle.releases();
534
535 if let Ok(existing_release) = releases.get_by_tag(tag).await {
537 let updated = releases
539 .update(existing_release.id.0)
540 .body(body)
541 .send()
542 .await
543 .context(format!("Failed to update release for tag {tag}"))?;
544
545 Ok(updated.html_url.to_string())
546 } else {
547 let created = releases
549 .create(tag)
550 .body(body)
551 .send()
552 .await
553 .context(format!("Failed to create release for tag {tag}"))?;
554
555 Ok(created.html_url.to_string())
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 #[test]
564 fn test_parse_tag_reference_v_prefix() {
565 assert_eq!(parse_tag_reference("v1.0.0"), "1.0.0");
566 }
567
568 #[test]
569 fn test_parse_tag_reference_release_prefix() {
570 assert_eq!(parse_tag_reference("release-1.0.0"), "1.0.0");
571 }
572
573 #[test]
574 fn test_parse_tag_reference_v_dash_prefix() {
575 assert_eq!(parse_tag_reference("v-1.0.0"), "1.0.0");
576 }
577
578 #[test]
579 fn test_parse_tag_reference_no_prefix() {
580 assert_eq!(parse_tag_reference("1.0.0"), "1.0.0");
581 }
582
583 #[test]
588 fn test_get_previous_tag_logic_no_tags() {
589 }
592
593 #[test]
594 fn test_get_previous_tag_logic_single_tag() {
595 }
598
599 #[test]
600 fn test_get_previous_tag_logic_multiple_tags() {
601 }
605
606 #[test]
607 fn test_get_previous_tag_logic_recreated_tag() {
608 }
612}