Centralize Gitea domain workflows

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

View File

@@ -34,13 +34,21 @@ cargo test --workspace
Do not weaken or skip these gates to make a commit pass. Fix the underlying
warning, lint, formatting issue, or test failure.
## Rust and Swift ownership boundary
## Gitea, app, CLI, and Swift ownership boundary
Rust owns all application behavior. Put networking, authentication, input
validation, persistence, preference changes, filtering, sorting, aggregation,
parsing, content classification, display-data formatting, and navigation target
derivation in `crates/app`. Cover non-trivial behavior there with Rust tests and
expose view-ready records and operations through UniFFI.
`gotcha_gitea` owns Gitea networking and authentication plus the behavior of
Gitea objects: validation, pagination, name-to-ID resolution, mutations,
ownership checks, object relationships, activity targets, commit graphs, and
diff parsing. The app and CLI must not import generated Gitea API modules or
build generated-client configurations. Add a concrete `Client` operation to
`gotcha_gitea` instead. Generated response models may cross the boundary when a
frontend only needs to present their fields; the CLI's explicit `api request`
command is the sole low-level escape hatch.
`crates/app` owns application state, persistence, preferences, favorites,
content classification, navigation targets, and view-ready UniFFI records. The
CLI owns argument and YAML parsing plus terminal formatting. Do not duplicate
Gitea object transformations or relationship handling in either frontend.
Swift is the native iOS presentation layer. Limit `ios/Sources` to UIKit and
SwiftUI lifecycle, view-controller navigation, native controls, layout, drawing,
@@ -52,7 +60,8 @@ transformations, classify content, or implement persistence and API behavior.
When changing iOS functionality:
- Trace the complete flow before editing and implement behavior in Rust first.
- Trace the complete flow before editing and implement Gitea behavior in
`gotcha_gitea` and app behavior in `crates/app` first.
- Prefer view-ready Rust records over exposing raw Gitea models or rebuilding
titles, summaries, selections, progress, and categories in Swift.
- Treat a pure Swift helper that does not require an Apple UI framework as a

View File

@@ -4,12 +4,13 @@ Source: the OpenAPI contract served by the configured Gitea 1.25.4 instance.
It contains 299 paths, 467 operations, 243 read/query operations, and 216 schema
definitions.
`gotcha_gitea` exposes the complete typed Gitea 1.25 client through
`gotcha_gitea::apis` and every generated request/response model through
`gotcha_gitea::models`. `Client::configuration()` supplies the selected base URL
and token to those generated functions.
`gotcha_gitea` encapsulates the generated Gitea 1.25 client. Its concrete
`Client` methods own the supported Gitea workflows and relationships, while
`gotcha_gitea::models` exposes generated response records for presentation.
Generated API modules and client configuration are deliberately private so the
CLI and app cannot duplicate Gitea behavior.
| Domain | Queries | Other actions | API crate | CLI queries | CLI writes | Capabilities |
| Domain | Queries | Other actions | Generated client | CLI queries | CLI writes | Capabilities |
| --- | ---: | ---: | --- | ---: | ---: | --- |
| activitypub | 1 | 1 | Complete | 0 | 0 | Person actors and inbox federation |
| admin | 14 | 18 | Complete | 0 | 0 | Users, organizations, email, hooks, cron, unadopted repositories, Actions runners/jobs/runs |
@@ -37,7 +38,8 @@ Typed text commands currently implemented include:
- milestone list/show/create/edit/delete
- pull list/show/create/edit/merge/commits/files/reviews
The remaining 229 query operations and 214 mutation operations are available to
Rust callers through the typed API modules but do not yet have purpose-built CLI
commands or text/input views. `api request` remains an explicit JSON escape hatch;
it is not counted as typed CLI coverage.
The remaining 229 query operations and 214 mutation operations are represented
by the private generated client but do not yet have shared domain methods or
purpose-built CLI commands. Add new workflows to `gotcha_gitea::Client` before
using them in either frontend. `api request` remains an explicit JSON escape
hatch; it is not counted as typed CLI coverage.

1
Cargo.lock generated
View File

@@ -475,6 +475,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"tokio",
]
[[package]]

View File

@@ -60,15 +60,16 @@ accepted as command-line arguments or environment variables.
## Architecture
The Rust core owns Gitea access, validation, presentation records, preferences,
favorites, and Keychain-backed credentials. UniFFI generates the Swift bridge
in `ios/Generated`. UIKit owns navigation, lists, text input, menus, scrolling,
pull-to-refresh, appearance, and other platform behavior.
`gotcha_gitea` owns Gitea access, authentication, validation, mutations, and
relationships between Gitea objects. The CLI is a terminal presentation layer:
it parses arguments and YAML, invokes shared client operations, and formats the
results. `gotcha-app` owns application state, preferences, favorites,
Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native
navigation, controls, layout, and other platform presentation behavior.
New API workflows should first be exercised and typed in the CLI, then exposed
through the Rust core and presented with native UIKit controls. Future work
includes write workflows and iOS integrations such as sharing, notifications,
and background refresh.
New Gitea workflows belong in `gotcha_gitea::Client` first, then receive CLI or
app presentation as needed. Future work includes iOS integrations such as
sharing, notifications, and background refresh.
## iOS app

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