use anyhow::{Context as _, Result};
use std::sync::Arc;
use super::{RetryAfterCapture, retry_octocrab_call};
use anodizer_core::retry::RetryPolicy;
pub(crate) async fn delete_release_asset_by_name(
octo: &Arc<octocrab::Octocrab>,
owner: &str,
repo: &str,
release_id: u64,
asset_name: &str,
policy: &RetryPolicy,
retry_after: Option<&RetryAfterCapture>,
) -> Result<bool> {
const MAX_PAGES: u32 = 50; let mut page: u32 = 1;
loop {
let route = format!(
"/repos/{}/{}/releases/{}/assets?per_page=100&page={}",
owner, repo, release_id, page
);
let assets: Vec<octocrab::models::repos::Asset> =
retry_octocrab_call(policy, "list assets", retry_after, || {
let route = route.clone();
let octo = octo.clone();
async move { octo.get(route, None::<&()>).await }
})
.await
.with_context(|| {
format!(
"release: list assets for release {} on {}/{} (page {})",
release_id, owner, repo, page
)
})?;
for asset in &assets {
if asset.name == asset_name {
let asset_id = asset.id.into_inner();
let owner_s = owner.to_string();
let repo_s = repo.to_string();
retry_octocrab_call(policy, "delete asset", retry_after, || {
let octo = octo.clone();
let owner_s = owner_s.clone();
let repo_s = repo_s.clone();
async move {
octo.repos(owner_s, repo_s)
.release_assets()
.delete(asset_id)
.await
}
})
.await
.with_context(|| {
format!(
"release: delete asset '{}' (id={}) from release {} on {}/{}",
asset_name, asset.id, release_id, owner, repo
)
})?;
return Ok(true);
}
}
if assets.len() < 100 {
break;
}
page += 1;
if page > MAX_PAGES {
break;
}
}
Ok(false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RemoteAssetProbe {
pub(crate) size: u64,
pub(crate) uploaded: bool,
}
pub(crate) async fn find_release_asset_probe(
octo: &Arc<octocrab::Octocrab>,
owner: &str,
repo: &str,
release_id: u64,
asset_name: &str,
policy: &RetryPolicy,
retry_after: Option<&RetryAfterCapture>,
) -> Result<Option<RemoteAssetProbe>> {
const MAX_PAGES: u32 = 50;
let mut page: u32 = 1;
loop {
let route = format!(
"/repos/{}/{}/releases/{}/assets?per_page=100&page={}",
owner, repo, release_id, page
);
let assets: Vec<octocrab::models::repos::Asset> =
retry_octocrab_call(policy, "list assets", retry_after, || {
let route = route.clone();
let octo = octo.clone();
async move { octo.get(route, None::<&()>).await }
})
.await
.with_context(|| {
format!(
"release: list assets for release {} on {}/{} (page {})",
release_id, owner, repo, page
)
})?;
for asset in &assets {
if asset.name == asset_name {
return Ok(Some(RemoteAssetProbe {
size: asset.size as u64,
uploaded: asset.state == "uploaded",
}));
}
}
if assets.len() < 100 {
break;
}
page += 1;
if page > MAX_PAGES {
break;
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{build_test_octocrab, test_retry_policy};
use anodizer_core::test_helpers::responder::spawn_oneshot_http_responder;
use std::sync::atomic::Ordering;
fn asset_json(name: &str, size: u64, id: u64) -> String {
asset_json_with_state(name, size, id, "uploaded")
}
fn asset_json_with_state(name: &str, size: u64, id: u64, state: &str) -> String {
format!(
r#"{{
"url": "https://api.github.com/repos/o/r/releases/assets/{id}",
"browser_download_url": "https://github.com/o/r/releases/download/v1/{name}",
"id": {id},
"node_id": "RA_kwDO",
"name": "{name}",
"label": null,
"state": "{state}",
"content_type": "application/gzip",
"size": {size},
"download_count": 0,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"uploader": null
}}"#
)
}
fn ok_json(body: String) -> &'static str {
let len = body.len();
Box::leak(
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {len}\r\n\r\n{body}"
)
.into_boxed_str(),
)
}
fn full_page_no_match(count: usize) -> String {
let entries: Vec<String> = (0..count)
.map(|i| asset_json("filler.bin", 1, 1000 + i as u64))
.collect();
format!("[{}]", entries.join(","))
}
const RESP_204: &str = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n";
const RESP_503: &str = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n";
#[tokio::test]
async fn find_returns_some_when_asset_matches_first_page() {
let body = format!("[{}]", asset_json("anodize-v1.tar.gz", 4242, 42));
let (addr, calls) = spawn_oneshot_http_responder(vec![ok_json(body)]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let got = find_release_asset_probe(&octo, "o", "r", 1, "anodize-v1.tar.gz", &policy, None)
.await
.expect("call succeeds");
assert_eq!(
got,
Some(RemoteAssetProbe {
size: 4242,
uploaded: true
}),
"must surface the matching asset's size and uploaded state"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"single page with a match must NOT paginate further"
);
}
#[tokio::test]
async fn find_reports_partial_state_for_interrupted_upload() {
let body = format!(
"[{}]",
asset_json_with_state("broken.tar.gz", 5, 13, "starter")
);
let (addr, _calls) = spawn_oneshot_http_responder(vec![ok_json(body)]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let got = find_release_asset_probe(&octo, "o", "r", 1, "broken.tar.gz", &policy, None)
.await
.expect("call succeeds");
assert_eq!(
got,
Some(RemoteAssetProbe {
size: 5,
uploaded: false
}),
"non-'uploaded' state must probe as uploaded: false"
);
}
#[tokio::test]
async fn find_returns_none_when_no_asset_matches() {
let (addr, calls) = spawn_oneshot_http_responder(vec![ok_json("[]".to_string())]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let got = find_release_asset_probe(&octo, "o", "r", 1, "missing.tar.gz", &policy, None)
.await
.expect("call succeeds");
assert_eq!(got, None, "empty list must yield None, not an error");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"empty page (< 100) must terminate pagination immediately"
);
}
#[tokio::test]
async fn find_paginates_to_page_two_when_page_one_is_full() {
let page1 = ok_json(full_page_no_match(100));
let page2 = ok_json(format!("[{}]", asset_json("target.zip", 999, 7)));
let (addr, calls) = spawn_oneshot_http_responder(vec![page1, page2]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let got = find_release_asset_probe(&octo, "o", "r", 1, "target.zip", &policy, None)
.await
.expect("call succeeds");
assert_eq!(
got.map(|p| p.size),
Some(999),
"must find the match on page 2"
);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"page-1 full (100 entries) must force a page-2 fetch"
);
}
#[tokio::test]
async fn find_retries_on_transient_5xx() {
let body = format!("[{}]", asset_json("retry-me.tar.gz", 7, 11));
let (addr, calls) = spawn_oneshot_http_responder(vec![RESP_503, ok_json(body)]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let got = find_release_asset_probe(&octo, "o", "r", 1, "retry-me.tar.gz", &policy, None)
.await
.expect("must retry past 503 to success");
assert_eq!(got.map(|p| p.size), Some(7));
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"expected 1 retried 503 + 1 success = 2 HTTP attempts"
);
}
#[tokio::test]
async fn delete_returns_false_when_asset_absent() {
let (addr, calls) = spawn_oneshot_http_responder(vec![ok_json("[]".to_string())]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let got = delete_release_asset_by_name(&octo, "o", "r", 1, "ghost.bin", &policy, None)
.await
.expect("call succeeds");
assert!(!got, "absent asset must report not-found, not an error");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"absent asset must NOT issue a DELETE request"
);
}
#[tokio::test]
async fn delete_returns_true_after_successful_delete() {
let list_body = format!("[{}]", asset_json("kill.tar.gz", 1, 99));
let (addr, calls) = spawn_oneshot_http_responder(vec![ok_json(list_body), RESP_204]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let got = delete_release_asset_by_name(&octo, "o", "r", 1, "kill.tar.gz", &policy, None)
.await
.expect("call succeeds");
assert!(got, "successful delete must report true");
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"expected exactly 2 HTTP calls: 1 list + 1 delete"
);
}
#[tokio::test]
async fn delete_retries_on_transient_5xx_in_list_call() {
let list_body = format!("[{}]", asset_json("kill.tar.gz", 1, 99));
let (addr, calls) =
spawn_oneshot_http_responder(vec![RESP_503, ok_json(list_body), RESP_204]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let got = delete_release_asset_by_name(&octo, "o", "r", 1, "kill.tar.gz", &policy, None)
.await
.expect("must retry past 503 to successful delete");
assert!(
got,
"delete must report true after the retried path succeeds"
);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"expected 1 retried 503 + 1 list success + 1 delete = 3 HTTP attempts"
);
}
}