986 lines
29 KiB
Rust
986 lines
29 KiB
Rust
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<Vec<RepositoryData>, 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<Vec<models::Issue>, 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<Vec<models::Label>, 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<Vec<models::Milestone>, 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<MilestoneDetails, String> {
|
|
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<Vec<models::Issue>, 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<Vec<String>, 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::<BTreeSet<_>>()
|
|
.into_iter()
|
|
.collect())
|
|
}
|
|
|
|
pub async fn load_branches(
|
|
server: &Server,
|
|
owner: &str,
|
|
repository: &str,
|
|
default_branch: &str,
|
|
) -> Result<Vec<String>, 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<Vec<models::ContentsResponse>, 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<Vec<u8>, 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<Vec<HistoryCommit>, 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<Vec<HistoryCommit>, 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<Vec<models::ChangedFile>, 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<IssueDetails, String> {
|
|
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<i64>,
|
|
) -> Result<IssueEditorData, String> {
|
|
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<models::Issue, String> {
|
|
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<models::Issue, String> {
|
|
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<PullDetails, String> {
|
|
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<Vec<models::CommitAffectedFiles>, 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<diff::Parsed, String> {
|
|
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<diff::Parsed, String> {
|
|
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<HashMap<String, Vec<String>>, String> {
|
|
let client =
|
|
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
|
|
let configuration = client.configuration();
|
|
let mut refs: HashMap<String, Vec<String>> = 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<models::Commit>)>,
|
|
mut extra_refs: HashMap<String, Vec<String>>,
|
|
) -> Vec<HistoryCommit> {
|
|
let mut commits = HashMap::new();
|
|
let mut ranks: HashMap<String, (usize, usize)> = HashMap::new();
|
|
let mut refs: HashMap<String, Vec<String>> = 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())
|
|
}
|
|
|
|
async fn load_commits_for_ref(
|
|
server: &Server,
|
|
owner: &str,
|
|
repository: &str,
|
|
branch: Option<&str>,
|
|
) -> Result<Vec<models::Commit>, 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<HomeData, String> {
|
|
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::<Vec<_>>(),
|
|
["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));
|
|
}
|
|
}
|