Centralize Gitea domain workflows

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,11 @@
use std::collections::{BTreeMap, BTreeSet};
use gotcha_gitea::models;
pub use gotcha_gitea::{
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
parse_api_date,
};
use serde::{Deserialize, Serialize};
pub const PAGE_SIZE: i32 = 30;
pub struct Page<T> {
pub items: Vec<T>,
pub has_more: bool,
}
impl<T> Page<T> {
pub fn from_items(items: Vec<T>) -> Self {
Self {
has_more: items.len() == PAGE_SIZE as usize,
items,
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AppearanceMode {
@@ -128,16 +115,6 @@ pub struct RepositoryData {
pub default_branch: String,
}
#[derive(Clone)]
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(Default)]
pub struct State {
pub preferences: Preferences,
@@ -145,25 +122,6 @@ pub struct State {
pub repositories: Vec<RepositoryData>,
}
pub struct HomeData {
pub activities: Vec<models::Activity>,
pub heatmap: Vec<models::UserHeatmapData>,
pub has_more: bool,
}
pub struct IssueDetails {
pub issue: models::Issue,
pub comments: Vec<models::Comment>,
pub viewer_id: Option<i64>,
pub has_more: bool,
}
pub struct IssueEditorData {
pub issue: Option<models::Issue>,
pub labels: Vec<models::Label>,
pub milestones: Vec<models::Milestone>,
}
pub struct IssueDraft {
pub title: String,
pub body: String,
@@ -172,26 +130,12 @@ pub struct IssueDraft {
pub due_date: Option<i64>,
}
pub struct MilestoneDetails {
pub milestone: models::Milestone,
pub issues: Vec<models::Issue>,
pub pulls: Vec<models::Issue>,
pub has_more: bool,
}
pub struct MilestoneDraft {
pub title: String,
pub description: String,
pub due_date: Option<i64>,
}
pub struct PullDetails {
pub pull: models::PullRequest,
pub comments: Vec<models::Comment>,
pub files: Vec<models::ChangedFile>,
pub has_more: bool,
}
pub fn open_status() -> String {
"open".into()
}
@@ -221,24 +165,6 @@ pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
era * 146_097 + day_of_era - 719_468
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -263,18 +189,4 @@ mod tests {
.is_active("open")
);
}
#[test]
fn issue_dates_round_trip_and_reject_invalid_dates() {
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);
assert_eq!(parse_api_date("not-a-date"), None);
}
#[test]
fn full_api_pages_expose_more_results() {
assert!(Page::from_items(vec![(); PAGE_SIZE as usize]).has_more);
assert!(!Page::from_items(vec![(); PAGE_SIZE as usize - 1]).has_more);
}
}

View File

@@ -3,9 +3,7 @@ use std::sync::{Arc, Mutex};
use gotcha_gitea::Client;
use thiserror::Error;
mod activity;
mod api;
mod diff;
mod domain;
mod presentation;
mod storage;

View File

@@ -1,11 +1,10 @@
use gotcha_gitea::models;
use gotcha_gitea::{
PullFileSource, activity, comment_can_edit, diff, models, pull_file_source, pull_state,
};
use crate::{
activity,
domain::{
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
},
use crate::domain::{
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
};
#[derive(Clone, uniffi::Record)]
@@ -630,13 +629,7 @@ pub fn pull_page(details: PullDetails) -> PullPage {
.as_ref()
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
.unwrap_or("base");
let state = if pull.merged.unwrap_or(false) {
"merged"
} else if pull.draft.unwrap_or(false) {
"draft"
} else {
pull.state.as_deref().unwrap_or("unknown")
};
let state = pull_state(pull);
PullPage {
title: pull
.title
@@ -760,7 +753,7 @@ pub fn repository_file_page(path: &str, data: Vec<u8>) -> RepositoryFilePage {
}
}
pub fn diff_page(path: &str, diff: crate::diff::Parsed) -> DiffPage {
pub fn diff_page(path: &str, diff: diff::Parsed) -> DiffPage {
DiffPage {
title: path.rsplit('/').next().unwrap_or(path).into(),
columns: diff.columns.min(u32::MAX as usize) as u32,
@@ -1040,14 +1033,7 @@ fn comment_row(comment: &models::Comment, viewer_id: Option<i64>) -> CommentRow
.as_deref()
.or(comment.created_at.as_deref()),
),
can_edit: matches!(
(
comment.id,
comment.user.as_ref().and_then(|user| user.id),
viewer_id,
),
(Some(_), Some(author), Some(viewer)) if author == viewer
),
can_edit: comment_can_edit(comment, viewer_id),
}
}
@@ -1059,17 +1045,10 @@ fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
}
fn pull_files_ref(pull: &models::PullRequest) -> String {
if pull.state.as_deref() == Some("open") {
let branch = pull
.head
.as_ref()
.and_then(|head| head.r#ref.as_deref().or(head.label.as_deref()))
.unwrap_or("head branch");
format!("Files on {branch}")
} else if let Some(sha) = pull.merge_commit_sha.as_deref() {
format!("Files at merge commit {}", &sha[..sha.len().min(8)])
} else {
"Files from pull request".into()
match pull_file_source(pull) {
PullFileSource::Branch(branch) => format!("Files on {branch}"),
PullFileSource::Commit(sha) => format!("Files at merge commit {sha}"),
PullFileSource::Request => "Files from pull request".into(),
}
}

View File

@@ -10,7 +10,7 @@ use std::{
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use gotcha_gitea::{Client, Url};
use gotcha_gitea::{Client, RepositoryId, Url};
use serde::{Deserialize, Serialize};
type Result<T> = std::result::Result<T, String>;
@@ -36,26 +36,7 @@ pub struct Selection {
pub repository: Option<RepositoryScope>,
}
#[derive(Clone)]
pub struct RepositoryScope {
pub owner: String,
pub repository: String,
}
impl RepositoryScope {
pub fn parse(value: &str) -> Result<Self> {
let (owner, repository) = value
.split_once('/')
.ok_or("repository must be OWNER/REPOSITORY")?;
if owner.is_empty() || repository.is_empty() || repository.contains('/') {
return Err("repository must be OWNER/REPOSITORY".into());
}
Ok(Self {
owner: owner.into(),
repository: repository.into(),
})
}
}
pub type RepositoryScope = RepositoryId;
impl Config {
pub fn load() -> Result<Self> {

View File

@@ -1,12 +1,13 @@
use std::{error::Error, io::Read};
use gotcha_gitea::{
Client, apis,
Client, CreateIssue, EditIssue, IssueQuery,
models::{
ChangedFile, Comment, Commit, CreateIssueCommentOption, CreateIssueOption,
CreateMilestoneOption, CreatePullRequestOption, EditIssueOption, EditMilestoneOption,
EditPullRequestOption, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview,
ChangedFile, Comment, Commit, CreateIssueOption, CreateMilestoneOption,
CreatePullRequestOption, EditIssueOption, EditMilestoneOption, EditPullRequestOption,
Issue, MergePullRequestOption, Milestone, PullRequest, PullReview,
},
pull_state,
};
use serde::{Deserialize, de::DeserializeOwned};
@@ -248,69 +249,48 @@ pub async fn run(
selection: &Selection,
client: &Client,
) -> Result<bool, Box<dyn Error>> {
let configuration = client.configuration();
match command {
[domain, action, arguments @ ..] if domain == "issue" && action == "list" => {
let options = parse_issue_list(arguments)?;
let scope = scope(selection, options.repository.as_deref())?;
let issues = apis::issue_api::issue_list_issues(
&configuration,
&scope.owner,
&scope.repository,
Some(&options.state),
options.labels.as_deref(),
options.keyword.as_deref(),
Some(&options.kind),
options.milestones.as_deref(),
options.from,
options.until,
options.author.as_deref(),
options.assignee.as_deref(),
options.mentions.as_deref(),
Some(options.page),
Some(options.limit),
)
.await?;
print_issues(&issues);
let issues = client
.issues(
&scope,
&IssueQuery {
state: options.state,
labels: options.labels,
keyword: options.keyword,
kind: options.kind,
milestones: options.milestones,
from: options.from,
until: options.until,
author: options.author,
assignee: options.assignee,
mentions: options.mentions,
page: options.page,
limit: options.limit,
},
)
.await?;
print_issues(&issues.items);
}
[domain, action, index] if domain == "issue" && action == "show" => {
let scope = scope(selection, None)?;
print_issue(
&apis::issue_api::issue_get_issue(
&configuration,
&scope.owner,
&scope.repository,
number(index)?,
)
.await?,
);
print_issue(&client.issue(&scope, number(index)?).await?);
}
[domain, action, index, repository] if domain == "issue" && action == "show" => {
let scope = scope(selection, Some(repository))?;
print_issue(
&apis::issue_api::issue_get_issue(
&configuration,
&scope.owner,
&scope.repository,
number(index)?,
)
.await?,
);
print_issue(&client.issue(&scope, number(index)?).await?);
}
[domain, action] if domain == "issue" && action == "create" => {
create_issue(&configuration, &scope(selection, None)?).await?;
create_issue(client, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "issue" && action == "create" => {
create_issue(&configuration, &scope(selection, Some(repository))?).await?;
create_issue(client, &scope(selection, Some(repository))?).await?;
}
[domain, action, arguments @ ..] if domain == "issue" && action == "edit" => {
let (indexes, repository) = issue_targets(arguments)?;
edit_issues(
&configuration,
&scope(selection, repository.as_deref())?,
&indexes,
)
.await?;
edit_issues(client, &scope(selection, repository.as_deref())?, &indexes).await?;
}
[domain, action, arguments @ ..]
if domain == "issue" && matches!(action.as_str(), "close" | "reopen") =>
@@ -318,7 +298,7 @@ pub async fn run(
let (indexes, repository) = issue_targets(arguments)?;
let scope = scope(selection, repository.as_deref())?;
set_issue_state(
&configuration,
client,
&scope,
&indexes,
if action == "close" { "closed" } else { "open" },
@@ -326,129 +306,93 @@ pub async fn run(
.await?;
}
[domain, action, index] if domain == "issue" && action == "delete" => {
delete_issue(&configuration, &scope(selection, None)?, number(index)?).await?;
delete_issue(client, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "issue" && action == "delete" => {
delete_issue(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
delete_issue(client, &scope(selection, Some(repository))?, number(index)?).await?;
}
[domain, action, index] if domain == "issue" && action == "comments" => {
list_comments(&configuration, &scope(selection, None)?, number(index)?).await?;
list_comments(client, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "issue" && action == "comments" => {
list_comments(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
list_comments(client, &scope(selection, Some(repository))?, number(index)?).await?;
}
[domain, action, index] if domain == "issue" && action == "comment" => {
create_comment(&configuration, &scope(selection, None)?, number(index)?).await?;
create_comment(client, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "issue" && action == "comment" => {
create_comment(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
create_comment(client, &scope(selection, Some(repository))?, number(index)?).await?;
}
[domain, action] if domain == "milestone" && action == "list" => {
list_milestones(&configuration, &scope(selection, None)?).await?;
list_milestones(client, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "milestone" && action == "list" => {
list_milestones(&configuration, &scope(selection, Some(repository))?).await?;
list_milestones(client, &scope(selection, Some(repository))?).await?;
}
[domain, action, id] if domain == "milestone" && action == "show" => {
show_milestone(&configuration, &scope(selection, None)?, id).await?;
show_milestone(client, &scope(selection, None)?, id).await?;
}
[domain, action, id, repository] if domain == "milestone" && action == "show" => {
show_milestone(&configuration, &scope(selection, Some(repository))?, id).await?;
show_milestone(client, &scope(selection, Some(repository))?, id).await?;
}
[domain, action] if domain == "milestone" && action == "create" => {
create_milestone(&configuration, &scope(selection, None)?).await?;
create_milestone(client, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "milestone" && action == "create" => {
create_milestone(&configuration, &scope(selection, Some(repository))?).await?;
create_milestone(client, &scope(selection, Some(repository))?).await?;
}
[domain, action, id] if domain == "milestone" && action == "edit" => {
edit_milestone(&configuration, &scope(selection, None)?, id).await?;
edit_milestone(client, &scope(selection, None)?, id).await?;
}
[domain, action, id, repository] if domain == "milestone" && action == "edit" => {
edit_milestone(&configuration, &scope(selection, Some(repository))?, id).await?;
edit_milestone(client, &scope(selection, Some(repository))?, id).await?;
}
[domain, action, id] if domain == "milestone" && action == "delete" => {
delete_milestone(&configuration, &scope(selection, None)?, id).await?;
delete_milestone(client, &scope(selection, None)?, id).await?;
}
[domain, action, id, repository] if domain == "milestone" && action == "delete" => {
delete_milestone(&configuration, &scope(selection, Some(repository))?, id).await?;
delete_milestone(client, &scope(selection, Some(repository))?, id).await?;
}
[domain, action] if domain == "pull" && action == "list" => {
list_pulls(&configuration, &scope(selection, None)?).await?;
list_pulls(client, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "pull" && action == "list" => {
list_pulls(&configuration, &scope(selection, Some(repository))?).await?;
list_pulls(client, &scope(selection, Some(repository))?).await?;
}
[domain, action, index] if domain == "pull" && action == "show" => {
show_pull(&configuration, &scope(selection, None)?, number(index)?).await?;
show_pull(client, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "pull" && action == "show" => {
show_pull(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
show_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
}
[domain, action] if domain == "pull" && action == "create" => {
create_pull(&configuration, &scope(selection, None)?).await?;
create_pull(client, &scope(selection, None)?).await?;
}
[domain, action, repository] if domain == "pull" && action == "create" => {
create_pull(&configuration, &scope(selection, Some(repository))?).await?;
create_pull(client, &scope(selection, Some(repository))?).await?;
}
[domain, action, index] if domain == "pull" && action == "edit" => {
edit_pull(&configuration, &scope(selection, None)?, number(index)?).await?;
edit_pull(client, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "pull" && action == "edit" => {
edit_pull(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
edit_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
}
[domain, action, index] if domain == "pull" && action == "merge" => {
merge_pull(&configuration, &scope(selection, None)?, number(index)?).await?;
merge_pull(client, &scope(selection, None)?, number(index)?).await?;
}
[domain, action, index, repository] if domain == "pull" && action == "merge" => {
merge_pull(
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
merge_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
}
[domain, action, index]
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
{
pull_details(
&configuration,
&scope(selection, None)?,
number(index)?,
action,
)
.await?;
pull_details(client, &scope(selection, None)?, number(index)?, action).await?;
}
[domain, action, index, repository]
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
{
pull_details(
&configuration,
client,
&scope(selection, Some(repository))?,
number(index)?,
action,
@@ -497,475 +441,193 @@ fn parse_yaml<T: DeserializeOwned>(input: &str) -> Result<T, Box<dyn Error>> {
Ok(serde_yaml::from_str(input)?)
}
async fn create_issue(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let mut input: CreateIssueInput = read_yaml()?;
if input.issue.title.trim().is_empty() {
return Err("issue title must not be empty".into());
}
if input.issue.milestone.is_some() && input.milestone_name.is_some() {
return Err("use either milestone or milestone_name, not both".into());
}
if let Some(name) = input.milestone_name.as_deref() {
input.issue.milestone = Some(resolve_milestone_id(configuration, scope, name).await?);
}
if !input.label_names.is_empty() {
input
.issue
.labels
.get_or_insert_default()
.extend(resolve_label_ids(configuration, scope, &input.label_names).await?);
}
async fn create_issue(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
let input: CreateIssueInput = read_yaml()?;
print_issue(
&apis::issue_api::issue_create_issue(
configuration,
&scope.owner,
&scope.repository,
Some(input.issue),
)
.await?,
);
Ok(())
}
async fn edit_issues(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
indexes: &[i64],
) -> Result<(), Box<dyn Error>> {
let mut input: EditIssueInput = read_yaml()?;
if input.issue.milestone.is_some() && input.milestone_name.is_some() {
return Err("use either milestone or milestone_name, not both".into());
}
if let Some(name) = input.milestone_name.as_deref() {
input.issue.milestone = Some(resolve_milestone_id(configuration, scope, name).await?);
}
if input.issue.assignees.is_some() && !input.add_assignees.is_empty() {
return Err("use either assignees or add_assignees, not both".into());
}
let remove = resolve_label_ids(configuration, scope, &input.remove_labels).await?;
let add = resolve_label_ids(configuration, scope, &input.add_labels).await?;
for &index in indexes {
apply_issue_edit(configuration, scope, index, &input, &remove, &add).await?;
}
Ok(())
}
async fn apply_issue_edit(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
index: i64,
input: &EditIssueInput,
remove: &[i64],
add: &[i64],
) -> Result<(), Box<dyn Error>> {
let mut edit = input.issue.clone();
if !input.add_assignees.is_empty() {
let issue =
apis::issue_api::issue_get_issue(configuration, &scope.owner, &scope.repository, index)
.await?;
let mut assignees = issue
.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 != EditIssueOption::default() {
apis::issue_api::issue_edit_issue(
configuration,
&scope.owner,
&scope.repository,
index,
Some(edit),
)
.await?;
}
for &id in remove {
apis::issue_api::issue_remove_label(
configuration,
&scope.owner,
&scope.repository,
index,
id,
)
.await?;
}
if !add.is_empty() {
apis::issue_api::issue_add_label(
configuration,
&scope.owner,
&scope.repository,
index,
Some(gotcha_gitea::models::IssueLabelsOption {
labels: Some(add.iter().copied().map(serde_json::Value::from).collect()),
}),
)
.await?;
}
print_issue(
&apis::issue_api::issue_get_issue(configuration, &scope.owner, &scope.repository, index)
&client
.create_issue(
scope,
CreateIssue {
option: input.issue,
label_names: input.label_names,
milestone_name: input.milestone_name,
},
)
.await?,
);
Ok(())
}
async fn resolve_label_ids(
configuration: &apis::configuration::Configuration,
async fn edit_issues(
client: &Client,
scope: &RepositoryScope,
names: &[String],
) -> Result<Vec<i64>, Box<dyn Error>> {
if names.is_empty() {
return Ok(Vec::new());
}
let mut labels = Vec::new();
let mut page = 1;
loop {
let batch = apis::issue_api::issue_list_labels(
configuration,
&scope.owner,
&scope.repository,
Some(page),
Some(50),
indexes: &[i64],
) -> Result<(), Box<dyn Error>> {
let input: EditIssueInput = read_yaml()?;
for issue in client
.edit_issues(
scope,
indexes,
EditIssue {
option: input.issue,
replace_labels: None,
add_labels: input.add_labels,
remove_labels: input.remove_labels,
add_assignees: input.add_assignees,
milestone_name: input.milestone_name,
},
)
.await?;
let has_more = batch.len() == 50;
labels.extend(batch);
if !has_more {
break;
}
page += 1;
.await?
{
print_issue(&issue);
}
names
.iter()
.map(|name| {
labels
.iter()
.find(|label| label.name.as_deref() == Some(name))
.and_then(|label| label.id)
.ok_or_else(|| format!("unknown label {name:?}").into())
})
.collect()
}
async fn resolve_milestone_id(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
name: &str,
) -> Result<i64, Box<dyn Error>> {
apis::issue_api::issue_get_milestones_list(
configuration,
&scope.owner,
&scope.repository,
Some("all"),
Some(name),
Some(1),
Some(2),
)
.await?
.into_iter()
.find(|milestone| milestone.title.as_deref() == Some(name))
.and_then(|milestone| milestone.id)
.ok_or_else(|| format!("unknown milestone {name:?}").into())
Ok(())
}
async fn set_issue_state(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
indexes: &[i64],
state: &str,
) -> Result<(), Box<dyn Error>> {
for &index in indexes {
let issue = apis::issue_api::issue_edit_issue(
configuration,
&scope.owner,
&scope.repository,
index,
Some(EditIssueOption {
state: Some(state.into()),
..Default::default()
}),
)
.await?;
for issue in client.set_issue_state(scope, indexes, state).await? {
print_issue(&issue);
}
Ok(())
}
async fn delete_issue(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
apis::issue_api::issue_delete(configuration, &scope.owner, &scope.repository, index).await?;
client.delete_issue(scope, index).await?;
println!("Deleted issue #{index}.");
Ok(())
}
async fn list_comments(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let comments = apis::issue_api::issue_get_comments(
configuration,
&scope.owner,
&scope.repository,
index,
None,
None,
)
.await?;
let comments = client.issue_comments(scope, index).await?;
print_comments(&comments);
Ok(())
}
async fn create_comment(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let body = read_stdin()?.trim_end().to_owned();
let comment = apis::issue_api::issue_create_comment(
configuration,
&scope.owner,
&scope.repository,
index,
Some(CreateIssueCommentOption::new(body)),
)
.await?;
let comment = client.create_issue_comment(scope, index, body).await?;
print_comments(&[comment]);
Ok(())
}
async fn list_milestones(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let milestones = apis::issue_api::issue_get_milestones_list(
configuration,
&scope.owner,
&scope.repository,
None,
None,
None,
None,
)
.await?;
async fn list_milestones(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
let milestones = client.milestones(scope).await?;
print_milestones(&milestones);
Ok(())
}
async fn show_milestone(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
id: &str,
) -> Result<(), Box<dyn Error>> {
let milestone =
apis::issue_api::issue_get_milestone(configuration, &scope.owner, &scope.repository, id)
.await?;
let milestone = client.milestone(scope, number(id)?).await?;
print_milestone(&milestone);
Ok(())
}
async fn create_milestone(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
async fn create_milestone(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
let body: CreateMilestoneOption = read_yaml()?;
if body.title.as_deref().unwrap_or_default().trim().is_empty() {
return Err("milestone title must not be empty".into());
}
let milestone = apis::issue_api::issue_create_milestone(
configuration,
&scope.owner,
&scope.repository,
Some(body),
)
.await?;
let milestone = client.create_milestone(scope, body).await?;
print_milestone(&milestone);
Ok(())
}
async fn edit_milestone(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
id: &str,
) -> Result<(), Box<dyn Error>> {
let milestone = apis::issue_api::issue_edit_milestone(
configuration,
&scope.owner,
&scope.repository,
id,
Some(read_yaml::<EditMilestoneOption>()?),
)
.await?;
let milestone = client
.edit_milestone(scope, id, read_yaml::<EditMilestoneOption>()?)
.await?;
print_milestone(&milestone);
Ok(())
}
async fn delete_milestone(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
id: &str,
) -> Result<(), Box<dyn Error>> {
apis::issue_api::issue_delete_milestone(configuration, &scope.owner, &scope.repository, id)
.await?;
client.delete_milestone(scope, id).await?;
println!("Deleted milestone {id}.");
Ok(())
}
async fn list_pulls(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let pulls = apis::repository_api::repo_list_pull_requests(
configuration,
&scope.owner,
&scope.repository,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await?;
print_pulls(&pulls);
async fn list_pulls(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
print_pulls(&client.repository_pulls(scope, "open", 1, 30).await?.items);
Ok(())
}
async fn show_pull(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let pull = apis::repository_api::repo_get_pull_request(
configuration,
&scope.owner,
&scope.repository,
index,
)
.await?;
let pull = client.pull(scope, index).await?;
print_pull(&pull);
Ok(())
}
async fn create_pull(
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
async fn create_pull(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
let body: CreatePullRequestOption = read_yaml()?;
if [
body.title.as_deref(),
body.head.as_deref(),
body.base.as_deref(),
]
.into_iter()
.any(|value| value.unwrap_or_default().trim().is_empty())
{
return Err("pull request title, head, and base must not be empty".into());
}
let pull = apis::repository_api::repo_create_pull_request(
configuration,
&scope.owner,
&scope.repository,
Some(body),
)
.await?;
let pull = client.create_pull(scope, body).await?;
print_pull(&pull);
Ok(())
}
async fn edit_pull(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
let pull = apis::repository_api::repo_edit_pull_request(
configuration,
&scope.owner,
&scope.repository,
index,
Some(read_yaml::<EditPullRequestOption>()?),
)
.await?;
let pull = client
.edit_pull(scope, index, read_yaml::<EditPullRequestOption>()?)
.await?;
print_pull(&pull);
Ok(())
}
async fn merge_pull(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
index: i64,
) -> Result<(), Box<dyn Error>> {
apis::repository_api::repo_merge_pull_request(
configuration,
&scope.owner,
&scope.repository,
index,
Some(read_yaml::<MergePullRequestOption>()?),
)
.await?;
client
.merge_pull(scope, index, read_yaml::<MergePullRequestOption>()?)
.await?;
println!("Merged pull request #{index}.");
Ok(())
}
async fn pull_details(
configuration: &apis::configuration::Configuration,
client: &Client,
scope: &RepositoryScope,
index: i64,
action: &str,
) -> Result<(), Box<dyn Error>> {
match action {
"commits" => print_commits(
&apis::repository_api::repo_get_pull_request_commits(
configuration,
&scope.owner,
&scope.repository,
index,
None,
None,
None,
None,
)
.await?,
),
"files" => print_files(
&apis::repository_api::repo_get_pull_request_files(
configuration,
&scope.owner,
&scope.repository,
index,
None,
None,
None,
None,
)
.await?,
),
"reviews" => print_reviews(
&apis::repository_api::repo_list_pull_reviews(
configuration,
&scope.owner,
&scope.repository,
index,
None,
None,
)
.await?,
),
"commits" => print_commits(&client.pull_commits(scope, index).await?),
"files" => print_files(&client.pull_files(scope, index).await?),
"reviews" => print_reviews(&client.pull_reviews(scope, index).await?),
_ => unreachable!(),
}
Ok(())
@@ -1129,7 +791,7 @@ fn print_pull(pull: &PullRequest) {
println!(
"#{} [{}] {}",
pull.number.unwrap_or_default(),
pull.state.as_deref().unwrap_or("unknown"),
pull_state(pull),
pull.title.as_deref().unwrap_or("")
);
field("url", pull.html_url.as_deref());

View File

@@ -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

View File

@@ -1,4 +1,9 @@
use gotcha_gitea::models;
use crate::{
Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, HomeData},
models,
};
use gitea_openapi::apis;
#[derive(Debug, Eq, PartialEq)]
pub enum Target {
@@ -23,6 +28,38 @@ pub enum Target {
},
}
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;

235
crates/gitea/src/domain.rs Normal file
View 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
View 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]));
}
}

View File

@@ -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();
}
}

View 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
View 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(())
}

View File

@@ -0,0 +1,478 @@
use std::{
cmp::Reverse,
collections::{BTreeSet, BinaryHeap, HashMap},
};
use tokio::task::JoinSet;
use crate::{
Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, PullRefs, RepositoryId},
models,
};
use gitea_openapi::apis;
impl Client {
pub async fn current_user_repositories_page(
&self,
page: i32,
limit: i32,
) -> Result<Page<models::Repository>> {
validate_page(page, limit)?;
apis::user_api::user_current_list_repos(&self.configuration(), Some(page), Some(limit))
.await
.map(|items| Page::from_items(items, limit))
.map_err(Error::generated)
}
pub async fn owned_repositories_page(
&self,
page: i32,
limit: i32,
) -> Result<Page<models::Repository>> {
let login = self
.current_user()
.await?
.login
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
let page = self.current_user_repositories_page(page, limit).await?;
Ok(Page {
has_more: page.has_more,
items: page
.items
.into_iter()
.filter(|repository| {
repository
.owner
.as_ref()
.and_then(|owner| owner.login.as_deref())
== Some(login.as_str())
})
.collect(),
})
}
pub async fn branches(
&self,
repository: &RepositoryId,
default_branch: &str,
) -> Result<Vec<String>> {
let configuration = self.configuration();
let mut branches = Vec::new();
for page in 1.. {
let batch = apis::repository_api::repo_list_branches(
&configuration,
&repository.owner,
&repository.repository,
Some(page),
Some(100),
)
.await
.map_err(Error::generated)?;
let done = batch.len() < 100;
branches.extend(batch.into_iter().filter_map(|branch| branch.name));
if done {
break;
}
}
branches.sort_by_key(|branch| (branch != default_branch, branch.to_lowercase()));
Ok(branches)
}
pub async fn repository_contents(
&self,
repository: &RepositoryId,
path: &str,
) -> Result<Vec<models::ContentsResponse>> {
let configuration = self.configuration();
if path.is_empty() {
apis::repository_api::repo_get_contents_list(
&configuration,
&repository.owner,
&repository.repository,
None,
)
.await
.map_err(Error::generated)
} else {
apis::repository_api::repo_get_contents_ext(
&configuration,
&repository.owner,
&repository.repository,
path,
None,
None,
)
.await
.map(|contents| contents.dir_contents.unwrap_or_default())
.map_err(Error::generated)
}
}
pub async fn repository_file(&self, repository: &RepositoryId, path: &str) -> Result<Vec<u8>> {
apis::repository_api::repo_get_raw_file(
&self.configuration(),
&repository.owner,
&repository.repository,
path,
None,
)
.await
.map_err(Error::generated)?
.bytes()
.await
.map(|bytes| bytes.to_vec())
.map_err(Into::into)
}
pub async fn branch_history(
&self,
repository: &RepositoryId,
branch: &str,
path: Option<&str>,
pages: u32,
) -> Result<Page<HistoryCommit>> {
self.commits_for_ref(repository, Some(branch), path, pages)
.await
.map(|page| Page {
has_more: page.has_more,
items: page
.items
.into_iter()
.map(|commit| HistoryCommit {
commit,
top_lanes: Vec::new(),
bottom_lanes: Vec::new(),
node_lane: None,
connections: Vec::new(),
refs: Vec::new(),
})
.collect(),
})
}
pub async fn all_history(
&self,
repository: &RepositoryId,
branches: &[String],
path: Option<&str>,
pages: u32,
) -> Result<Page<HistoryCommit>> {
let mut tasks = JoinSet::new();
for (lane, branch) in branches.iter().cloned().enumerate() {
let (client, repository) = (self.clone(), repository.clone());
let path = path.map(str::to_string);
tasks.spawn(async move {
let commits = client
.commits_for_ref(&repository, Some(&branch), path.as_deref(), pages)
.await?;
Ok::<_, Error>((lane, branch, commits))
});
}
let mut histories = Vec::new();
while let Some(result) = tasks.join_next().await {
histories.push(result.map_err(|error| Error::Generated(error.to_string()))??);
}
histories.sort_by_key(|(lane, _, _)| *lane);
let has_more = histories.iter().any(|(_, _, page)| page.has_more);
let histories = histories
.into_iter()
.map(|(lane, branch, page)| (lane, branch, page.items))
.collect();
let pull_refs = self.pull_refs(repository).await.unwrap_or_default();
Ok(Page {
items: build_graph(histories, pull_refs),
has_more,
})
}
pub async fn commit_files(
&self,
repository: &RepositoryId,
sha: &str,
) -> Result<Vec<models::CommitAffectedFiles>> {
apis::repository_api::repo_get_single_commit(
&self.configuration(),
&repository.owner,
&repository.repository,
sha,
Some(true),
None,
Some(true),
)
.await
.map(|commit| commit.files.unwrap_or_default())
.map_err(Error::generated)
}
pub async fn commit_diff(&self, repository: &RepositoryId, sha: &str) -> Result<String> {
apis::repository_api::repo_download_commit_diff_or_patch(
&self.configuration(),
&repository.owner,
&repository.repository,
sha,
"diff",
)
.await
.map_err(Error::generated)
}
async fn pull_refs(&self, repository: &RepositoryId) -> Result<PullRefs> {
let mut refs: PullRefs = HashMap::new();
for page in 1.. {
let batch = self.repository_pulls(repository, "all", page, 100).await?;
for pull in batch.items {
let Some(head) = pull.head else { continue };
let Some(sha) = head.sha else { continue };
let Some(label) = head.label.or(head.r#ref) else {
continue;
};
if !label.is_empty() {
refs.entry(sha).or_default().push(label);
}
}
if !batch.has_more {
break;
}
}
Ok(refs)
}
async fn commits_for_ref(
&self,
repository: &RepositoryId,
branch: Option<&str>,
path: Option<&str>,
pages: u32,
) -> Result<Page<models::Commit>> {
let configuration = self.configuration();
let mut commits = Vec::new();
let mut has_more = false;
for page in 1..=pages.max(1) {
let batch = apis::repository_api::repo_get_all_commits(
&configuration,
&repository.owner,
&repository.repository,
branch,
path,
None,
None,
None,
None,
Some(false),
Some(page as i32),
Some(DEFAULT_PAGE_SIZE),
None,
)
.await
.map_err(Error::generated)?;
has_more = batch.len() == DEFAULT_PAGE_SIZE as usize;
commits.extend(batch);
if !has_more {
break;
}
}
Ok(Page {
items: commits,
has_more,
})
}
}
fn build_graph(
histories: Vec<(usize, String, Vec<models::Commit>)>,
mut extra_refs: PullRefs,
) -> Vec<HistoryCommit> {
let mut commits = HashMap::new();
let mut ranks: HashMap<String, (usize, usize)> = HashMap::new();
let mut refs: PullRefs = HashMap::new();
for (branch_index, branch, history) in histories {
if let Some(sha) = history.iter().find_map(|commit| commit.sha.clone()) {
refs.entry(sha).or_default().push(branch);
}
for (index, commit) in history.into_iter().enumerate() {
let Some(sha) = commit.sha.clone() else {
continue;
};
ranks
.entry(sha.clone())
.and_modify(|rank| *rank = (*rank).min((branch_index, index)))
.or_insert((branch_index, index));
commits.entry(sha).or_insert(commit);
}
}
for (sha, labels) in extra_refs.drain() {
let row = refs.entry(sha).or_default();
for label in labels {
if !row.contains(&label) {
row.push(label);
}
}
}
let mut children = HashMap::new();
for sha in commits.keys() {
children.insert(sha.clone(), 0_usize);
}
for commit in commits.values() {
for parent in commit_parents(commit) {
if let Some(count) = children.get_mut(parent) {
*count += 1;
}
}
}
let mut ready = BinaryHeap::new();
for (sha, count) in &children {
if *count == 0 {
ready.push(Reverse((ranks[sha], sha.clone())));
}
}
let mut ordered = Vec::with_capacity(commits.len());
while let Some(Reverse((_, sha))) = ready.pop() {
let Some(commit) = commits.remove(&sha) else {
continue;
};
for parent in commit_parents(&commit) {
if let Some(count) = children.get_mut(parent) {
*count -= 1;
if *count == 0 {
ready.push(Reverse((ranks[parent], parent.to_string())));
}
}
}
ordered.push(HistoryCommit {
commit,
top_lanes: Vec::new(),
bottom_lanes: Vec::new(),
node_lane: None,
connections: Vec::new(),
refs: refs.remove(&sha).unwrap_or_default(),
});
}
layout_graph(&mut ordered);
ordered
}
fn layout_graph(commits: &mut [HistoryCommit]) {
let mut lanes: Vec<Option<String>> = Vec::new();
for row in commits {
let Some(sha) = row.commit.sha.as_deref() else {
continue;
};
let matching_lanes: Vec<_> = lanes
.iter()
.enumerate()
.filter_map(|(index, next)| (next.as_deref() == Some(sha)).then_some(index))
.collect();
let existing_node = matching_lanes.first().copied();
let node = existing_node.unwrap_or_else(|| allocate_lane(&mut lanes, sha));
row.top_lanes = lanes
.iter()
.enumerate()
.filter_map(|(index, lane)| lane.as_ref().map(|_| index))
.filter(|index| existing_node.is_some() || *index != node)
.collect();
let mut connections = BTreeSet::new();
for duplicate in matching_lanes.into_iter().skip(1) {
lanes[duplicate] = None;
connect(node, duplicate, &mut connections);
}
let parents: Vec<_> = commit_parents(&row.commit).map(str::to_string).collect();
lanes[node] = None;
for (index, parent) in parents.into_iter().enumerate() {
if index == 0 {
lanes[node] = Some(parent);
} else if let Some(existing) = lanes
.iter()
.position(|next| next.as_deref() == Some(&parent))
{
connect(node, existing, &mut connections);
} else {
let branch = allocate_lane(&mut lanes, &parent);
connect(node, branch, &mut connections);
}
}
row.node_lane = Some(node);
row.connections = connections.into_iter().collect();
row.bottom_lanes = lanes
.iter()
.enumerate()
.filter_map(|(index, lane)| lane.as_ref().map(|_| index))
.collect();
}
}
fn allocate_lane(lanes: &mut Vec<Option<String>>, sha: &str) -> usize {
if let Some(index) = lanes.iter().position(Option::is_none) {
lanes[index] = Some(sha.into());
index
} else {
lanes.push(Some(sha.into()));
lanes.len() - 1
}
}
fn connect(left: usize, right: usize, connections: &mut BTreeSet<usize>) {
for lane in left.min(right) + 1..=left.max(right) {
connections.insert(lane);
}
}
fn commit_parents(commit: &models::Commit) -> impl Iterator<Item = &str> {
commit
.parents
.as_deref()
.unwrap_or_default()
.iter()
.filter_map(|parent| parent.sha.as_deref())
}
fn validate_page(page: i32, limit: i32) -> Result<()> {
if page < 1 || limit < 1 {
return Err(Error::InvalidInput(
"page and limit must be positive".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn commit(sha: &str, parents: &[&str]) -> models::Commit {
models::Commit {
sha: Some(sha.into()),
parents: Some(
parents
.iter()
.map(|sha| models::CommitMeta {
sha: Some((*sha).into()),
..Default::default()
})
.collect(),
),
..Default::default()
}
}
#[test]
fn lays_out_shared_commit_graph() {
let rows = build_graph(
vec![
(
0,
"main".into(),
vec![commit("merge", &["left", "right"]), commit("left", &[])],
),
(1, "feature".into(), vec![commit("right", &[])]),
],
HashMap::new(),
);
assert_eq!(rows.len(), 3);
assert!(rows.iter().any(|row| row.refs == ["main"]));
assert!(rows.iter().any(|row| row.refs == ["feature"]));
assert!(rows.iter().any(|row| !row.connections.is_empty()));
}
}