Centralize Gitea domain workflows

This commit is contained in:
Georg Bauer
2026-07-31 16:44:16 +02:00
parent b468a2670c
commit f38c3c4939
19 changed files with 2248 additions and 1624 deletions

View File

@@ -0,0 +1,478 @@
use std::{
cmp::Reverse,
collections::{BTreeSet, BinaryHeap, HashMap},
};
use tokio::task::JoinSet;
use crate::{
Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, PullRefs, RepositoryId},
models,
};
use gitea_openapi::apis;
impl Client {
pub async fn current_user_repositories_page(
&self,
page: i32,
limit: i32,
) -> Result<Page<models::Repository>> {
validate_page(page, limit)?;
apis::user_api::user_current_list_repos(&self.configuration(), Some(page), Some(limit))
.await
.map(|items| Page::from_items(items, limit))
.map_err(Error::generated)
}
pub async fn owned_repositories_page(
&self,
page: i32,
limit: i32,
) -> Result<Page<models::Repository>> {
let login = self
.current_user()
.await?
.login
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
let page = self.current_user_repositories_page(page, limit).await?;
Ok(Page {
has_more: page.has_more,
items: page
.items
.into_iter()
.filter(|repository| {
repository
.owner
.as_ref()
.and_then(|owner| owner.login.as_deref())
== Some(login.as_str())
})
.collect(),
})
}
pub async fn branches(
&self,
repository: &RepositoryId,
default_branch: &str,
) -> Result<Vec<String>> {
let configuration = self.configuration();
let mut branches = Vec::new();
for page in 1.. {
let batch = apis::repository_api::repo_list_branches(
&configuration,
&repository.owner,
&repository.repository,
Some(page),
Some(100),
)
.await
.map_err(Error::generated)?;
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 repository_contents(
&self,
repository: &RepositoryId,
path: &str,
) -> Result<Vec<models::ContentsResponse>> {
let configuration = self.configuration();
if path.is_empty() {
apis::repository_api::repo_get_contents_list(
&configuration,
&repository.owner,
&repository.repository,
None,
)
.await
.map_err(Error::generated)
} else {
apis::repository_api::repo_get_contents_ext(
&configuration,
&repository.owner,
&repository.repository,
path,
None,
None,
)
.await
.map(|contents| contents.dir_contents.unwrap_or_default())
.map_err(Error::generated)
}
}
pub async fn repository_file(&self, repository: &RepositoryId, path: &str) -> Result<Vec<u8>> {
apis::repository_api::repo_get_raw_file(
&self.configuration(),
&repository.owner,
&repository.repository,
path,
None,
)
.await
.map_err(Error::generated)?
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(Into::into)
}
pub async fn branch_history(
&self,
repository: &RepositoryId,
branch: &str,
path: Option<&str>,
pages: u32,
) -> Result<Page<HistoryCommit>> {
self.commits_for_ref(repository, Some(branch), path, pages)
.await
.map(|page| Page {
has_more: page.has_more,
items: page
.items
.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 all_history(
&self,
repository: &RepositoryId,
branches: &[String],
path: Option<&str>,
pages: u32,
) -> Result<Page<HistoryCommit>> {
let mut tasks = JoinSet::new();
for (lane, branch) in branches.iter().cloned().enumerate() {
let (client, repository) = (self.clone(), repository.clone());
let path = path.map(str::to_string);
tasks.spawn(async move {
let commits = client
.commits_for_ref(&repository, Some(&branch), path.as_deref(), pages)
.await?;
Ok::<_, Error>((lane, branch, commits))
});
}
let mut histories = Vec::new();
while let Some(result) = tasks.join_next().await {
histories.push(result.map_err(|error| Error::Generated(error.to_string()))??);
}
histories.sort_by_key(|(lane, _, _)| *lane);
let has_more = histories.iter().any(|(_, _, page)| page.has_more);
let histories = histories
.into_iter()
.map(|(lane, branch, page)| (lane, branch, page.items))
.collect();
let pull_refs = self.pull_refs(repository).await.unwrap_or_default();
Ok(Page {
items: build_graph(histories, pull_refs),
has_more,
})
}
pub async fn commit_files(
&self,
repository: &RepositoryId,
sha: &str,
) -> Result<Vec<models::CommitAffectedFiles>> {
apis::repository_api::repo_get_single_commit(
&self.configuration(),
&repository.owner,
&repository.repository,
sha,
Some(true),
None,
Some(true),
)
.await
.map(|commit| commit.files.unwrap_or_default())
.map_err(Error::generated)
}
pub async fn commit_diff(&self, repository: &RepositoryId, sha: &str) -> Result<String> {
apis::repository_api::repo_download_commit_diff_or_patch(
&self.configuration(),
&repository.owner,
&repository.repository,
sha,
"diff",
)
.await
.map_err(Error::generated)
}
async fn pull_refs(&self, repository: &RepositoryId) -> Result<PullRefs> {
let mut refs: PullRefs = HashMap::new();
for page in 1.. {
let batch = self.repository_pulls(repository, "all", page, 100).await?;
for pull in batch.items {
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 !batch.has_more {
break;
}
}
Ok(refs)
}
async fn commits_for_ref(
&self,
repository: &RepositoryId,
branch: Option<&str>,
path: Option<&str>,
pages: u32,
) -> Result<Page<models::Commit>> {
let configuration = self.configuration();
let mut commits = Vec::new();
let mut has_more = false;
for page in 1..=pages.max(1) {
let batch = apis::repository_api::repo_get_all_commits(
&configuration,
&repository.owner,
&repository.repository,
branch,
path,
None,
None,
None,
None,
Some(false),
Some(page as i32),
Some(DEFAULT_PAGE_SIZE),
None,
)
.await
.map_err(Error::generated)?;
has_more = batch.len() == DEFAULT_PAGE_SIZE as usize;
commits.extend(batch);
if !has_more {
break;
}
}
Ok(Page {
items: commits,
has_more,
})
}
}
fn build_graph(
histories: Vec<(usize, String, Vec<models::Commit>)>,
mut extra_refs: PullRefs,
) -> Vec<HistoryCommit> {
let mut commits = HashMap::new();
let mut ranks: HashMap<String, (usize, usize)> = HashMap::new();
let mut refs: PullRefs = 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<Option<String>> = 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<Option<String>>, 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<usize>) {
for lane in left.min(right) + 1..=left.max(right) {
connections.insert(lane);
}
}
fn commit_parents(commit: &models::Commit) -> impl Iterator<Item = &str> {
commit
.parents
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|parent| parent.sha.as_deref())
}
fn validate_page(page: i32, limit: i32) -> Result<()> {
if page < 1 || limit < 1 {
return Err(Error::InvalidInput(
"page and limit must be positive".into(),
));
}
Ok(())
}
#[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_shared_commit_graph() {
let rows = build_graph(
vec![
(
0,
"main".into(),
vec![commit("merge", &["left", "right"]), commit("left", &[])],
),
(1, "feature".into(), vec![commit("right", &[])]),
],
HashMap::new(),
);
assert_eq!(rows.len(), 3);
assert!(rows.iter().any(|row| row.refs == ["main"]));
assert!(rows.iter().any(|row| row.refs == ["feature"]));
assert!(rows.iter().any(|row| !row.connections.is_empty()));
}
}