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 Do not weaken or skip these gates to make a commit pass. Fix the underlying
warning, lint, formatting issue, or test failure. 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 `gotcha_gitea` owns Gitea networking and authentication plus the behavior of
validation, persistence, preference changes, filtering, sorting, aggregation, Gitea objects: validation, pagination, name-to-ID resolution, mutations,
parsing, content classification, display-data formatting, and navigation target ownership checks, object relationships, activity targets, commit graphs, and
derivation in `crates/app`. Cover non-trivial behavior there with Rust tests and diff parsing. The app and CLI must not import generated Gitea API modules or
expose view-ready records and operations through UniFFI. 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 Swift is the native iOS presentation layer. Limit `ios/Sources` to UIKit and
SwiftUI lifecycle, view-controller navigation, native controls, layout, drawing, 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: 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 - Prefer view-ready Rust records over exposing raw Gitea models or rebuilding
titles, summaries, selections, progress, and categories in Swift. titles, summaries, selections, progress, and categories in Swift.
- Treat a pure Swift helper that does not require an Apple UI framework as a - 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 It contains 299 paths, 467 operations, 243 read/query operations, and 216 schema
definitions. definitions.
`gotcha_gitea` exposes the complete typed Gitea 1.25 client through `gotcha_gitea` encapsulates the generated Gitea 1.25 client. Its concrete
`gotcha_gitea::apis` and every generated request/response model through `Client` methods own the supported Gitea workflows and relationships, while
`gotcha_gitea::models`. `Client::configuration()` supplies the selected base URL `gotcha_gitea::models` exposes generated response records for presentation.
and token to those generated functions. 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 | | 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 | | 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 - milestone list/show/create/edit/delete
- pull list/show/create/edit/merge/commits/files/reviews - pull list/show/create/edit/merge/commits/files/reviews
The remaining 229 query operations and 214 mutation operations are available to The remaining 229 query operations and 214 mutation operations are represented
Rust callers through the typed API modules but do not yet have purpose-built CLI by the private generated client but do not yet have shared domain methods or
commands or text/input views. `api request` remains an explicit JSON escape hatch; purpose-built CLI commands. Add new workflows to `gotcha_gitea::Client` before
it is not counted as typed CLI coverage. 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", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"tokio",
] ]
[[package]] [[package]]

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,11 @@
use std::collections::{BTreeMap, BTreeSet}; 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}; 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)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum AppearanceMode { pub enum AppearanceMode {
@@ -128,16 +115,6 @@ pub struct RepositoryData {
pub default_branch: String, 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)] #[derive(Default)]
pub struct State { pub struct State {
pub preferences: Preferences, pub preferences: Preferences,
@@ -145,25 +122,6 @@ pub struct State {
pub repositories: Vec<RepositoryData>, 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 struct IssueDraft {
pub title: String, pub title: String,
pub body: String, pub body: String,
@@ -172,26 +130,12 @@ pub struct IssueDraft {
pub due_date: Option<i64>, 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 struct MilestoneDraft {
pub title: String, pub title: String,
pub description: String, pub description: String,
pub due_date: Option<i64>, 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 { pub fn open_status() -> String {
"open".into() "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 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -263,18 +189,4 @@ mod tests {
.is_active("open") .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 gotcha_gitea::Client;
use thiserror::Error; use thiserror::Error;
mod activity;
mod api; mod api;
mod diff;
mod domain; mod domain;
mod presentation; mod presentation;
mod storage; 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::{ use crate::domain::{
activity, HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
domain::{ PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
},
}; };
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
@@ -630,13 +629,7 @@ pub fn pull_page(details: PullDetails) -> PullPage {
.as_ref() .as_ref()
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref())) .and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
.unwrap_or("base"); .unwrap_or("base");
let state = if pull.merged.unwrap_or(false) { let state = pull_state(pull);
"merged"
} else if pull.draft.unwrap_or(false) {
"draft"
} else {
pull.state.as_deref().unwrap_or("unknown")
};
PullPage { PullPage {
title: pull title: pull
.title .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 { DiffPage {
title: path.rsplit('/').next().unwrap_or(path).into(), title: path.rsplit('/').next().unwrap_or(path).into(),
columns: diff.columns.min(u32::MAX as usize) as u32, 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() .as_deref()
.or(comment.created_at.as_deref()), .or(comment.created_at.as_deref()),
), ),
can_edit: matches!( can_edit: comment_can_edit(comment, viewer_id),
(
comment.id,
comment.user.as_ref().and_then(|user| user.id),
viewer_id,
),
(Some(_), Some(author), Some(viewer)) if author == viewer
),
} }
} }
@@ -1059,17 +1045,10 @@ fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
} }
fn pull_files_ref(pull: &models::PullRequest) -> String { fn pull_files_ref(pull: &models::PullRequest) -> String {
if pull.state.as_deref() == Some("open") { match pull_file_source(pull) {
let branch = pull PullFileSource::Branch(branch) => format!("Files on {branch}"),
.head PullFileSource::Commit(sha) => format!("Files at merge commit {sha}"),
.as_ref() PullFileSource::Request => "Files from pull request".into(),
.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()
} }
} }

View File

@@ -10,7 +10,7 @@ use std::{
#[cfg(unix)] #[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use gotcha_gitea::{Client, Url}; use gotcha_gitea::{Client, RepositoryId, Url};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
type Result<T> = std::result::Result<T, String>; type Result<T> = std::result::Result<T, String>;
@@ -36,26 +36,7 @@ pub struct Selection {
pub repository: Option<RepositoryScope>, pub repository: Option<RepositoryScope>,
} }
#[derive(Clone)] pub type RepositoryScope = RepositoryId;
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(),
})
}
}
impl Config { impl Config {
pub fn load() -> Result<Self> { pub fn load() -> Result<Self> {

View File

@@ -1,12 +1,13 @@
use std::{error::Error, io::Read}; use std::{error::Error, io::Read};
use gotcha_gitea::{ use gotcha_gitea::{
Client, apis, Client, CreateIssue, EditIssue, IssueQuery,
models::{ models::{
ChangedFile, Comment, Commit, CreateIssueCommentOption, CreateIssueOption, ChangedFile, Comment, Commit, CreateIssueOption, CreateMilestoneOption,
CreateMilestoneOption, CreatePullRequestOption, EditIssueOption, EditMilestoneOption, CreatePullRequestOption, EditIssueOption, EditMilestoneOption, EditPullRequestOption,
EditPullRequestOption, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview, Issue, MergePullRequestOption, Milestone, PullRequest, PullReview,
}, },
pull_state,
}; };
use serde::{Deserialize, de::DeserializeOwned}; use serde::{Deserialize, de::DeserializeOwned};
@@ -248,69 +249,48 @@ pub async fn run(
selection: &Selection, selection: &Selection,
client: &Client, client: &Client,
) -> Result<bool, Box<dyn Error>> { ) -> Result<bool, Box<dyn Error>> {
let configuration = client.configuration();
match command { match command {
[domain, action, arguments @ ..] if domain == "issue" && action == "list" => { [domain, action, arguments @ ..] if domain == "issue" && action == "list" => {
let options = parse_issue_list(arguments)?; let options = parse_issue_list(arguments)?;
let scope = scope(selection, options.repository.as_deref())?; let scope = scope(selection, options.repository.as_deref())?;
let issues = apis::issue_api::issue_list_issues( let issues = client
&configuration, .issues(
&scope.owner, &scope,
&scope.repository, &IssueQuery {
Some(&options.state), state: options.state,
options.labels.as_deref(), labels: options.labels,
options.keyword.as_deref(), keyword: options.keyword,
Some(&options.kind), kind: options.kind,
options.milestones.as_deref(), milestones: options.milestones,
options.from, from: options.from,
options.until, until: options.until,
options.author.as_deref(), author: options.author,
options.assignee.as_deref(), assignee: options.assignee,
options.mentions.as_deref(), mentions: options.mentions,
Some(options.page), page: options.page,
Some(options.limit), limit: options.limit,
) },
.await?; )
print_issues(&issues); .await?;
print_issues(&issues.items);
} }
[domain, action, index] if domain == "issue" && action == "show" => { [domain, action, index] if domain == "issue" && action == "show" => {
let scope = scope(selection, None)?; let scope = scope(selection, None)?;
print_issue( print_issue(&client.issue(&scope, number(index)?).await?);
&apis::issue_api::issue_get_issue(
&configuration,
&scope.owner,
&scope.repository,
number(index)?,
)
.await?,
);
} }
[domain, action, index, repository] if domain == "issue" && action == "show" => { [domain, action, index, repository] if domain == "issue" && action == "show" => {
let scope = scope(selection, Some(repository))?; let scope = scope(selection, Some(repository))?;
print_issue( print_issue(&client.issue(&scope, number(index)?).await?);
&apis::issue_api::issue_get_issue(
&configuration,
&scope.owner,
&scope.repository,
number(index)?,
)
.await?,
);
} }
[domain, action] if domain == "issue" && action == "create" => { [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" => { [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" => { [domain, action, arguments @ ..] if domain == "issue" && action == "edit" => {
let (indexes, repository) = issue_targets(arguments)?; let (indexes, repository) = issue_targets(arguments)?;
edit_issues( edit_issues(client, &scope(selection, repository.as_deref())?, &indexes).await?;
&configuration,
&scope(selection, repository.as_deref())?,
&indexes,
)
.await?;
} }
[domain, action, arguments @ ..] [domain, action, arguments @ ..]
if domain == "issue" && matches!(action.as_str(), "close" | "reopen") => if domain == "issue" && matches!(action.as_str(), "close" | "reopen") =>
@@ -318,7 +298,7 @@ pub async fn run(
let (indexes, repository) = issue_targets(arguments)?; let (indexes, repository) = issue_targets(arguments)?;
let scope = scope(selection, repository.as_deref())?; let scope = scope(selection, repository.as_deref())?;
set_issue_state( set_issue_state(
&configuration, client,
&scope, &scope,
&indexes, &indexes,
if action == "close" { "closed" } else { "open" }, if action == "close" { "closed" } else { "open" },
@@ -326,129 +306,93 @@ pub async fn run(
.await?; .await?;
} }
[domain, action, index] if domain == "issue" && action == "delete" => { [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" => { [domain, action, index, repository] if domain == "issue" && action == "delete" => {
delete_issue( delete_issue(client, &scope(selection, Some(repository))?, number(index)?).await?;
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
} }
[domain, action, index] if domain == "issue" && action == "comments" => { [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" => { [domain, action, index, repository] if domain == "issue" && action == "comments" => {
list_comments( list_comments(client, &scope(selection, Some(repository))?, number(index)?).await?;
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
} }
[domain, action, index] if domain == "issue" && action == "comment" => { [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" => { [domain, action, index, repository] if domain == "issue" && action == "comment" => {
create_comment( create_comment(client, &scope(selection, Some(repository))?, number(index)?).await?;
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
} }
[domain, action] if domain == "milestone" && action == "list" => { [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" => { [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" => { [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" => { [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" => { [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" => { [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" => { [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" => { [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" => { [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" => { [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" => { [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" => { [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" => { [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" => { [domain, action, index, repository] if domain == "pull" && action == "show" => {
show_pull( show_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
} }
[domain, action] if domain == "pull" && action == "create" => { [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" => { [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" => { [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" => { [domain, action, index, repository] if domain == "pull" && action == "edit" => {
edit_pull( edit_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
} }
[domain, action, index] if domain == "pull" && action == "merge" => { [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" => { [domain, action, index, repository] if domain == "pull" && action == "merge" => {
merge_pull( merge_pull(client, &scope(selection, Some(repository))?, number(index)?).await?;
&configuration,
&scope(selection, Some(repository))?,
number(index)?,
)
.await?;
} }
[domain, action, index] [domain, action, index]
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") => if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
{ {
pull_details( pull_details(client, &scope(selection, None)?, number(index)?, action).await?;
&configuration,
&scope(selection, None)?,
number(index)?,
action,
)
.await?;
} }
[domain, action, index, repository] [domain, action, index, repository]
if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") => if domain == "pull" && matches!(action.as_str(), "commits" | "files" | "reviews") =>
{ {
pull_details( pull_details(
&configuration, client,
&scope(selection, Some(repository))?, &scope(selection, Some(repository))?,
number(index)?, number(index)?,
action, action,
@@ -497,475 +441,193 @@ fn parse_yaml<T: DeserializeOwned>(input: &str) -> Result<T, Box<dyn Error>> {
Ok(serde_yaml::from_str(input)?) Ok(serde_yaml::from_str(input)?)
} }
async fn create_issue( async fn create_issue(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
configuration: &apis::configuration::Configuration, let input: CreateIssueInput = read_yaml()?;
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?);
}
print_issue( print_issue(
&apis::issue_api::issue_create_issue( &client
configuration, .create_issue(
&scope.owner, scope,
&scope.repository, CreateIssue {
Some(input.issue), option: input.issue,
) label_names: input.label_names,
.await?, milestone_name: input.milestone_name,
); },
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)
.await?, .await?,
); );
Ok(()) Ok(())
} }
async fn resolve_label_ids( async fn edit_issues(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
names: &[String], indexes: &[i64],
) -> Result<Vec<i64>, Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
if names.is_empty() { let input: EditIssueInput = read_yaml()?;
return Ok(Vec::new()); for issue in client
} .edit_issues(
let mut labels = Vec::new(); scope,
let mut page = 1; indexes,
loop { EditIssue {
let batch = apis::issue_api::issue_list_labels( option: input.issue,
configuration, replace_labels: None,
&scope.owner, add_labels: input.add_labels,
&scope.repository, remove_labels: input.remove_labels,
Some(page), add_assignees: input.add_assignees,
Some(50), milestone_name: input.milestone_name,
},
) )
.await?; .await?
let has_more = batch.len() == 50; {
labels.extend(batch); print_issue(&issue);
if !has_more {
break;
}
page += 1;
} }
names Ok(())
.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())
} }
async fn set_issue_state( async fn set_issue_state(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
indexes: &[i64], indexes: &[i64],
state: &str, state: &str,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
for &index in indexes { for issue in client.set_issue_state(scope, indexes, state).await? {
let issue = apis::issue_api::issue_edit_issue(
configuration,
&scope.owner,
&scope.repository,
index,
Some(EditIssueOption {
state: Some(state.into()),
..Default::default()
}),
)
.await?;
print_issue(&issue); print_issue(&issue);
} }
Ok(()) Ok(())
} }
async fn delete_issue( async fn delete_issue(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
index: i64, index: i64,
) -> Result<(), Box<dyn Error>> { ) -> 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}."); println!("Deleted issue #{index}.");
Ok(()) Ok(())
} }
async fn list_comments( async fn list_comments(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
index: i64, index: i64,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let comments = apis::issue_api::issue_get_comments( let comments = client.issue_comments(scope, index).await?;
configuration,
&scope.owner,
&scope.repository,
index,
None,
None,
)
.await?;
print_comments(&comments); print_comments(&comments);
Ok(()) Ok(())
} }
async fn create_comment( async fn create_comment(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
index: i64, index: i64,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let body = read_stdin()?.trim_end().to_owned(); let body = read_stdin()?.trim_end().to_owned();
let comment = apis::issue_api::issue_create_comment( let comment = client.create_issue_comment(scope, index, body).await?;
configuration,
&scope.owner,
&scope.repository,
index,
Some(CreateIssueCommentOption::new(body)),
)
.await?;
print_comments(&[comment]); print_comments(&[comment]);
Ok(()) Ok(())
} }
async fn list_milestones( async fn list_milestones(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
configuration: &apis::configuration::Configuration, let milestones = client.milestones(scope).await?;
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?;
print_milestones(&milestones); print_milestones(&milestones);
Ok(()) Ok(())
} }
async fn show_milestone( async fn show_milestone(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
id: &str, id: &str,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let milestone = let milestone = client.milestone(scope, number(id)?).await?;
apis::issue_api::issue_get_milestone(configuration, &scope.owner, &scope.repository, id)
.await?;
print_milestone(&milestone); print_milestone(&milestone);
Ok(()) Ok(())
} }
async fn create_milestone( async fn create_milestone(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let body: CreateMilestoneOption = read_yaml()?; let body: CreateMilestoneOption = read_yaml()?;
if body.title.as_deref().unwrap_or_default().trim().is_empty() { let milestone = client.create_milestone(scope, body).await?;
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?;
print_milestone(&milestone); print_milestone(&milestone);
Ok(()) Ok(())
} }
async fn edit_milestone( async fn edit_milestone(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
id: &str, id: &str,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let milestone = apis::issue_api::issue_edit_milestone( let milestone = client
configuration, .edit_milestone(scope, id, read_yaml::<EditMilestoneOption>()?)
&scope.owner, .await?;
&scope.repository,
id,
Some(read_yaml::<EditMilestoneOption>()?),
)
.await?;
print_milestone(&milestone); print_milestone(&milestone);
Ok(()) Ok(())
} }
async fn delete_milestone( async fn delete_milestone(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
id: &str, id: &str,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
apis::issue_api::issue_delete_milestone(configuration, &scope.owner, &scope.repository, id) client.delete_milestone(scope, id).await?;
.await?;
println!("Deleted milestone {id}."); println!("Deleted milestone {id}.");
Ok(()) Ok(())
} }
async fn list_pulls( async fn list_pulls(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
configuration: &apis::configuration::Configuration, print_pulls(&client.repository_pulls(scope, "open", 1, 30).await?.items);
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);
Ok(()) Ok(())
} }
async fn show_pull( async fn show_pull(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
index: i64, index: i64,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let pull = apis::repository_api::repo_get_pull_request( let pull = client.pull(scope, index).await?;
configuration,
&scope.owner,
&scope.repository,
index,
)
.await?;
print_pull(&pull); print_pull(&pull);
Ok(()) Ok(())
} }
async fn create_pull( async fn create_pull(client: &Client, scope: &RepositoryScope) -> Result<(), Box<dyn Error>> {
configuration: &apis::configuration::Configuration,
scope: &RepositoryScope,
) -> Result<(), Box<dyn Error>> {
let body: CreatePullRequestOption = read_yaml()?; let body: CreatePullRequestOption = read_yaml()?;
if [ let pull = client.create_pull(scope, body).await?;
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?;
print_pull(&pull); print_pull(&pull);
Ok(()) Ok(())
} }
async fn edit_pull( async fn edit_pull(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
index: i64, index: i64,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let pull = apis::repository_api::repo_edit_pull_request( let pull = client
configuration, .edit_pull(scope, index, read_yaml::<EditPullRequestOption>()?)
&scope.owner, .await?;
&scope.repository,
index,
Some(read_yaml::<EditPullRequestOption>()?),
)
.await?;
print_pull(&pull); print_pull(&pull);
Ok(()) Ok(())
} }
async fn merge_pull( async fn merge_pull(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
index: i64, index: i64,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
apis::repository_api::repo_merge_pull_request( client
configuration, .merge_pull(scope, index, read_yaml::<MergePullRequestOption>()?)
&scope.owner, .await?;
&scope.repository,
index,
Some(read_yaml::<MergePullRequestOption>()?),
)
.await?;
println!("Merged pull request #{index}."); println!("Merged pull request #{index}.");
Ok(()) Ok(())
} }
async fn pull_details( async fn pull_details(
configuration: &apis::configuration::Configuration, client: &Client,
scope: &RepositoryScope, scope: &RepositoryScope,
index: i64, index: i64,
action: &str, action: &str,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
match action { match action {
"commits" => print_commits( "commits" => print_commits(&client.pull_commits(scope, index).await?),
&apis::repository_api::repo_get_pull_request_commits( "files" => print_files(&client.pull_files(scope, index).await?),
configuration, "reviews" => print_reviews(&client.pull_reviews(scope, index).await?),
&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?,
),
_ => unreachable!(), _ => unreachable!(),
} }
Ok(()) Ok(())
@@ -1129,7 +791,7 @@ fn print_pull(pull: &PullRequest) {
println!( println!(
"#{} [{}] {}", "#{} [{}] {}",
pull.number.unwrap_or_default(), pull.number.unwrap_or_default(),
pull.state.as_deref().unwrap_or("unknown"), pull_state(pull),
pull.title.as_deref().unwrap_or("") pull.title.as_deref().unwrap_or("")
); );
field("url", pull.html_url.as_deref()); 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 reqwest.workspace = true
serde.workspace = true serde.workspace = true
serde_json.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)] #[derive(Debug, Eq, PartialEq)]
pub enum Target { 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> { pub fn target(activity: &models::Activity) -> Option<Target> {
use models::activity::OpType; 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}; pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url};
use serde::Deserialize; 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 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>; pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)] #[derive(Debug)]
pub enum Error { pub enum Error {
Configuration(String), Configuration(String),
InvalidInput(String),
Forbidden(String),
Transport(reqwest::Error), Transport(reqwest::Error),
Generated(String), Generated(String),
Api { status: StatusCode, message: String }, Api { status: StatusCode, message: String },
@@ -24,6 +43,8 @@ impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Configuration(message) => formatter.write_str(message), 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::Transport(error) => error.fmt(formatter),
Self::Generated(message) => formatter.write_str(message), Self::Generated(message) => formatter.write_str(message),
Self::Api { status, 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)] #[derive(Clone, Debug)]
pub struct Client { pub struct Client {
api_url: Url, api_url: Url,
@@ -95,7 +122,7 @@ impl Client {
} }
/// Returns an authenticated configuration for every generated typed API. /// 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 { apis::configuration::Configuration {
base_path: self.api_url.as_str().trim_end_matches('/').into(), base_path: self.api_url.as_str().trim_end_matches('/').into(),
client: self.http.clone(), client: self.http.clone(),
@@ -201,7 +228,6 @@ mod tests {
client.configuration().base_path, client.configuration().base_path,
"https://example.com/gitea/api/v1" "https://example.com/gitea/api/v1"
); );
let _typed_query = apis::issue_api::issue_list_issues;
let _typed_model = models::Issue::default(); 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()));
}
}