use std::{ cmp::Reverse, collections::{BTreeSet, BinaryHeap, HashMap}, }; use gotcha_gitea::{Client, apis, models}; use tokio::task::JoinSet; use crate::{ diff, domain::{ HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails, PullDetails, RepositoryData, Server, api_date, }, presentation::compact_date, }; pub async fn load_repositories(server: &Server) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let user = client .current_user() .await .map_err(|error| error.to_string())?; let login = user.login.unwrap_or_default(); let repositories = apis::user_api::user_current_list_repos(&configuration, Some(1), Some(100)) .await .map_err(|error| error.to_string())?; Ok(repositories .into_iter() .filter(|repository| { repository .owner .as_ref() .and_then(|owner| owner.login.as_deref()) == Some(login.as_str()) }) .filter_map(|repository| { Some(RepositoryData { owner: repository.owner?.login?, name: repository.name?, description: repository .description .filter(|text| !text.is_empty()) .unwrap_or_else(|| "No description".into()), language: repository .language .filter(|text| !text.is_empty()) .unwrap_or_else(|| "Unknown language".into()), open_issues: repository.open_issues_count.unwrap_or_default(), updated: compact_date(repository.updated_at.as_deref()), default_branch: repository.default_branch.unwrap_or_else(|| "main".into()), }) }) .collect()) } pub async fn load_issues( server: &Server, owner: &str, repository: &str, status: &str, labels: &[String], milestone: &str, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let labels = (!labels.is_empty()).then(|| labels.join(",")); apis::issue_api::issue_list_issues( &client.configuration(), owner, repository, Some(status), labels.as_deref(), None, Some("issues"), (!milestone.is_empty()).then_some(milestone), None, None, None, None, None, Some(1), Some(100), ) .await .map_err(|error| error.to_string()) } pub async fn load_labels( server: &Server, owner: &str, repository: &str, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let mut labels = Vec::new(); for page in 1.. { let batch = apis::issue_api::issue_list_labels( &configuration, owner, repository, Some(page), Some(100), ) .await .map_err(|error| error.to_string())?; let done = batch.len() < 100; labels.extend(batch); if done { break; } } Ok(labels) } pub async fn load_milestones( server: &Server, owner: &str, repository: &str, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let mut milestones = Vec::new(); for page in 1.. { let batch = apis::issue_api::issue_get_milestones_list( &configuration, owner, repository, Some("all"), None, Some(page), Some(100), ) .await .map_err(|error| error.to_string())?; let done = batch.len() < 100; milestones.extend(batch); if done { break; } } Ok(milestones) } pub async fn load_milestone( server: &Server, owner: &str, repository: &str, id: i64, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let milestone = apis::issue_api::issue_get_milestone(&configuration, owner, repository, &id.to_string()) .await .map_err(|error| error.to_string())?; let load = |kind| { apis::issue_api::issue_list_issues( &configuration, owner, repository, Some("all"), None, None, Some(kind), milestone.title.as_deref(), None, None, None, None, None, Some(1), Some(100), ) }; let (issues, pulls) = tokio::join!(load("issues"), load("pulls")); let mut pulls = pulls.map_err(|error| error.to_string())?; for pull in &mut pulls { pull.repository.get_or_insert_with(|| { Box::new(models::RepositoryMeta { name: Some(repository.into()), owner: Some(owner.into()), ..Default::default() }) }); } Ok(MilestoneDetails { milestone, issues: issues.map_err(|error| error.to_string())?, pulls, }) } pub async fn load_pulls( server: &Server, status: &str, milestone: &str, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let owner = client .current_user() .await .map_err(|error| error.to_string())? .login .ok_or("The server account has no username.")?; let mut pulls = Vec::new(); for page in 1.. { let batch = apis::issue_api::issue_search_issues( &configuration, Some(status), None, (!milestone.is_empty()).then_some(milestone), None, None, Some("pulls"), None, None, None, None, None, None, None, Some(&owner), None, Some(page), Some(100), ) .await .map_err(|error| error.to_string())?; if batch.is_empty() { break; } pulls.extend(batch); } Ok(pulls) } pub async fn load_pull_milestones(server: &Server) -> Result, String> { let (open, closed) = tokio::try_join!( load_pulls(server, "open", ""), load_pulls(server, "closed", "") )?; Ok(open .into_iter() .chain(closed) .filter_map(|pull| pull.milestone?.title) .collect::>() .into_iter() .collect()) } pub async fn load_branches( server: &Server, owner: &str, repository: &str, default_branch: &str, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let mut branches = Vec::new(); for page in 1.. { let batch = apis::repository_api::repo_list_branches( &configuration, owner, repository, Some(page), Some(100), ) .await .map_err(|error| error.to_string())?; let done = batch.len() < 100; branches.extend(batch.into_iter().filter_map(|branch| branch.name)); if done { break; } } branches.sort_by_key(|branch| (branch != default_branch, branch.to_lowercase())); Ok(branches) } pub async fn load_repository_contents( server: &Server, owner: &str, repository: &str, path: &str, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); if path.is_empty() { apis::repository_api::repo_get_contents_list(&configuration, owner, repository, None) .await .map_err(|error| error.to_string()) } else { apis::repository_api::repo_get_contents_ext( &configuration, owner, repository, path, None, None, ) .await .map(|contents| contents.dir_contents.unwrap_or_default()) .map_err(|error| error.to_string()) } } pub async fn load_repository_file( server: &Server, owner: &str, repository: &str, path: &str, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; apis::repository_api::repo_get_raw_file(&client.configuration(), owner, repository, path, None) .await .map_err(|error| error.to_string())? .bytes() .await .map(|bytes| bytes.to_vec()) .map_err(|error| error.to_string()) } pub async fn load_branch_commits( server: &Server, owner: &str, repository: &str, branch: &str, ) -> Result, String> { load_commits_for_ref(server, owner, repository, Some(branch)) .await .map(|commits| { commits .into_iter() .map(|commit| HistoryCommit { commit, top_lanes: Vec::new(), bottom_lanes: Vec::new(), node_lane: None, connections: Vec::new(), refs: Vec::new(), }) .collect() }) } pub async fn load_all_commits( server: &Server, owner: &str, repository: &str, branches: &[String], ) -> Result, String> { let mut tasks = JoinSet::new(); for (lane, branch) in branches.iter().cloned().enumerate() { let (server, owner, repository) = (server.clone(), owner.to_string(), repository.to_string()); tasks.spawn(async move { let commits = load_commits_for_ref(&server, &owner, &repository, Some(&branch)).await?; Ok::<_, String>((lane, branch, commits)) }); } let mut histories = Vec::new(); while let Some(result) = tasks.join_next().await { histories.push(result.map_err(|error| error.to_string())??); } histories.sort_by_key(|(lane, _, _)| *lane); let pull_refs = load_pull_refs(server, owner, repository) .await .unwrap_or_default(); Ok(build_graph(histories, pull_refs)) } pub async fn load_pull_files( configuration: &apis::configuration::Configuration, owner: &str, repository: &str, number: i64, ) -> Result, String> { let mut files = Vec::new(); for page in 1.. { let batch = apis::repository_api::repo_get_pull_request_files( configuration, owner, repository, number, None, None, Some(page), Some(100), ) .await .map_err(|error| error.to_string())?; let done = batch.len() < 100; files.extend(batch); if done { break; } } Ok(files) } pub async fn load_issue( server: &Server, owner: &str, repository: &str, number: i64, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let (issue, comments) = tokio::join!( apis::issue_api::issue_get_issue(&configuration, owner, repository, number), apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,), ); Ok(IssueDetails { issue: issue.map_err(|error| error.to_string())?, comments: comments.map_err(|error| error.to_string())?, }) } pub async fn load_issue_editor( server: &Server, owner: &str, repository: &str, number: Option, ) -> Result { let issue = async { match number { Some(number) => { let client = Client::new(&server.url, Some(&server.token)) .map_err(|error| error.to_string())?; apis::issue_api::issue_get_issue(&client.configuration(), owner, repository, number) .await .map(Some) .map_err(|error| error.to_string()) } None => Ok(None), } }; let (issue, labels, milestones) = tokio::try_join!( issue, load_labels(server, owner, repository), load_milestones(server, owner, repository) )?; Ok(IssueEditorData { issue, labels, milestones, }) } fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption { models::CreateIssueOption { body: Some(draft.body), due_date: draft.due_date.map(api_date), labels: Some(draft.label_ids), milestone: draft.milestone_id, ..models::CreateIssueOption::new(draft.title) } } fn edit_issue_option(draft: &IssueDraft) -> models::EditIssueOption { models::EditIssueOption { body: Some(draft.body.clone()), due_date: draft.due_date.map(api_date), milestone: Some(draft.milestone_id.unwrap_or_default()), title: Some(draft.title.clone()), unset_due_date: draft.due_date.is_none().then_some(true), ..models::EditIssueOption::new() } } pub async fn create_issue( server: &Server, owner: &str, repository: &str, draft: IssueDraft, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; apis::issue_api::issue_create_issue( &client.configuration(), owner, repository, Some(create_issue_option(draft)), ) .await .map_err(|error| error.to_string()) } pub async fn edit_issue( server: &Server, owner: &str, repository: &str, number: i64, draft: IssueDraft, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let issue = apis::issue_api::issue_edit_issue( &configuration, owner, repository, number, Some(edit_issue_option(&draft)), ) .await .map_err(|error| error.to_string())?; apis::issue_api::issue_replace_labels( &configuration, owner, repository, number, Some(models::IssueLabelsOption { labels: Some( draft .label_ids .into_iter() .map(serde_json::Value::from) .collect(), ), }), ) .await .map_err(|error| error.to_string())?; Ok(issue) } pub async fn load_pull( server: &Server, owner: &str, repository: &str, number: i64, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let (pull, comments, files) = tokio::join!( apis::repository_api::repo_get_pull_request(&configuration, owner, repository, number,), apis::issue_api::issue_get_comments(&configuration, owner, repository, number, None, None,), load_pull_files(&configuration, owner, repository, number), ); Ok(PullDetails { pull: pull.map_err(|error| error.to_string())?, comments: comments.map_err(|error| error.to_string())?, files: files.map_err(|error| error.to_string())?, }) } pub async fn load_commit_files( server: &Server, owner: &str, repository: &str, sha: &str, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; apis::repository_api::repo_get_single_commit( &client.configuration(), owner, repository, sha, Some(true), None, Some(true), ) .await .map(|commit| commit.files.unwrap_or_default()) .map_err(|error| error.to_string()) } pub async fn load_commit_diff( server: &Server, owner: &str, repository: &str, sha: &str, path: &str, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; apis::repository_api::repo_download_commit_diff_or_patch( &client.configuration(), owner, repository, sha, "diff", ) .await .map(|text| diff::parse_file(&text, path)) .map_err(|error| error.to_string()) } pub async fn load_pull_diff( server: &Server, owner: &str, repository: &str, number: i64, path: &str, ) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; apis::repository_api::repo_download_pull_diff_or_patch( &client.configuration(), owner, repository, number, "diff", Some(false), ) .await .map(|text| diff::parse_file(&text, path)) .map_err(|error| error.to_string()) } async fn load_pull_refs( server: &Server, owner: &str, repository: &str, ) -> Result>, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let mut refs: HashMap> = HashMap::new(); for page in 1.. { let batch = apis::repository_api::repo_list_pull_requests( &configuration, owner, repository, None, Some("all"), None, None, None, None, Some(page), Some(100), ) .await .map_err(|error| error.to_string())?; let done = batch.len() < 100; for pull in batch { let Some(head) = pull.head else { continue }; let Some(sha) = head.sha else { continue }; let Some(label) = head.label.or(head.r#ref) else { continue; }; if !label.is_empty() { refs.entry(sha).or_default().push(label); } } if done { break; } } Ok(refs) } fn build_graph( histories: Vec<(usize, String, Vec)>, mut extra_refs: HashMap>, ) -> Vec { let mut commits = HashMap::new(); let mut ranks: HashMap = HashMap::new(); let mut refs: HashMap> = HashMap::new(); for (branch_index, branch, history) in histories { if let Some(sha) = history.iter().find_map(|commit| commit.sha.clone()) { refs.entry(sha).or_default().push(branch); } for (index, commit) in history.into_iter().enumerate() { let Some(sha) = commit.sha.clone() else { continue; }; ranks .entry(sha.clone()) .and_modify(|rank| *rank = (*rank).min((branch_index, index))) .or_insert((branch_index, index)); commits.entry(sha).or_insert(commit); } } for (sha, labels) in extra_refs.drain() { let row = refs.entry(sha).or_default(); for label in labels { if !row.contains(&label) { row.push(label); } } } let mut children = HashMap::new(); for sha in commits.keys() { children.insert(sha.clone(), 0_usize); } for commit in commits.values() { for parent in commit_parents(commit) { if let Some(count) = children.get_mut(parent) { *count += 1; } } } let mut ready = BinaryHeap::new(); for (sha, count) in &children { if *count == 0 { ready.push(Reverse((ranks[sha], sha.clone()))); } } let mut ordered = Vec::with_capacity(commits.len()); while let Some(Reverse((_, sha))) = ready.pop() { let Some(commit) = commits.remove(&sha) else { continue; }; for parent in commit_parents(&commit) { if let Some(count) = children.get_mut(parent) { *count -= 1; if *count == 0 { ready.push(Reverse((ranks[parent], parent.to_string()))); } } } ordered.push(HistoryCommit { commit, top_lanes: Vec::new(), bottom_lanes: Vec::new(), node_lane: None, connections: Vec::new(), refs: refs.remove(&sha).unwrap_or_default(), }); } layout_graph(&mut ordered); ordered } fn layout_graph(commits: &mut [HistoryCommit]) { let mut lanes: Vec> = Vec::new(); for row in commits { let Some(sha) = row.commit.sha.as_deref() else { continue; }; let matching_lanes: Vec<_> = lanes .iter() .enumerate() .filter_map(|(index, next)| (next.as_deref() == Some(sha)).then_some(index)) .collect(); let existing_node = matching_lanes.first().copied(); let node = existing_node.unwrap_or_else(|| allocate_lane(&mut lanes, sha)); row.top_lanes = lanes .iter() .enumerate() .filter_map(|(index, lane)| lane.as_ref().map(|_| index)) .filter(|index| existing_node.is_some() || *index != node) .collect(); let mut connections = BTreeSet::new(); for duplicate in matching_lanes.into_iter().skip(1) { lanes[duplicate] = None; connect(node, duplicate, &mut connections); } let parents: Vec<_> = commit_parents(&row.commit).map(str::to_string).collect(); lanes[node] = None; for (index, parent) in parents.into_iter().enumerate() { if index == 0 { lanes[node] = Some(parent); } else if let Some(existing) = lanes .iter() .position(|next| next.as_deref() == Some(&parent)) { connect(node, existing, &mut connections); } else { let branch = allocate_lane(&mut lanes, &parent); connect(node, branch, &mut connections); } } row.node_lane = Some(node); row.connections = connections.into_iter().collect(); row.bottom_lanes = lanes .iter() .enumerate() .filter_map(|(index, lane)| lane.as_ref().map(|_| index)) .collect(); } } fn allocate_lane(lanes: &mut Vec>, sha: &str) -> usize { if let Some(index) = lanes.iter().position(Option::is_none) { lanes[index] = Some(sha.into()); index } else { lanes.push(Some(sha.into())); lanes.len() - 1 } } fn connect(left: usize, right: usize, connections: &mut BTreeSet) { for lane in left.min(right) + 1..=left.max(right) { connections.insert(lane); } } fn commit_parents(commit: &models::Commit) -> impl Iterator { commit .parents .as_deref() .unwrap_or_default() .iter() .filter_map(|parent| parent.sha.as_deref()) } async fn load_commits_for_ref( server: &Server, owner: &str, repository: &str, branch: Option<&str>, ) -> Result, String> { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let mut commits = Vec::new(); for page in 1.. { let batch = apis::repository_api::repo_get_all_commits( &configuration, owner, repository, branch, None, None, None, None, None, Some(false), Some(page), Some(100), None, ) .await .map_err(|error| error.to_string())?; let done = batch.len() < 100; commits.extend(batch); if done { break; } } Ok(commits) } pub async fn load_home(server: &Server) -> Result { let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); let login = client .current_user() .await .map_err(|error| error.to_string())? .login .ok_or("The server account has no username.")?; let activity_configuration = configuration.clone(); let activity_login = login.clone(); let activities = async move { let mut activities = Vec::new(); for page in 1.. { let batch = apis::user_api::user_list_activity_feeds( &activity_configuration, &activity_login, Some(true), None, Some(page), Some(100), ) .await .map_err(|error| error.to_string())?; if batch.is_empty() { break; } activities.extend(batch); } Ok::<_, String>(activities) }; let (activities, heatmap) = tokio::join!( activities, apis::user_api::user_get_heatmap_data(&configuration, &login), ); Ok(HomeData { activities: activities?, heatmap: heatmap.map_err(|error| error.to_string())?, }) } #[cfg(test)] mod tests { use super::*; fn commit(sha: &str, parents: &[&str]) -> models::Commit { models::Commit { sha: Some(sha.into()), parents: Some( parents .iter() .map(|sha| models::CommitMeta { sha: Some((*sha).into()), ..Default::default() }) .collect(), ), ..Default::default() } } #[test] fn lays_out_merged_pull_request_on_its_parent_lane() { let commits = build_graph( vec![( 0, "main".into(), vec![ commit("tip", &["merge"]), commit("merge", &["main-before", "feature"]), commit("feature", &["base"]), commit("main-before", &["base"]), commit("base", &[]), ], )], HashMap::from([("feature".into(), vec!["linux-refactoring-base".into()])]), ); assert_eq!( commits .iter() .map(|commit| commit.commit.sha.as_deref().unwrap()) .collect::>(), ["tip", "merge", "feature", "main-before", "base"] ); assert_eq!(commits[0].refs, ["main"]); assert_eq!(commits[1].connections, [1]); assert_eq!(commits[2].refs, ["linux-refactoring-base"]); assert_eq!(commits[2].node_lane, Some(1)); assert_eq!(commits[1].top_lanes, [0]); assert_eq!(commits[1].bottom_lanes, [0, 1]); assert_eq!(commits[2].top_lanes, [0, 1]); assert_eq!(commits[2].bottom_lanes, [0, 1]); assert!(commits[3].connections.is_empty()); assert_eq!(commits[3].bottom_lanes, [0, 1]); assert_eq!(commits[4].top_lanes, [0, 1]); assert_eq!(commits[4].connections, [1]); assert!(commits[4].bottom_lanes.is_empty()); assert_eq!(commits[4].node_lane, Some(0)); } #[test] fn builds_complete_create_and_edit_issue_requests() { let create = create_issue_option(IssueDraft { title: "Title".into(), body: "**Body**".into(), label_ids: vec![2, 3], milestone_id: Some(5), due_date: Some(1_709_164_800), }); assert_eq!(create.title, "Title"); assert_eq!(create.body.as_deref(), Some("**Body**")); assert_eq!(create.labels, Some(vec![2, 3])); assert_eq!(create.milestone, Some(5)); assert_eq!(create.due_date.as_deref(), Some("2024-02-29T00:00:00Z")); let edit = edit_issue_option(&IssueDraft { title: "New".into(), body: String::new(), label_ids: Vec::new(), milestone_id: None, due_date: None, }); assert_eq!(edit.title.as_deref(), Some("New")); assert_eq!(edit.body.as_deref(), Some("")); assert_eq!(edit.milestone, Some(0)); assert_eq!(edit.unset_due_date, Some(true)); } }