use anyhow::{Context, Result};
use percent_encoding::{NON_ALPHANUMERIC, percent_encode};
use std::collections::HashSet;
use tracing::{debug, instrument};
use crate::ai::types::PrSummary;
#[derive(serde::Deserialize)]
struct RefResponse {
object: RefObject,
}
#[derive(serde::Deserialize)]
struct RefObject {
sha: String,
#[serde(rename = "type")]
r#type: String,
}
#[derive(serde::Deserialize)]
struct TagObject {
object: GitObject,
}
#[derive(serde::Deserialize)]
struct GitObject {
sha: String,
}
#[instrument(skip(client))]
pub async fn fetch_prs_between_refs(
client: &octocrab::Octocrab,
owner: &str,
repo: &str,
from_ref: &str,
to_ref: &str,
) -> Result<Vec<PrSummary>> {
let from_sha = resolve_ref_to_sha(client, owner, repo, from_ref).await?;
let to_sha = resolve_ref_to_sha(client, owner, repo, to_ref).await?;
let commit_shas = fetch_commits_between_refs(client, owner, repo, &from_sha, &to_sha).await?;
let mut prs = Vec::new();
let mut page = 1u32;
loop {
let pulls = client
.pulls(owner, repo)
.list()
.state(octocrab::params::State::Closed)
.per_page(100)
.page(page)
.send()
.await
.context("Failed to fetch PRs from GitHub")?;
if pulls.items.is_empty() {
break;
}
for pr in &pulls.items {
if pr.merged_at.is_none() {
continue;
}
if let Some(merge_commit) = &pr.merge_commit_sha
&& commit_shas.contains(merge_commit)
{
prs.push(PrSummary {
number: pr.number,
title: pr.title.clone().unwrap_or_default(),
body: pr.body.clone().unwrap_or_default(),
author: pr
.user
.as_ref()
.map_or_else(|| "unknown".to_string(), |u| u.login.clone()),
merged_at: pr.merged_at.map(|dt| dt.to_rfc3339()),
});
}
}
if pulls.items.len() < 100 {
break;
}
page += 1;
}
Ok(prs)
}
#[instrument(skip(client))]
async fn resolve_ref_to_sha(
client: &octocrab::Octocrab,
owner: &str,
repo: &str,
ref_name: &str,
) -> Result<String> {
match super::graphql::resolve_tag_to_commit_sha(client, owner, repo, ref_name).await? {
Some(sha) => Ok(sha),
None => {
match resolve_tag_via_rest(client, owner, repo, ref_name).await {
Ok(sha) => Ok(sha),
Err(e) => {
debug!(
error = ?e,
tag = %ref_name,
"REST API fallback failed, treating input as literal SHA"
);
Ok(ref_name.to_string())
}
}
}
}
}
#[instrument(skip(client))]
async fn resolve_tag_via_rest(
client: &octocrab::Octocrab,
owner: &str,
repo: &str,
tag_name: &str,
) -> Result<String> {
let encoded_tag = percent_encode(tag_name.as_bytes(), NON_ALPHANUMERIC).to_string();
let route = format!("/repos/{owner}/{repo}/git/refs/tags/{encoded_tag}");
let response: RefResponse = client
.get::<RefResponse, &str, ()>(&route, None::<&()>)
.await
.context(format!("Failed to resolve tag {tag_name} via REST API"))?;
if response.object.r#type == "tag" {
let tag_route = format!("/repos/{owner}/{repo}/git/tags/{}", response.object.sha);
let tag_obj: TagObject = client
.get::<TagObject, &str, ()>(&tag_route, None::<&()>)
.await
.context(format!(
"Failed to dereference annotated tag {tag_name} to commit SHA"
))?;
Ok(tag_obj.object.sha)
} else {
Ok(response.object.sha)
}
}
#[instrument(skip(client))]
async fn fetch_commits_between_refs(
client: &octocrab::Octocrab,
owner: &str,
repo: &str,
from_sha: &str,
to_sha: &str,
) -> Result<HashSet<String>> {
#[derive(serde::Deserialize)]
struct CompareResponse {
commits: Vec<CommitInfo>,
}
#[derive(serde::Deserialize)]
struct CommitInfo {
sha: String,
}
let mut commit_shas = HashSet::new();
let mut page = 1u32;
loop {
let route =
format!("/repos/{owner}/{repo}/compare/{from_sha}...{to_sha}?per_page=100&page={page}");
let comparison: CompareResponse = client
.get(&route, None::<&()>)
.await
.context("Failed to compare commits")?;
let count = comparison.commits.len();
commit_shas.extend(comparison.commits.into_iter().map(|c| c.sha));
if count < 100 {
break;
}
page += 1;
}
Ok(commit_shas)
}
#[instrument(skip(client))]
pub async fn get_latest_tag(
client: &octocrab::Octocrab,
owner: &str,
repo: &str,
) -> Result<Option<(String, String)>> {
let releases = client
.repos(owner, repo)
.releases()
.list()
.per_page(1)
.send()
.await
.context("Failed to fetch releases from GitHub")?;
if releases.items.is_empty() {
return Ok(None);
}
let latest = &releases.items[0];
let tag_name = latest.tag_name.clone();
match super::graphql::resolve_tag_to_commit_sha(client, owner, repo, &tag_name).await? {
Some(sha) => Ok(Some((tag_name, sha))),
None => anyhow::bail!("Failed to resolve tag {tag_name} to commit SHA"),
}
}
#[instrument(skip(client))]
pub async fn get_previous_tag(
client: &octocrab::Octocrab,
owner: &str,
repo: &str,
target_tag: &str,
) -> Result<Option<(String, String)>> {
#[derive(serde::Deserialize)]
struct TagInfo {
name: String,
commit: CommitRef,
}
#[derive(serde::Deserialize)]
struct CommitRef {
sha: String,
}
#[derive(serde::Deserialize)]
struct CommitDetail {
commit: CommitData,
}
#[derive(serde::Deserialize)]
struct CommitData {
author: CommitAuthor,
}
#[derive(serde::Deserialize)]
struct CommitAuthor {
date: String,
}
let mut all_tags = Vec::new();
let mut page = 1u32;
loop {
let route = format!("/repos/{owner}/{repo}/tags?per_page=100&page={page}");
let tags: Vec<TagInfo> = client
.get(&route, None::<&()>)
.await
.context("Failed to fetch tags from GitHub")?;
if tags.is_empty() {
break;
}
all_tags.extend(tags);
if all_tags.len() < (page as usize * 100) {
break;
}
page += 1;
}
if all_tags.is_empty() {
return Ok(None);
}
let mut tags_with_timestamps = Vec::new();
for tag in all_tags {
let commit_route = format!("/repos/{owner}/{repo}/commits/{}", tag.commit.sha);
match client
.get::<CommitDetail, &str, ()>(&commit_route, None::<&()>)
.await
{
Ok(commit_detail) => {
tags_with_timestamps.push((
tag.name.clone(),
tag.commit.sha.clone(),
commit_detail.commit.author.date.clone(),
));
}
Err(e) => {
debug!(
tag = %tag.name,
error = ?e,
"Failed to resolve tag to commit timestamp, skipping"
);
}
}
}
tags_with_timestamps.sort_by(|a, b| a.2.cmp(&b.2));
for i in 0..tags_with_timestamps.len() {
if tags_with_timestamps[i].0 == target_tag {
if i > 0 {
let prev = &tags_with_timestamps[i - 1];
return Ok(Some((prev.0.clone(), prev.1.clone())));
}
return Ok(None);
}
}
debug!(target_tag = %target_tag, "Target tag not found in repository");
Ok(None)
}
#[instrument(skip(client))]
pub async fn get_root_commit(
client: &octocrab::Octocrab,
owner: &str,
repo: &str,
) -> Result<String> {
const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
let route = format!("/repos/{owner}/{repo}/compare/{EMPTY_TREE_SHA}...HEAD");
#[derive(serde::Deserialize)]
struct CompareResponse {
commits: Vec<CommitInfo>,
}
#[derive(serde::Deserialize)]
struct CommitInfo {
sha: String,
}
let comparison: CompareResponse = client
.get(&route, None::<&()>)
.await
.context("Failed to fetch commits from GitHub")?;
if comparison.commits.is_empty() {
anyhow::bail!("Repository has no commits");
}
let root_commit = &comparison.commits[0];
Ok(root_commit.sha.clone())
}
#[must_use]
pub fn parse_tag_reference(tag: &str) -> String {
let version = tag
.strip_prefix("release-")
.or_else(|| tag.strip_prefix("v-"))
.or_else(|| tag.strip_prefix('v'))
.unwrap_or(tag);
version.to_string()
}
#[instrument(skip(client))]
pub async fn post_release_notes(
client: &octocrab::Octocrab,
owner: &str,
repo: &str,
tag: &str,
body: &str,
) -> Result<String> {
let repo_handle = client.repos(owner, repo);
let releases = repo_handle.releases();
if let Ok(existing_release) = releases.get_by_tag(tag).await {
let updated = releases
.update(existing_release.id.0)
.body(body)
.send()
.await
.context(format!("Failed to update release for tag {tag}"))?;
Ok(updated.html_url.to_string())
} else {
let created = releases
.create(tag)
.body(body)
.send()
.await
.context(format!("Failed to create release for tag {tag}"))?;
Ok(created.html_url.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_tag_reference_v_prefix() {
assert_eq!(parse_tag_reference("v1.0.0"), "1.0.0");
}
#[test]
fn test_parse_tag_reference_release_prefix() {
assert_eq!(parse_tag_reference("release-1.0.0"), "1.0.0");
}
#[test]
fn test_parse_tag_reference_v_dash_prefix() {
assert_eq!(parse_tag_reference("v-1.0.0"), "1.0.0");
}
#[test]
fn test_parse_tag_reference_no_prefix() {
assert_eq!(parse_tag_reference("1.0.0"), "1.0.0");
}
#[test]
fn test_get_previous_tag_logic_no_tags() {
}
#[test]
fn test_get_previous_tag_logic_single_tag() {
}
#[test]
fn test_get_previous_tag_logic_multiple_tags() {
}
#[test]
fn test_get_previous_tag_logic_recreated_tag() {
}
}