Centralize Gitea domain workflows
This commit is contained in:
@@ -11,3 +11,4 @@ gitea-openapi = { package = "gitea-client", version = "=1.25.2", default-feature
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
208
crates/gitea/src/activity.rs
Normal file
208
crates/gitea/src/activity.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use crate::{
|
||||
Client, Error, Result,
|
||||
domain::{DEFAULT_PAGE_SIZE, HomeData},
|
||||
models,
|
||||
};
|
||||
use gitea_openapi::apis;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum Target {
|
||||
Repository {
|
||||
owner: String,
|
||||
repository: String,
|
||||
},
|
||||
Commit {
|
||||
owner: String,
|
||||
repository: String,
|
||||
sha: String,
|
||||
},
|
||||
Issue {
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
},
|
||||
Pull {
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub async fn home(&self, page: i32) -> Result<HomeData> {
|
||||
if page < 1 {
|
||||
return Err(Error::InvalidInput("page must be positive".into()));
|
||||
}
|
||||
let configuration = self.configuration();
|
||||
let login = self
|
||||
.current_user()
|
||||
.await?
|
||||
.login
|
||||
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
|
||||
let activities = apis::user_api::user_list_activity_feeds(
|
||||
&configuration,
|
||||
&login,
|
||||
Some(true),
|
||||
None,
|
||||
Some(page),
|
||||
Some(DEFAULT_PAGE_SIZE),
|
||||
);
|
||||
let (activities, heatmap) = tokio::join!(
|
||||
activities,
|
||||
apis::user_api::user_get_heatmap_data(&configuration, &login),
|
||||
);
|
||||
let activities = activities.map_err(Error::generated)?;
|
||||
Ok(HomeData {
|
||||
has_more: activities.len() == DEFAULT_PAGE_SIZE as usize,
|
||||
activities,
|
||||
heatmap: heatmap.map_err(Error::generated)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target(activity: &models::Activity) -> Option<Target> {
|
||||
use models::activity::OpType;
|
||||
|
||||
let (owner, repository) = activity
|
||||
.repo
|
||||
.as_ref()?
|
||||
.full_name
|
||||
.as_deref()?
|
||||
.split_once('/')?;
|
||||
let pair = || (owner.to_string(), repository.to_string());
|
||||
match activity.op_type? {
|
||||
OpType::CreateRepo => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Repository { owner, repository })
|
||||
}
|
||||
OpType::CommitRepo | OpType::MirrorSyncPush => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Commit {
|
||||
owner,
|
||||
repository,
|
||||
sha: commit_sha(activity.content.as_deref()?)?,
|
||||
})
|
||||
}
|
||||
OpType::CreateIssue | OpType::CloseIssue | OpType::ReopenIssue | OpType::CommentIssue => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Issue {
|
||||
owner,
|
||||
repository,
|
||||
number: issue_number(activity)?,
|
||||
})
|
||||
}
|
||||
OpType::CreatePullRequest
|
||||
| OpType::MergePullRequest
|
||||
| OpType::AutoMergePullRequest
|
||||
| OpType::ClosePullRequest
|
||||
| OpType::ReopenPullRequest
|
||||
| OpType::CommentPull
|
||||
| OpType::ApprovePullRequest
|
||||
| OpType::RejectPullRequest
|
||||
| OpType::PullReviewDismissed
|
||||
| OpType::PullRequestReadyForReview => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Pull {
|
||||
owner,
|
||||
repository,
|
||||
number: issue_number(activity)?,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn issue_number(activity: &models::Activity) -> Option<i64> {
|
||||
activity
|
||||
.comment
|
||||
.as_ref()
|
||||
.and_then(|comment| {
|
||||
comment
|
||||
.pull_request_url
|
||||
.as_deref()
|
||||
.or(comment.issue_url.as_deref())
|
||||
})
|
||||
.and_then(url_number)
|
||||
.or_else(|| {
|
||||
activity
|
||||
.content
|
||||
.as_deref()?
|
||||
.split(|character: char| !character.is_ascii_digit())
|
||||
.find(|part| !part.is_empty())?
|
||||
.parse()
|
||||
.ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn url_number(url: &str) -> Option<i64> {
|
||||
url.trim_end_matches('/').rsplit('/').next()?.parse().ok()
|
||||
}
|
||||
|
||||
fn commit_sha(content: &str) -> Option<String> {
|
||||
let payload: serde_json::Value = serde_json::from_str(content).ok()?;
|
||||
payload.get("HeadCommit").and_then(find_sha).or_else(|| {
|
||||
payload
|
||||
.get("Commits")?
|
||||
.as_array()?
|
||||
.last()
|
||||
.and_then(find_sha)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_sha(value: &serde_json::Value) -> Option<String> {
|
||||
value.as_object()?.iter().find_map(|(key, value)| {
|
||||
(key.to_ascii_lowercase().contains("sha"))
|
||||
.then(|| value.as_str())
|
||||
.flatten()
|
||||
.filter(|sha| sha.len() >= 7)
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use models::activity::OpType;
|
||||
|
||||
fn activity(op_type: OpType, content: &str) -> models::Activity {
|
||||
models::Activity {
|
||||
op_type: Some(op_type),
|
||||
content: Some(content.into()),
|
||||
repo: Some(Box::new(models::Repository {
|
||||
full_name: Some("octo/demo".into()),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_linkable_activity_targets() {
|
||||
assert_eq!(
|
||||
target(&activity(OpType::CreateRepo, "")),
|
||||
Some(Target::Repository {
|
||||
owner: "octo".into(),
|
||||
repository: "demo".into(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
target(&activity(OpType::CreatePullRequest, "42|feature")),
|
||||
Some(Target::Pull {
|
||||
owner: "octo".into(),
|
||||
repository: "demo".into(),
|
||||
number: 42,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
target(&activity(
|
||||
OpType::CommitRepo,
|
||||
r#"{"HeadCommit":{"Sha1":"0123456789abcdef"}}"#,
|
||||
)),
|
||||
Some(Target::Commit {
|
||||
owner: "octo".into(),
|
||||
repository: "demo".into(),
|
||||
sha: "0123456789abcdef".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
190
crates/gitea/src/diff.rs
Normal file
190
crates/gitea/src/diff.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct Line {
|
||||
pub old_number: String,
|
||||
pub new_number: String,
|
||||
pub text: String,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
pub struct Parsed {
|
||||
pub lines: Vec<Line>,
|
||||
pub columns: usize,
|
||||
}
|
||||
|
||||
pub fn parse_file(diff: &str, path: &str) -> Parsed {
|
||||
let section = sections(diff)
|
||||
.into_iter()
|
||||
.find(|section| section_path(section).as_deref() == Some(path))
|
||||
.unwrap_or("");
|
||||
if section.is_empty() {
|
||||
let text = format!("No diff data is available for {path}.");
|
||||
return Parsed {
|
||||
columns: text.chars().count(),
|
||||
lines: vec![Line {
|
||||
old_number: String::new(),
|
||||
new_number: String::new(),
|
||||
text,
|
||||
kind: "header",
|
||||
}],
|
||||
};
|
||||
}
|
||||
let mut old = 0;
|
||||
let mut new = 0;
|
||||
let mut columns = 0;
|
||||
let lines = section
|
||||
.lines()
|
||||
.map(|text| {
|
||||
columns = columns.max(display_columns(text));
|
||||
let (old_number, new_number, kind) = if text.starts_with("@@") {
|
||||
if let Some((old_start, new_start)) = hunk_starts(text) {
|
||||
old = old_start;
|
||||
new = new_start;
|
||||
}
|
||||
(String::new(), String::new(), "hunk")
|
||||
} else if text.starts_with('+') && !text.starts_with("+++") {
|
||||
let number = new.to_string();
|
||||
new += 1;
|
||||
(String::new(), number, "addition")
|
||||
} else if text.starts_with('-') && !text.starts_with("---") {
|
||||
let number = old.to_string();
|
||||
old += 1;
|
||||
(number, String::new(), "removal")
|
||||
} else if text.starts_with(' ') {
|
||||
let numbers = (old.to_string(), new.to_string());
|
||||
old += 1;
|
||||
new += 1;
|
||||
(numbers.0, numbers.1, "context")
|
||||
} else {
|
||||
(String::new(), String::new(), "header")
|
||||
};
|
||||
Line {
|
||||
old_number,
|
||||
new_number,
|
||||
text: text.into(),
|
||||
kind,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Parsed { lines, columns }
|
||||
}
|
||||
|
||||
fn sections(diff: &str) -> Vec<&str> {
|
||||
let mut starts: Vec<_> = diff
|
||||
.match_indices("diff --git ")
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
starts.push(diff.len());
|
||||
starts
|
||||
.windows(2)
|
||||
.map(move |bounds| &diff[bounds[0]..bounds[1]])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn section_path(section: &str) -> Option<String> {
|
||||
section
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("+++ "))
|
||||
.filter(|path| *path != "/dev/null")
|
||||
.or_else(|| {
|
||||
section
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("--- "))
|
||||
.filter(|path| *path != "/dev/null")
|
||||
})
|
||||
.map(|path| {
|
||||
decode_path(path)
|
||||
.trim_start_matches("a/")
|
||||
.trim_start_matches("b/")
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_path(path: &str) -> String {
|
||||
let path = path.trim_matches('"');
|
||||
let mut bytes = Vec::with_capacity(path.len());
|
||||
let mut chars = path.bytes();
|
||||
while let Some(byte) = chars.next() {
|
||||
if byte != b'\\' {
|
||||
bytes.push(byte);
|
||||
continue;
|
||||
}
|
||||
let Some(escaped) = chars.next() else { break };
|
||||
match escaped {
|
||||
b'n' => bytes.push(b'\n'),
|
||||
b'r' => bytes.push(b'\r'),
|
||||
b't' => bytes.push(b'\t'),
|
||||
b'\\' | b'"' => bytes.push(escaped),
|
||||
b'0'..=b'7' => {
|
||||
let mut value = escaped - b'0';
|
||||
for _ in 0..2 {
|
||||
let Some(next) = chars.next() else { break };
|
||||
if !(b'0'..=b'7').contains(&next) {
|
||||
bytes.push(value);
|
||||
bytes.push(next);
|
||||
value = 0;
|
||||
break;
|
||||
}
|
||||
value = value * 8 + next - b'0';
|
||||
}
|
||||
if value != 0 {
|
||||
bytes.push(value);
|
||||
}
|
||||
}
|
||||
other => bytes.push(other),
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
fn hunk_starts(line: &str) -> Option<(i32, i32)> {
|
||||
let mut parts = line.split_whitespace();
|
||||
(parts.next()? == "@@").then_some(())?;
|
||||
Some((
|
||||
range_start(parts.next()?, '-'),
|
||||
range_start(parts.next()?, '+'),
|
||||
))
|
||||
}
|
||||
|
||||
fn range_start(value: &str, prefix: char) -> i32 {
|
||||
value
|
||||
.strip_prefix(prefix)
|
||||
.and_then(|value| value.split(',').next())
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn display_columns(line: &str) -> usize {
|
||||
line.chars().fold(0, |column, character| {
|
||||
if character == '\t' {
|
||||
(column / 8 + 1) * 8
|
||||
} else {
|
||||
column + 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selects_and_numbers_a_file_diff() {
|
||||
let diff = "diff --git a/one b/one\n--- a/one\n+++ b/one\n@@ -1 +1 @@\n-old\n+new\n\
|
||||
diff --git a/two b/two\n--- a/two\n+++ b/two\n@@ -4,2 +8,3 @@\n context\n-removed\n+added\n";
|
||||
let parsed = parse_file(diff, "two");
|
||||
assert_eq!(parsed.lines[4].old_number, "4");
|
||||
assert_eq!(parsed.lines[4].new_number, "8");
|
||||
assert_eq!(parsed.lines[5].kind, "removal");
|
||||
assert_eq!(parsed.lines[6].kind, "addition");
|
||||
assert!(parsed.lines.iter().all(|line| !line.text.contains("old")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_git_quoted_paths() {
|
||||
let diff = "diff --git \"a/caf\\303\\251\" \"b/caf\\303\\251\"\n--- \"a/caf\\303\\251\"\n+++ \"b/caf\\303\\251\"\n@@ -0,0 +1 @@\n+hello\n";
|
||||
assert_eq!(
|
||||
parse_file(diff, "café").lines.last().unwrap().kind,
|
||||
"addition"
|
||||
);
|
||||
}
|
||||
}
|
||||
235
crates/gitea/src/domain.rs
Normal file
235
crates/gitea/src/domain.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::models;
|
||||
|
||||
pub const DEFAULT_PAGE_SIZE: i32 = 30;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RepositoryId {
|
||||
pub owner: String,
|
||||
pub repository: String,
|
||||
}
|
||||
|
||||
impl RepositoryId {
|
||||
pub fn new(owner: impl Into<String>, repository: impl Into<String>) -> crate::Result<Self> {
|
||||
let result = Self {
|
||||
owner: owner.into(),
|
||||
repository: repository.into(),
|
||||
};
|
||||
if result.owner.is_empty()
|
||||
|| result.repository.is_empty()
|
||||
|| result.owner.contains('/')
|
||||
|| result.repository.contains('/')
|
||||
{
|
||||
return Err(crate::Error::InvalidInput(
|
||||
"repository must be OWNER/REPOSITORY".into(),
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn parse(value: &str) -> crate::Result<Self> {
|
||||
let (owner, repository) = value.split_once('/').ok_or_else(|| {
|
||||
crate::Error::InvalidInput("repository must be OWNER/REPOSITORY".into())
|
||||
})?;
|
||||
Self::new(owner, repository)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Page<T> {
|
||||
pub items: Vec<T>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
impl<T> Page<T> {
|
||||
pub fn from_items(items: Vec<T>, limit: i32) -> Self {
|
||||
Self {
|
||||
has_more: limit > 0 && items.len() == limit as usize,
|
||||
items,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IssueQuery {
|
||||
pub state: String,
|
||||
pub labels: Option<String>,
|
||||
pub keyword: Option<String>,
|
||||
pub kind: String,
|
||||
pub milestones: Option<String>,
|
||||
pub from: Option<String>,
|
||||
pub until: Option<String>,
|
||||
pub author: Option<String>,
|
||||
pub assignee: Option<String>,
|
||||
pub mentions: Option<String>,
|
||||
pub page: i32,
|
||||
pub limit: i32,
|
||||
}
|
||||
|
||||
impl Default for IssueQuery {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: "open".into(),
|
||||
labels: None,
|
||||
keyword: None,
|
||||
kind: "issues".into(),
|
||||
milestones: None,
|
||||
from: None,
|
||||
until: None,
|
||||
author: None,
|
||||
assignee: None,
|
||||
mentions: None,
|
||||
page: 1,
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateIssue {
|
||||
pub option: models::CreateIssueOption,
|
||||
pub label_names: Vec<String>,
|
||||
pub milestone_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EditIssue {
|
||||
pub option: models::EditIssueOption,
|
||||
pub replace_labels: Option<Vec<i64>>,
|
||||
pub add_labels: Vec<String>,
|
||||
pub remove_labels: Vec<String>,
|
||||
pub add_assignees: Vec<String>,
|
||||
pub milestone_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IssueDraft {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub label_ids: Vec<i64>,
|
||||
pub milestone_id: Option<i64>,
|
||||
pub due_date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IssueDetails {
|
||||
pub issue: models::Issue,
|
||||
pub comments: Vec<models::Comment>,
|
||||
pub viewer_id: Option<i64>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct IssueEditorData {
|
||||
pub issue: Option<models::Issue>,
|
||||
pub labels: Vec<models::Label>,
|
||||
pub milestones: Vec<models::Milestone>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MilestoneDraft {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub due_on: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MilestoneDetails {
|
||||
pub milestone: models::Milestone,
|
||||
pub issues: Vec<models::Issue>,
|
||||
pub pulls: Vec<models::Issue>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PullDetails {
|
||||
pub pull: models::PullRequest,
|
||||
pub comments: Vec<models::Comment>,
|
||||
pub files: Vec<models::ChangedFile>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryCommit {
|
||||
pub commit: models::Commit,
|
||||
pub top_lanes: Vec<usize>,
|
||||
pub bottom_lanes: Vec<usize>,
|
||||
pub node_lane: Option<usize>,
|
||||
pub connections: Vec<usize>,
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HomeData {
|
||||
pub activities: Vec<models::Activity>,
|
||||
pub heatmap: Vec<models::UserHeatmapData>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
pub type PullRefs = HashMap<String, Vec<String>>;
|
||||
|
||||
pub fn api_date(timestamp: i64) -> String {
|
||||
let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400));
|
||||
format!("{year:04}-{month:02}-{day:02}T00:00:00Z")
|
||||
}
|
||||
|
||||
pub fn parse_api_date(value: &str) -> Option<i64> {
|
||||
let date = value.get(..10)?;
|
||||
let mut parts = date.split('-');
|
||||
let year = parts.next()?.parse().ok()?;
|
||||
let month = parts.next()?.parse().ok()?;
|
||||
let day = parts.next()?.parse().ok()?;
|
||||
if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||
return None;
|
||||
}
|
||||
let days = days_from_civil(year, month, day);
|
||||
(civil_from_days(days) == (year, month, day)).then_some(days * 86_400)
|
||||
}
|
||||
|
||||
fn civil_from_days(days: i64) -> (i64, i64, i64) {
|
||||
let days = days + 719_468;
|
||||
let era = days.div_euclid(146_097);
|
||||
let day_of_era = days - era * 146_097;
|
||||
let year_of_era =
|
||||
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
|
||||
let mut year = year_of_era + era * 400;
|
||||
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
|
||||
let month_prime = (5 * day_of_year + 2) / 153;
|
||||
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
|
||||
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
|
||||
year += i64::from(month <= 2);
|
||||
(year, month, day)
|
||||
}
|
||||
|
||||
fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
|
||||
year -= i64::from(month <= 2);
|
||||
let era = year.div_euclid(400);
|
||||
let year_of_era = year - era * 400;
|
||||
let month_prime = month + if month > 2 { -3 } else { 9 };
|
||||
let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
|
||||
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
|
||||
era * 146_097 + day_of_era - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_repositories_pages_and_dates() {
|
||||
assert_eq!(
|
||||
RepositoryId::parse("alice/project").unwrap(),
|
||||
RepositoryId {
|
||||
owner: "alice".into(),
|
||||
repository: "project".into(),
|
||||
}
|
||||
);
|
||||
assert!(RepositoryId::parse("project").is_err());
|
||||
assert!(Page::from_items(vec![(); 30], 30).has_more);
|
||||
assert!(!Page::from_items(vec![(); 29], 30).has_more);
|
||||
let leap_day = parse_api_date("2024-02-29T12:34:56Z").unwrap();
|
||||
assert_eq!(api_date(leap_day), "2024-02-29T00:00:00Z");
|
||||
assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None);
|
||||
}
|
||||
}
|
||||
565
crates/gitea/src/issues.rs
Normal file
565
crates/gitea/src/issues.rs
Normal file
@@ -0,0 +1,565 @@
|
||||
use crate::{
|
||||
Client, Error, Result,
|
||||
domain::{
|
||||
CreateIssue, EditIssue, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, Page,
|
||||
RepositoryId,
|
||||
},
|
||||
models,
|
||||
};
|
||||
use gitea_openapi::apis;
|
||||
|
||||
impl Client {
|
||||
pub async fn issues(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
query: &IssueQuery,
|
||||
) -> Result<Page<models::Issue>> {
|
||||
if !matches!(query.state.as_str(), "open" | "closed" | "all") {
|
||||
return Err(Error::InvalidInput(
|
||||
"issue state must be open, closed, or all".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(query.kind.as_str(), "issues" | "pulls" | "all") {
|
||||
return Err(Error::InvalidInput(
|
||||
"issue kind must be issues, pulls, or all".into(),
|
||||
));
|
||||
}
|
||||
if query.page < 1 || query.limit < 1 {
|
||||
return Err(Error::InvalidInput(
|
||||
"issue page and limit must be positive".into(),
|
||||
));
|
||||
}
|
||||
apis::issue_api::issue_list_issues(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
Some(&query.state),
|
||||
query.labels.as_deref(),
|
||||
query.keyword.as_deref(),
|
||||
Some(&query.kind),
|
||||
query.milestones.as_deref(),
|
||||
query.from.clone(),
|
||||
query.until.clone(),
|
||||
query.author.as_deref(),
|
||||
query.assignee.as_deref(),
|
||||
query.mentions.as_deref(),
|
||||
Some(query.page),
|
||||
Some(query.limit),
|
||||
)
|
||||
.await
|
||||
.map(|items| Page::from_items(items, query.limit))
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn issue(&self, repository: &RepositoryId, number: i64) -> Result<models::Issue> {
|
||||
positive(number, "issue number")?;
|
||||
apis::issue_api::issue_get_issue(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn issue_details(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
) -> Result<IssueDetails> {
|
||||
let (issue, comments, viewer) = tokio::join!(
|
||||
self.issue(repository, number),
|
||||
self.issue_comments(repository, number),
|
||||
self.current_user(),
|
||||
);
|
||||
Ok(IssueDetails {
|
||||
issue: issue?,
|
||||
comments: comments?,
|
||||
viewer_id: viewer?.id,
|
||||
has_more: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn issue_editor(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: Option<i64>,
|
||||
) -> Result<IssueEditorData> {
|
||||
let issue = async {
|
||||
match number {
|
||||
Some(number) => self.issue(repository, number).await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
};
|
||||
let (issue, labels, milestones) =
|
||||
tokio::try_join!(issue, self.labels(repository), self.milestones(repository),)?;
|
||||
Ok(IssueEditorData {
|
||||
issue,
|
||||
labels,
|
||||
milestones,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn labels(&self, repository: &RepositoryId) -> Result<Vec<models::Label>> {
|
||||
let configuration = self.configuration();
|
||||
let mut labels = Vec::new();
|
||||
for page in 1.. {
|
||||
let batch = apis::issue_api::issue_list_labels(
|
||||
&configuration,
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
Some(page),
|
||||
Some(100),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)?;
|
||||
let done = batch.len() < 100;
|
||||
labels.extend(batch);
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(labels)
|
||||
}
|
||||
|
||||
pub async fn resolve_label_ids(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
names: &[String],
|
||||
) -> Result<Vec<i64>> {
|
||||
if names.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let labels = self.labels(repository).await?;
|
||||
names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
labels
|
||||
.iter()
|
||||
.find(|label| label.name.as_deref() == Some(name))
|
||||
.and_then(|label| label.id)
|
||||
.ok_or_else(|| Error::InvalidInput(format!("unknown label {name:?}")))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn create_issue(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
mut input: CreateIssue,
|
||||
) -> Result<models::Issue> {
|
||||
if input.option.title.trim().is_empty() {
|
||||
return Err(Error::InvalidInput("issue title must not be empty".into()));
|
||||
}
|
||||
if input.option.milestone.is_some() && input.milestone_name.is_some() {
|
||||
return Err(Error::InvalidInput(
|
||||
"use either milestone or milestone_name, not both".into(),
|
||||
));
|
||||
}
|
||||
if let Some(name) = input.milestone_name.as_deref() {
|
||||
input.option.milestone = Some(self.resolve_milestone_id(repository, name).await?);
|
||||
}
|
||||
if !input.label_names.is_empty() {
|
||||
input.option.labels.get_or_insert_default().extend(
|
||||
self.resolve_label_ids(repository, &input.label_names)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
apis::issue_api::issue_create_issue(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
Some(input.option),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn create_issue_draft(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
draft: IssueDraft,
|
||||
) -> Result<models::Issue> {
|
||||
self.create_issue(
|
||||
repository,
|
||||
CreateIssue {
|
||||
option: create_issue_option(draft),
|
||||
label_names: Vec::new(),
|
||||
milestone_name: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn edit_issues(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
numbers: &[i64],
|
||||
mut input: EditIssue,
|
||||
) -> Result<Vec<models::Issue>> {
|
||||
if numbers.is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"at least one issue number is required".into(),
|
||||
));
|
||||
}
|
||||
if input.option.milestone.is_some() && input.milestone_name.is_some() {
|
||||
return Err(Error::InvalidInput(
|
||||
"use either milestone or milestone_name, not both".into(),
|
||||
));
|
||||
}
|
||||
if input.option.assignees.is_some() && !input.add_assignees.is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"use either assignees or add_assignees, not both".into(),
|
||||
));
|
||||
}
|
||||
if input.replace_labels.is_some()
|
||||
&& (!input.add_labels.is_empty() || !input.remove_labels.is_empty())
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"use either replace_labels or add_labels/remove_labels, not both".into(),
|
||||
));
|
||||
}
|
||||
if let Some(name) = input.milestone_name.as_deref() {
|
||||
input.option.milestone = Some(self.resolve_milestone_id(repository, name).await?);
|
||||
}
|
||||
let remove = self
|
||||
.resolve_label_ids(repository, &input.remove_labels)
|
||||
.await?;
|
||||
let add = self
|
||||
.resolve_label_ids(repository, &input.add_labels)
|
||||
.await?;
|
||||
let mut issues = Vec::with_capacity(numbers.len());
|
||||
for &number in numbers {
|
||||
positive(number, "issue number")?;
|
||||
issues.push(
|
||||
self.apply_issue_edit(repository, number, &input, &remove, &add)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
async fn apply_issue_edit(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
input: &EditIssue,
|
||||
remove: &[i64],
|
||||
add: &[i64],
|
||||
) -> Result<models::Issue> {
|
||||
let configuration = self.configuration();
|
||||
let mut edit = input.option.clone();
|
||||
if !input.add_assignees.is_empty() {
|
||||
let mut assignees = self
|
||||
.issue(repository, number)
|
||||
.await?
|
||||
.assignees
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|user| user.login.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for assignee in &input.add_assignees {
|
||||
if !assignees.contains(assignee) {
|
||||
assignees.push(assignee.clone());
|
||||
}
|
||||
}
|
||||
edit.assignees = Some(assignees);
|
||||
}
|
||||
if edit != models::EditIssueOption::default() {
|
||||
apis::issue_api::issue_edit_issue(
|
||||
&configuration,
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
Some(edit),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)?;
|
||||
}
|
||||
if let Some(labels) = &input.replace_labels {
|
||||
apis::issue_api::issue_replace_labels(
|
||||
&configuration,
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
Some(label_option(labels)),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)?;
|
||||
} else {
|
||||
for &id in remove {
|
||||
apis::issue_api::issue_remove_label(
|
||||
&configuration,
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
id,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)?;
|
||||
}
|
||||
if !add.is_empty() {
|
||||
apis::issue_api::issue_add_label(
|
||||
&configuration,
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
Some(label_option(add)),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)?;
|
||||
}
|
||||
}
|
||||
self.issue(repository, number).await
|
||||
}
|
||||
|
||||
pub async fn edit_issue_draft(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
draft: IssueDraft,
|
||||
) -> Result<models::Issue> {
|
||||
self.edit_issues(repository, &[number], edit_issue_draft(draft))
|
||||
.await
|
||||
.map(|mut issues| issues.remove(0))
|
||||
}
|
||||
|
||||
pub async fn set_issue_state(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
numbers: &[i64],
|
||||
state: &str,
|
||||
) -> Result<Vec<models::Issue>> {
|
||||
if !matches!(state, "open" | "closed") {
|
||||
return Err(Error::InvalidInput(
|
||||
"issue state must be open or closed".into(),
|
||||
));
|
||||
}
|
||||
self.edit_issues(
|
||||
repository,
|
||||
numbers,
|
||||
EditIssue {
|
||||
option: models::EditIssueOption {
|
||||
state: Some(state.into()),
|
||||
..Default::default()
|
||||
},
|
||||
replace_labels: None,
|
||||
add_labels: Vec::new(),
|
||||
remove_labels: Vec::new(),
|
||||
add_assignees: Vec::new(),
|
||||
milestone_name: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_issue(&self, repository: &RepositoryId, number: i64) -> Result<()> {
|
||||
positive(number, "issue number")?;
|
||||
apis::issue_api::issue_delete(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn issue_comments(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
) -> Result<Vec<models::Comment>> {
|
||||
positive(number, "issue number")?;
|
||||
apis::issue_api::issue_get_comments(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn create_issue_comment(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
body: String,
|
||||
) -> Result<models::Comment> {
|
||||
positive(number, "issue number")?;
|
||||
if body.trim().is_empty() {
|
||||
return Err(Error::InvalidInput("comment must not be empty".into()));
|
||||
}
|
||||
apis::issue_api::issue_create_comment(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
Some(models::CreateIssueCommentOption::new(body)),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn save_issue_comment(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
comment_id: Option<i64>,
|
||||
body: String,
|
||||
) -> Result<models::Comment> {
|
||||
let Some(id) = comment_id else {
|
||||
return self.create_issue_comment(repository, number, body).await;
|
||||
};
|
||||
positive(id, "comment id")?;
|
||||
if body.trim().is_empty() {
|
||||
return Err(Error::InvalidInput("comment must not be empty".into()));
|
||||
}
|
||||
let configuration = self.configuration();
|
||||
let (comment, viewer) = tokio::join!(
|
||||
apis::issue_api::issue_get_comment(
|
||||
&configuration,
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
id,
|
||||
),
|
||||
self.current_user(),
|
||||
);
|
||||
let comment = comment.map_err(Error::generated)?;
|
||||
let viewer = viewer?;
|
||||
if !comment_can_edit(&comment, viewer.id) || !comment_belongs_to_issue(&comment, number) {
|
||||
return Err(Error::Forbidden(
|
||||
"You can only edit your own comments.".into(),
|
||||
));
|
||||
}
|
||||
apis::issue_api::issue_edit_comment(
|
||||
&configuration,
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
id,
|
||||
Some(models::EditIssueCommentOption::new(body)),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn comment_can_edit(comment: &models::Comment, viewer_id: Option<i64>) -> bool {
|
||||
matches!(
|
||||
(
|
||||
comment.id,
|
||||
comment.user.as_ref().and_then(|user| user.id),
|
||||
viewer_id,
|
||||
),
|
||||
(Some(_), Some(author), Some(viewer)) if author == viewer
|
||||
)
|
||||
}
|
||||
|
||||
fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption {
|
||||
models::CreateIssueOption {
|
||||
body: Some(draft.body),
|
||||
due_date: draft.due_date,
|
||||
labels: Some(draft.label_ids),
|
||||
milestone: draft.milestone_id,
|
||||
..models::CreateIssueOption::new(draft.title)
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_issue_draft(draft: IssueDraft) -> EditIssue {
|
||||
EditIssue {
|
||||
option: models::EditIssueOption {
|
||||
body: Some(draft.body),
|
||||
due_date: draft.due_date.clone(),
|
||||
milestone: Some(draft.milestone_id.unwrap_or_default()),
|
||||
title: Some(draft.title),
|
||||
unset_due_date: draft.due_date.is_none().then_some(true),
|
||||
..models::EditIssueOption::new()
|
||||
},
|
||||
replace_labels: Some(draft.label_ids),
|
||||
add_labels: Vec::new(),
|
||||
remove_labels: Vec::new(),
|
||||
add_assignees: Vec::new(),
|
||||
milestone_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn label_option(labels: &[i64]) -> models::IssueLabelsOption {
|
||||
models::IssueLabelsOption {
|
||||
labels: Some(
|
||||
labels
|
||||
.iter()
|
||||
.copied()
|
||||
.map(serde_json::Value::from)
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_belongs_to_issue(comment: &models::Comment, number: i64) -> bool {
|
||||
comment
|
||||
.issue_url
|
||||
.as_deref()
|
||||
.map(|url| url.trim_end_matches('/'))
|
||||
.and_then(|url| url.rsplit('/').next())
|
||||
.and_then(|index| index.parse().ok())
|
||||
== Some(number)
|
||||
}
|
||||
|
||||
fn positive(value: i64, name: &str) -> Result<()> {
|
||||
if value < 1 {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"{name} must be a positive integer"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scopes_comments_and_builds_label_payloads() {
|
||||
let comment = models::Comment {
|
||||
issue_url: Some("https://example.test/api/v1/repos/a/b/issues/7".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(comment_belongs_to_issue(&comment, 7));
|
||||
assert!(!comment_belongs_to_issue(&comment, 8));
|
||||
assert!(!comment_can_edit(&comment, Some(1)));
|
||||
let owned = models::Comment {
|
||||
id: Some(3),
|
||||
user: Some(Box::new(models::User {
|
||||
id: Some(1),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(comment_can_edit(&owned, Some(1)));
|
||||
assert_eq!(label_option(&[2, 4]).labels.unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_issue_drafts_without_losing_clear_operations() {
|
||||
let draft = IssueDraft {
|
||||
title: "Title".into(),
|
||||
body: "Body".into(),
|
||||
label_ids: vec![2, 4],
|
||||
milestone_id: None,
|
||||
due_date: None,
|
||||
};
|
||||
let create = create_issue_option(draft.clone());
|
||||
assert_eq!(create.title, "Title");
|
||||
assert_eq!(create.body.as_deref(), Some("Body"));
|
||||
assert_eq!(create.labels, Some(vec![2, 4]));
|
||||
let edit = edit_issue_draft(draft);
|
||||
assert_eq!(edit.option.milestone, Some(0));
|
||||
assert_eq!(edit.option.unset_due_date, Some(true));
|
||||
assert_eq!(edit.replace_labels, Some(vec![2, 4]));
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,33 @@ use reqwest::{
|
||||
pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub use gitea_openapi::{apis, models};
|
||||
use gitea_openapi::apis;
|
||||
pub use gitea_openapi::models;
|
||||
pub use models::{Repository, ServerVersion, User};
|
||||
|
||||
pub mod activity;
|
||||
pub mod diff;
|
||||
mod domain;
|
||||
mod issues;
|
||||
mod milestones;
|
||||
mod pulls;
|
||||
mod repositories;
|
||||
|
||||
pub use domain::{
|
||||
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
|
||||
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
|
||||
api_date, parse_api_date,
|
||||
};
|
||||
pub use issues::comment_can_edit;
|
||||
pub use pulls::{PullFileSource, pull_file_source, pull_state};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Configuration(String),
|
||||
InvalidInput(String),
|
||||
Forbidden(String),
|
||||
Transport(reqwest::Error),
|
||||
Generated(String),
|
||||
Api { status: StatusCode, message: String },
|
||||
@@ -24,6 +43,8 @@ impl fmt::Display for Error {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Configuration(message) => formatter.write_str(message),
|
||||
Self::InvalidInput(message) => formatter.write_str(message),
|
||||
Self::Forbidden(message) => formatter.write_str(message),
|
||||
Self::Transport(error) => error.fmt(formatter),
|
||||
Self::Generated(message) => formatter.write_str(message),
|
||||
Self::Api { status, message } => {
|
||||
@@ -48,6 +69,12 @@ impl From<reqwest::Error> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub(crate) fn generated(error: impl fmt::Display) -> Self {
|
||||
Self::Generated(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Client {
|
||||
api_url: Url,
|
||||
@@ -95,7 +122,7 @@ impl Client {
|
||||
}
|
||||
|
||||
/// Returns an authenticated configuration for every generated typed API.
|
||||
pub fn configuration(&self) -> apis::configuration::Configuration {
|
||||
pub(crate) fn configuration(&self) -> apis::configuration::Configuration {
|
||||
apis::configuration::Configuration {
|
||||
base_path: self.api_url.as_str().trim_end_matches('/').into(),
|
||||
client: self.http.clone(),
|
||||
@@ -201,7 +228,6 @@ mod tests {
|
||||
client.configuration().base_path,
|
||||
"https://example.com/gitea/api/v1"
|
||||
);
|
||||
let _typed_query = apis::issue_api::issue_list_issues;
|
||||
let _typed_model = models::Issue::default();
|
||||
}
|
||||
}
|
||||
|
||||
238
crates/gitea/src/milestones.rs
Normal file
238
crates/gitea/src/milestones.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
Client, Error, Method, Result,
|
||||
domain::{DEFAULT_PAGE_SIZE, IssueQuery, MilestoneDetails, MilestoneDraft, Page, RepositoryId},
|
||||
models,
|
||||
};
|
||||
use gitea_openapi::apis;
|
||||
|
||||
impl Client {
|
||||
pub async fn milestones_page(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
page: i32,
|
||||
limit: i32,
|
||||
) -> Result<Page<models::Milestone>> {
|
||||
if page < 1 || limit < 1 {
|
||||
return Err(Error::InvalidInput(
|
||||
"milestone page and limit must be positive".into(),
|
||||
));
|
||||
}
|
||||
apis::issue_api::issue_get_milestones_list(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
Some("all"),
|
||||
None,
|
||||
Some(page),
|
||||
Some(limit),
|
||||
)
|
||||
.await
|
||||
.map(|items| Page::from_items(items, limit))
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn milestones(&self, repository: &RepositoryId) -> Result<Vec<models::Milestone>> {
|
||||
let mut milestones = Vec::new();
|
||||
for page in 1.. {
|
||||
let batch = self.milestones_page(repository, page, 100).await?;
|
||||
milestones.extend(batch.items);
|
||||
if !batch.has_more {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(milestones)
|
||||
}
|
||||
|
||||
pub async fn resolve_milestone_id(&self, repository: &RepositoryId, name: &str) -> Result<i64> {
|
||||
self.milestones(repository)
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|milestone| milestone.title.as_deref() == Some(name))
|
||||
.and_then(|milestone| milestone.id)
|
||||
.ok_or_else(|| Error::InvalidInput(format!("unknown milestone {name:?}")))
|
||||
}
|
||||
|
||||
pub async fn milestone(&self, repository: &RepositoryId, id: i64) -> Result<models::Milestone> {
|
||||
if id < 1 {
|
||||
return Err(Error::InvalidInput(
|
||||
"milestone id must be a positive integer".into(),
|
||||
));
|
||||
}
|
||||
apis::issue_api::issue_get_milestone(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
&id.to_string(),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn milestone_details(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
id: i64,
|
||||
page: i32,
|
||||
) -> Result<MilestoneDetails> {
|
||||
let milestone = self.milestone(repository, id).await?;
|
||||
let base = IssueQuery {
|
||||
state: "all".into(),
|
||||
milestones: milestone.title.clone(),
|
||||
page,
|
||||
limit: DEFAULT_PAGE_SIZE,
|
||||
..Default::default()
|
||||
};
|
||||
let pulls_query = IssueQuery {
|
||||
kind: "pulls".into(),
|
||||
..base.clone()
|
||||
};
|
||||
let issues = self.issues(repository, &base);
|
||||
let pulls = self.issues(repository, &pulls_query);
|
||||
let (issues, pulls) = tokio::try_join!(issues, pulls)?;
|
||||
let has_more = issues.has_more || pulls.has_more;
|
||||
let mut pulls = pulls.items;
|
||||
for pull in &mut pulls {
|
||||
pull.repository.get_or_insert_with(|| {
|
||||
Box::new(models::RepositoryMeta {
|
||||
name: Some(repository.repository.clone()),
|
||||
owner: Some(repository.owner.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
}
|
||||
Ok(MilestoneDetails {
|
||||
milestone,
|
||||
has_more,
|
||||
issues: issues.items,
|
||||
pulls,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_milestone(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
option: models::CreateMilestoneOption,
|
||||
) -> Result<models::Milestone> {
|
||||
if option
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"milestone title must not be empty".into(),
|
||||
));
|
||||
}
|
||||
apis::issue_api::issue_create_milestone(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
Some(option),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn edit_milestone(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
id: &str,
|
||||
option: models::EditMilestoneOption,
|
||||
) -> Result<models::Milestone> {
|
||||
apis::issue_api::issue_edit_milestone(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
id,
|
||||
Some(option),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn save_milestone(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
id: Option<i64>,
|
||||
draft: MilestoneDraft,
|
||||
) -> Result<models::Milestone> {
|
||||
if draft.title.trim().is_empty() {
|
||||
return Err(Error::InvalidInput(
|
||||
"milestone title must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let endpoint = match id {
|
||||
Some(id) if id > 0 => format!(
|
||||
"repos/{}/{}/milestones/{id}",
|
||||
apis::urlencode(&repository.owner),
|
||||
apis::urlencode(&repository.repository)
|
||||
),
|
||||
Some(_) => {
|
||||
return Err(Error::InvalidInput(
|
||||
"milestone id must be a positive integer".into(),
|
||||
));
|
||||
}
|
||||
None => format!(
|
||||
"repos/{}/{}/milestones",
|
||||
apis::urlencode(&repository.owner),
|
||||
apis::urlencode(&repository.repository)
|
||||
),
|
||||
};
|
||||
let request = self
|
||||
.request(
|
||||
if id.is_some() {
|
||||
Method::PATCH
|
||||
} else {
|
||||
Method::POST
|
||||
},
|
||||
&endpoint,
|
||||
)?
|
||||
.json(&MilestoneRequest {
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
due_on: draft.due_on,
|
||||
});
|
||||
self.execute(request)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub async fn delete_milestone(&self, repository: &RepositoryId, id: &str) -> Result<()> {
|
||||
apis::issue_api::issue_delete_milestone(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
id,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MilestoneRequest {
|
||||
title: String,
|
||||
description: String,
|
||||
due_on: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn milestone_request_preserves_due_date_clear() {
|
||||
let value = serde_json::to_value(MilestoneRequest {
|
||||
title: "Release".into(),
|
||||
description: String::new(),
|
||||
due_on: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(value.get("due_on").unwrap().is_null());
|
||||
}
|
||||
}
|
||||
342
crates/gitea/src/pulls.rs
Normal file
342
crates/gitea/src/pulls.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{
|
||||
Client, Error, Result,
|
||||
domain::{DEFAULT_PAGE_SIZE, Page, PullDetails, RepositoryId},
|
||||
models,
|
||||
};
|
||||
use gitea_openapi::apis;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PullFileSource {
|
||||
Branch(String),
|
||||
Commit(String),
|
||||
Request,
|
||||
}
|
||||
|
||||
pub fn pull_state(pull: &models::PullRequest) -> &str {
|
||||
if pull.merged.unwrap_or(false) {
|
||||
"merged"
|
||||
} else if pull.draft.unwrap_or(false) {
|
||||
"draft"
|
||||
} else {
|
||||
pull.state.as_deref().unwrap_or("unknown")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pull_file_source(pull: &models::PullRequest) -> PullFileSource {
|
||||
if pull.state.as_deref() == Some("open") {
|
||||
PullFileSource::Branch(
|
||||
pull.head
|
||||
.as_ref()
|
||||
.and_then(|head| head.r#ref.clone().or(head.label.clone()))
|
||||
.unwrap_or_else(|| "head branch".into()),
|
||||
)
|
||||
} else if let Some(sha) = pull.merge_commit_sha.as_deref() {
|
||||
PullFileSource::Commit(sha.chars().take(8).collect())
|
||||
} else {
|
||||
PullFileSource::Request
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub async fn repository_pulls(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
state: &str,
|
||||
page: i32,
|
||||
limit: i32,
|
||||
) -> Result<Page<models::PullRequest>> {
|
||||
validate_page(page, limit)?;
|
||||
apis::repository_api::repo_list_pull_requests(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
None,
|
||||
Some(state),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(page),
|
||||
Some(limit),
|
||||
)
|
||||
.await
|
||||
.map(|items| Page::from_items(items, limit))
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn search_pulls(
|
||||
&self,
|
||||
state: &str,
|
||||
milestone: Option<&str>,
|
||||
page: i32,
|
||||
limit: i32,
|
||||
) -> Result<Page<models::Issue>> {
|
||||
validate_page(page, limit)?;
|
||||
let owner = self
|
||||
.current_user()
|
||||
.await?
|
||||
.login
|
||||
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
|
||||
apis::issue_api::issue_search_issues(
|
||||
&self.configuration(),
|
||||
Some(state),
|
||||
None,
|
||||
milestone,
|
||||
None,
|
||||
None,
|
||||
Some("pulls"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&owner),
|
||||
None,
|
||||
Some(page),
|
||||
Some(limit),
|
||||
)
|
||||
.await
|
||||
.map(|items| Page::from_items(items, limit))
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn pull_milestones(&self) -> Result<Vec<String>> {
|
||||
let (open, closed) = tokio::try_join!(
|
||||
self.all_searched_pulls("open"),
|
||||
self.all_searched_pulls("closed"),
|
||||
)?;
|
||||
Ok(open
|
||||
.into_iter()
|
||||
.chain(closed)
|
||||
.filter_map(|pull| pull.milestone?.title)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn all_searched_pulls(&self, state: &str) -> Result<Vec<models::Issue>> {
|
||||
let mut pulls = Vec::new();
|
||||
for page in 1.. {
|
||||
let batch = self
|
||||
.search_pulls(state, None, page, DEFAULT_PAGE_SIZE)
|
||||
.await?;
|
||||
pulls.extend(batch.items);
|
||||
if !batch.has_more {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(pulls)
|
||||
}
|
||||
|
||||
pub async fn pull(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
) -> Result<models::PullRequest> {
|
||||
positive(number, "pull request number")?;
|
||||
apis::repository_api::repo_get_pull_request(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn pull_details(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
page: i32,
|
||||
) -> Result<PullDetails> {
|
||||
let pull = self.pull(repository, number);
|
||||
let comments = async {
|
||||
if page == 1 {
|
||||
self.issue_comments(repository, number).await
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
};
|
||||
let files = self.pull_files_page(repository, number, page, DEFAULT_PAGE_SIZE);
|
||||
let (pull, comments, files) = tokio::try_join!(pull, comments, files)?;
|
||||
Ok(PullDetails {
|
||||
pull,
|
||||
comments,
|
||||
has_more: files.has_more,
|
||||
files: files.items,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_pull(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
option: models::CreatePullRequestOption,
|
||||
) -> Result<models::PullRequest> {
|
||||
if [
|
||||
option.title.as_deref(),
|
||||
option.head.as_deref(),
|
||||
option.base.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.any(|value| value.unwrap_or_default().trim().is_empty())
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"pull request title, head, and base must not be empty".into(),
|
||||
));
|
||||
}
|
||||
apis::repository_api::repo_create_pull_request(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
Some(option),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn edit_pull(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
option: models::EditPullRequestOption,
|
||||
) -> Result<models::PullRequest> {
|
||||
positive(number, "pull request number")?;
|
||||
apis::repository_api::repo_edit_pull_request(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
Some(option),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn merge_pull(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
option: models::MergePullRequestOption,
|
||||
) -> Result<()> {
|
||||
positive(number, "pull request number")?;
|
||||
apis::repository_api::repo_merge_pull_request(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
Some(option),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn pull_commits(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
) -> Result<Vec<models::Commit>> {
|
||||
positive(number, "pull request number")?;
|
||||
apis::repository_api::repo_get_pull_request_commits(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn pull_files_page(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
page: i32,
|
||||
limit: i32,
|
||||
) -> Result<Page<models::ChangedFile>> {
|
||||
positive(number, "pull request number")?;
|
||||
validate_page(page, limit)?;
|
||||
apis::repository_api::repo_get_pull_request_files(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
None,
|
||||
None,
|
||||
Some(page),
|
||||
Some(limit),
|
||||
)
|
||||
.await
|
||||
.map(|items| Page::from_items(items, limit))
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn pull_files(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
) -> Result<Vec<models::ChangedFile>> {
|
||||
self.pull_files_page(repository, number, 1, DEFAULT_PAGE_SIZE)
|
||||
.await
|
||||
.map(|page| page.items)
|
||||
}
|
||||
|
||||
pub async fn pull_reviews(
|
||||
&self,
|
||||
repository: &RepositoryId,
|
||||
number: i64,
|
||||
) -> Result<Vec<models::PullReview>> {
|
||||
positive(number, "pull request number")?;
|
||||
apis::repository_api::repo_list_pull_reviews(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
|
||||
pub async fn pull_diff(&self, repository: &RepositoryId, number: i64) -> Result<String> {
|
||||
positive(number, "pull request number")?;
|
||||
apis::repository_api::repo_download_pull_diff_or_patch(
|
||||
&self.configuration(),
|
||||
&repository.owner,
|
||||
&repository.repository,
|
||||
number,
|
||||
"diff",
|
||||
Some(false),
|
||||
)
|
||||
.await
|
||||
.map_err(Error::generated)
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
fn positive(value: i64, name: &str) -> Result<()> {
|
||||
if value < 1 {
|
||||
return Err(Error::InvalidInput(format!(
|
||||
"{name} must be a positive integer"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
478
crates/gitea/src/repositories.rs
Normal file
478
crates/gitea/src/repositories.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user