From f38c3c4939ff633d6620718facae4f69d1df055e Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 16:44:16 +0200 Subject: [PATCH] Centralize Gitea domain workflows --- AGENTS.md | 23 +- API_COVERAGE.md | 20 +- Cargo.lock | 1 + README.md | 17 +- crates/app/src/api.rs | 1115 +++---------------------- crates/app/src/domain.rs | 96 +-- crates/app/src/lib.rs | 2 - crates/app/src/presentation.rs | 47 +- crates/cli/src/config.rs | 23 +- crates/cli/src/work_items.rs | 598 +++---------- crates/gitea/Cargo.toml | 1 + crates/{app => gitea}/src/activity.rs | 39 +- crates/{app => gitea}/src/diff.rs | 0 crates/gitea/src/domain.rs | 235 ++++++ crates/gitea/src/issues.rs | 565 +++++++++++++ crates/gitea/src/lib.rs | 32 +- crates/gitea/src/milestones.rs | 238 ++++++ crates/gitea/src/pulls.rs | 342 ++++++++ crates/gitea/src/repositories.rs | 478 +++++++++++ 19 files changed, 2248 insertions(+), 1624 deletions(-) rename crates/{app => gitea}/src/activity.rs (79%) rename crates/{app => gitea}/src/diff.rs (100%) create mode 100644 crates/gitea/src/domain.rs create mode 100644 crates/gitea/src/issues.rs create mode 100644 crates/gitea/src/milestones.rs create mode 100644 crates/gitea/src/pulls.rs create mode 100644 crates/gitea/src/repositories.rs diff --git a/AGENTS.md b/AGENTS.md index 0bda8a6..2f823ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/API_COVERAGE.md b/API_COVERAGE.md index 6e81f2a..d375f50 100644 --- a/API_COVERAGE.md +++ b/API_COVERAGE.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 256839e..b408e4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,6 +475,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "tokio", ] [[package]] diff --git a/README.md b/README.md index 586e6cf..b9a13d9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index b92ba6f..35360b0 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -1,45 +1,25 @@ -use std::{ - cmp::Reverse, - collections::{BTreeSet, BinaryHeap, HashMap}, +use gotcha_gitea::{ + Client, DEFAULT_PAGE_SIZE as PAGE_SIZE, IssueQuery, RepositoryId, api_date, diff, models, }; -use gotcha_gitea::{Client, Method, apis, models}; -use serde::Serialize; -use tokio::task::JoinSet; - use crate::{ - diff, domain::{ HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails, - MilestoneDraft, PAGE_SIZE, Page, PullDetails, RepositoryData, Server, api_date, + MilestoneDraft, Page, PullDetails, RepositoryData, Server, }, presentation::compact_date, }; pub async fn load_repositories(server: &Server, page: i32) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let user = client - .current_user() + let page = client(server)? + .owned_repositories_page(page, PAGE_SIZE) .await - .map_err(|error| error.to_string())?; - let login = user.login.unwrap_or_default(); - let repositories = - apis::user_api::user_current_list_repos(&configuration, Some(page), Some(PAGE_SIZE)) - .await - .map_err(|error| error.to_string())?; - let has_more = repositories.len() == PAGE_SIZE as usize; + .map_err(message)?; Ok(Page { - items: repositories + 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()) - }) .filter_map(|repository| { Some(RepositoryData { owner: repository.owner?.login?, @@ -58,7 +38,6 @@ pub async fn load_repositories(server: &Server, page: i32) -> Result Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let labels = (!labels.is_empty()).then(|| labels.join(",")); - apis::issue_api::issue_list_issues( - &client.configuration(), - owner, - repository, - Some(status), - labels.as_deref(), - None, - Some("issues"), - (!milestone.is_empty()).then_some(milestone), - None, - None, - None, - None, - None, - Some(page), - Some(PAGE_SIZE), - ) - .await - .map(Page::from_items) - .map_err(|error| error.to_string()) + client(server)? + .issues( + &scope(owner, repository)?, + &IssueQuery { + state: status.into(), + labels: (!labels.is_empty()).then(|| labels.join(",")), + milestones: (!milestone.is_empty()).then(|| milestone.into()), + page, + limit: PAGE_SIZE, + ..Default::default() + }, + ) + .await + .map_err(message) } pub async fn load_milestones_page( @@ -102,20 +72,10 @@ pub async fn load_milestones_page( repository: &str, page: i32, ) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - apis::issue_api::issue_get_milestones_list( - &client.configuration(), - owner, - repository, - Some("all"), - None, - Some(page), - Some(PAGE_SIZE), - ) - .await - .map(Page::from_items) - .map_err(|error| error.to_string()) + client(server)? + .milestones_page(&scope(owner, repository)?, page, PAGE_SIZE) + .await + .map_err(message) } pub async fn load_labels( @@ -123,27 +83,10 @@ pub async fn load_labels( owner: &str, repository: &str, ) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let mut labels = Vec::new(); - for page in 1.. { - let batch = apis::issue_api::issue_list_labels( - &configuration, - owner, - repository, - Some(page), - Some(100), - ) + client(server)? + .labels(&scope(owner, repository)?) .await - .map_err(|error| error.to_string())?; - let done = batch.len() < 100; - labels.extend(batch); - if done { - break; - } - } - Ok(labels) + .map_err(message) } pub async fn load_milestones( @@ -151,29 +94,10 @@ pub async fn load_milestones( owner: &str, repository: &str, ) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let mut milestones = Vec::new(); - for page in 1.. { - let batch = apis::issue_api::issue_get_milestones_list( - &configuration, - owner, - repository, - Some("all"), - None, - Some(page), - Some(100), - ) + client(server)? + .milestones(&scope(owner, repository)?) .await - .map_err(|error| error.to_string())?; - let done = batch.len() < 100; - milestones.extend(batch); - if done { - break; - } - } - Ok(milestones) + .map_err(message) } pub async fn load_milestone( @@ -183,51 +107,10 @@ pub async fn load_milestone( id: i64, page: i32, ) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let milestone = - apis::issue_api::issue_get_milestone(&configuration, owner, repository, &id.to_string()) - .await - .map_err(|error| error.to_string())?; - let load = |kind| { - apis::issue_api::issue_list_issues( - &configuration, - owner, - repository, - Some("all"), - None, - None, - Some(kind), - milestone.title.as_deref(), - None, - None, - None, - None, - None, - Some(page), - Some(PAGE_SIZE), - ) - }; - let (issues, pulls) = tokio::join!(load("issues"), load("pulls")); - let issues = issues.map_err(|error| error.to_string())?; - let mut pulls = pulls.map_err(|error| error.to_string())?; - let has_more = issues.len() == PAGE_SIZE as usize || pulls.len() == PAGE_SIZE as usize; - for pull in &mut pulls { - pull.repository.get_or_insert_with(|| { - Box::new(models::RepositoryMeta { - name: Some(repository.into()), - owner: Some(owner.into()), - ..Default::default() - }) - }); - } - Ok(MilestoneDetails { - milestone, - issues, - pulls, - has_more, - }) + client(server)? + .milestone_details(&scope(owner, repository)?, id, page) + .await + .map_err(message) } pub async fn load_milestone_editor( @@ -236,31 +119,10 @@ pub async fn load_milestone_editor( repository: &str, id: i64, ) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - apis::issue_api::issue_get_milestone( - &client.configuration(), - owner, - repository, - &id.to_string(), - ) - .await - .map_err(|error| error.to_string()) -} - -#[derive(Serialize)] -struct MilestoneRequest { - title: String, - description: String, - due_on: Option, -} - -fn milestone_request(draft: MilestoneDraft) -> MilestoneRequest { - MilestoneRequest { - title: draft.title, - description: draft.description, - due_on: draft.due_date.map(api_date), - } + client(server)? + .milestone(&scope(owner, repository)?, id) + .await + .map_err(message) } pub async fn save_milestone( @@ -270,38 +132,18 @@ pub async fn save_milestone( id: Option, draft: MilestoneDraft, ) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let endpoint = match id { - Some(id) => format!( - "repos/{}/{}/milestones/{id}", - apis::urlencode(owner), - apis::urlencode(repository) - ), - None => format!( - "repos/{}/{}/milestones", - apis::urlencode(owner), - apis::urlencode(repository) - ), - }; - let request = client - .request( - if id.is_some() { - Method::PATCH - } else { - Method::POST + client(server)? + .save_milestone( + &scope(owner, repository)?, + id, + gotcha_gitea::MilestoneDraft { + title: draft.title, + description: draft.description, + due_on: draft.due_date.map(api_date), }, - &endpoint, ) - .map_err(|error| error.to_string())? - .json(&milestone_request(draft)); - client - .execute(request) .await - .map_err(|error| error.to_string())? - .json() - .await - .map_err(|error| error.to_string()) + .map_err(message) } pub async fn load_pulls( @@ -310,64 +152,19 @@ pub async fn load_pulls( milestone: &str, page: i32, ) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let owner = client - .current_user() + client(server)? + .search_pulls( + status, + (!milestone.is_empty()).then_some(milestone), + page, + PAGE_SIZE, + ) .await - .map_err(|error| error.to_string())? - .login - .ok_or("The server account has no username.")?; - apis::issue_api::issue_search_issues( - &configuration, - Some(status), - None, - (!milestone.is_empty()).then_some(milestone), - None, - None, - Some("pulls"), - None, - None, - None, - None, - None, - None, - None, - Some(&owner), - None, - Some(page), - Some(PAGE_SIZE), - ) - .await - .map(Page::from_items) - .map_err(|error| error.to_string()) + .map_err(message) } pub async fn load_pull_milestones(server: &Server) -> Result, String> { - let (open, closed) = tokio::try_join!( - load_all_pulls(server, "open"), - load_all_pulls(server, "closed") - )?; - Ok(open - .into_iter() - .chain(closed) - .filter_map(|pull| pull.milestone?.title) - .collect::>() - .into_iter() - .collect()) -} - -async fn load_all_pulls(server: &Server, status: &str) -> Result, String> { - let mut pulls = Vec::new(); - for page in 1.. { - let batch = load_pulls(server, status, "", page).await?; - pulls.extend(batch.items); - if !batch.has_more { - break; - } - } - Ok(pulls) + client(server)?.pull_milestones().await.map_err(message) } pub async fn load_branches( @@ -376,28 +173,10 @@ pub async fn load_branches( repository: &str, default_branch: &str, ) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let mut branches = Vec::new(); - for page in 1.. { - let batch = apis::repository_api::repo_list_branches( - &configuration, - owner, - repository, - Some(page), - Some(100), - ) + client(server)? + .branches(&scope(owner, repository)?, default_branch) .await - .map_err(|error| error.to_string())?; - let done = batch.len() < 100; - branches.extend(batch.into_iter().filter_map(|branch| branch.name)); - if done { - break; - } - } - branches.sort_by_key(|branch| (branch != default_branch, branch.to_lowercase())); - Ok(branches) + .map_err(message) } pub async fn load_repository_contents( @@ -406,26 +185,10 @@ pub async fn load_repository_contents( repository: &str, path: &str, ) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - if path.is_empty() { - apis::repository_api::repo_get_contents_list(&configuration, owner, repository, None) - .await - .map_err(|error| error.to_string()) - } else { - apis::repository_api::repo_get_contents_ext( - &configuration, - owner, - repository, - path, - None, - None, - ) + client(server)? + .repository_contents(&scope(owner, repository)?, path) .await - .map(|contents| contents.dir_contents.unwrap_or_default()) - .map_err(|error| error.to_string()) - } + .map_err(message) } pub async fn load_repository_file( @@ -434,15 +197,10 @@ pub async fn load_repository_file( repository: &str, path: &str, ) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - apis::repository_api::repo_get_raw_file(&client.configuration(), owner, repository, path, None) + client(server)? + .repository_file(&scope(owner, repository)?, path) .await - .map_err(|error| error.to_string())? - .bytes() - .await - .map(|bytes| bytes.to_vec()) - .map_err(|error| error.to_string()) + .map_err(message) } pub async fn load_branch_commits( @@ -453,23 +211,10 @@ pub async fn load_branch_commits( path: Option<&str>, pages: u32, ) -> Result, String> { - load_commits_for_ref(server, owner, repository, Some(branch), path, pages) + client(server)? + .branch_history(&scope(owner, repository)?, 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(), - }) + .map_err(message) } pub async fn load_all_commits( @@ -480,65 +225,10 @@ pub async fn load_all_commits( path: Option<&str>, pages: u32, ) -> Result, String> { - let mut tasks = JoinSet::new(); - for (lane, branch) in branches.iter().cloned().enumerate() { - let (server, owner, repository) = - (server.clone(), owner.to_string(), repository.to_string()); - let path = path.map(str::to_string); - tasks.spawn(async move { - let commits = load_commits_for_ref( - &server, - &owner, - &repository, - Some(&branch), - path.as_deref(), - pages, - ) - .await?; - Ok::<_, String>((lane, branch, commits)) - }); - } - - let mut histories = Vec::new(); - while let Some(result) = tasks.join_next().await { - histories.push(result.map_err(|error| error.to_string())??); - } - histories.sort_by_key(|(lane, _, _)| *lane); - let 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 = load_pull_refs(server, owner, repository) + client(server)? + .all_history(&scope(owner, repository)?, branches, path, pages) .await - .unwrap_or_default(); - Ok(Page { - items: build_graph(histories, pull_refs), - has_more, - }) -} - -pub async fn load_pull_files( - configuration: &apis::configuration::Configuration, - owner: &str, - repository: &str, - number: i64, - page: i32, -) -> Result, String> { - apis::repository_api::repo_get_pull_request_files( - configuration, - owner, - repository, - number, - None, - None, - Some(page), - Some(PAGE_SIZE), - ) - .await - .map(Page::from_items) - .map_err(|error| error.to_string()) + .map_err(message) } pub async fn load_issue( @@ -546,37 +236,12 @@ pub async fn load_issue( owner: &str, repository: &str, number: i64, - page: i32, + _page: i32, ) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let (issue, comments, viewer) = tokio::join!( - apis::issue_api::issue_get_issue(&configuration, owner, repository, number), - load_issue_comments(&configuration, owner, repository, number, page), - client.current_user(), - ); - Ok(IssueDetails { - issue: issue.map_err(|error| error.to_string())?, - has_more: false, - comments: comments?, - viewer_id: viewer.map_err(|error| error.to_string())?.id, - }) -} - -async fn load_issue_comments( - configuration: &apis::configuration::Configuration, - owner: &str, - repository: &str, - number: i64, - page: i32, -) -> Result, String> { - if page > 1 { - return Ok(Vec::new()); - } - apis::issue_api::issue_get_comments(configuration, owner, repository, number, None, None) + client(server)? + .issue_details(&scope(owner, repository)?, number) .await - .map_err(|error| error.to_string()) + .map_err(message) } pub async fn save_issue_comment( @@ -587,57 +252,11 @@ pub async fn save_issue_comment( comment_id: Option, body: String, ) -> Result<(), String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - match comment_id { - Some(id) => { - let (comment, viewer) = tokio::join!( - apis::issue_api::issue_get_comment(&configuration, owner, repository, id), - client.current_user(), - ); - let comment = comment.map_err(|error| error.to_string())?; - let viewer = viewer.map_err(|error| error.to_string())?; - let owned = matches!( - (comment.user.as_ref().and_then(|user| user.id), viewer.id), - (Some(author), Some(viewer)) if author == viewer - ); - if !owned || !comment_belongs_to_issue(&comment, number) { - return Err("You can only edit your own comments.".into()); - } - apis::issue_api::issue_edit_comment( - &configuration, - owner, - repository, - id, - Some(models::EditIssueCommentOption::new(body)), - ) - .await - .map_err(|error| error.to_string())?; - } - None => { - apis::issue_api::issue_create_comment( - &configuration, - owner, - repository, - number, - Some(models::CreateIssueCommentOption::new(body)), - ) - .await - .map_err(|error| error.to_string())?; - } - } - Ok(()) -} - -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) + client(server)? + .save_issue_comment(&scope(owner, repository)?, number, comment_id, body) + .await + .map(|_| ()) + .map_err(message) } pub async fn load_issue_editor( @@ -646,50 +265,10 @@ pub async fn load_issue_editor( repository: &str, number: Option, ) -> Result { - let issue = async { - match number { - Some(number) => { - let client = Client::new(&server.url, Some(&server.token)) - .map_err(|error| error.to_string())?; - apis::issue_api::issue_get_issue(&client.configuration(), owner, repository, number) - .await - .map(Some) - .map_err(|error| error.to_string()) - } - None => Ok(None), - } - }; - let (issue, labels, milestones) = tokio::try_join!( - issue, - load_labels(server, owner, repository), - load_milestones(server, owner, repository) - )?; - Ok(IssueEditorData { - issue, - labels, - milestones, - }) -} - -fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption { - models::CreateIssueOption { - body: Some(draft.body), - due_date: draft.due_date.map(api_date), - labels: Some(draft.label_ids), - milestone: draft.milestone_id, - ..models::CreateIssueOption::new(draft.title) - } -} - -fn edit_issue_option(draft: &IssueDraft) -> models::EditIssueOption { - models::EditIssueOption { - body: Some(draft.body.clone()), - due_date: draft.due_date.map(api_date), - milestone: Some(draft.milestone_id.unwrap_or_default()), - title: Some(draft.title.clone()), - unset_due_date: draft.due_date.is_none().then_some(true), - ..models::EditIssueOption::new() - } + client(server)? + .issue_editor(&scope(owner, repository)?, number) + .await + .map_err(message) } pub async fn create_issue( @@ -698,16 +277,10 @@ pub async fn create_issue( repository: &str, draft: IssueDraft, ) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - apis::issue_api::issue_create_issue( - &client.configuration(), - owner, - repository, - Some(create_issue_option(draft)), - ) - .await - .map_err(|error| error.to_string()) + client(server)? + .create_issue_draft(&scope(owner, repository)?, shared_issue_draft(draft)) + .await + .map_err(message) } pub async fn edit_issue( @@ -717,36 +290,14 @@ pub async fn edit_issue( number: i64, draft: IssueDraft, ) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let issue = apis::issue_api::issue_edit_issue( - &configuration, - owner, - repository, - number, - Some(edit_issue_option(&draft)), - ) - .await - .map_err(|error| error.to_string())?; - apis::issue_api::issue_replace_labels( - &configuration, - owner, - repository, - number, - Some(models::IssueLabelsOption { - labels: Some( - draft - .label_ids - .into_iter() - .map(serde_json::Value::from) - .collect(), - ), - }), - ) - .await - .map_err(|error| error.to_string())?; - Ok(issue) + client(server)? + .edit_issue_draft( + &scope(owner, repository)?, + number, + shared_issue_draft(draft), + ) + .await + .map_err(message) } pub async fn load_pull( @@ -756,21 +307,10 @@ pub async fn load_pull( number: i64, page: i32, ) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let (pull, comments, files) = tokio::join!( - apis::repository_api::repo_get_pull_request(&configuration, owner, repository, number,), - load_issue_comments(&configuration, owner, repository, number, page), - load_pull_files(&configuration, owner, repository, number, page), - ); - let files = files?; - Ok(PullDetails { - pull: pull.map_err(|error| error.to_string())?, - has_more: files.has_more, - comments: comments?, - files: files.items, - }) + client(server)? + .pull_details(&scope(owner, repository)?, number, page) + .await + .map_err(message) } pub async fn load_commit_files( @@ -779,20 +319,10 @@ pub async fn load_commit_files( repository: &str, sha: &str, ) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - apis::repository_api::repo_get_single_commit( - &client.configuration(), - owner, - repository, - sha, - Some(true), - None, - Some(true), - ) - .await - .map(|commit| commit.files.unwrap_or_default()) - .map_err(|error| error.to_string()) + client(server)? + .commit_files(&scope(owner, repository)?, sha) + .await + .map_err(message) } pub async fn load_commit_diff( @@ -802,18 +332,12 @@ pub async fn load_commit_diff( sha: &str, path: &str, ) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - apis::repository_api::repo_download_commit_diff_or_patch( - &client.configuration(), - owner, - repository, - sha, - "diff", - ) - .await - .map(|text| diff::parse_file(&text, path)) - .map_err(|error| error.to_string()) + let client = client(server)?; + let diff = client + .commit_diff(&scope(owner, repository)?, sha) + .await + .map_err(message)?; + Ok(diff::parse_file(&diff, path)) } pub async fn load_pull_diff( @@ -823,403 +347,36 @@ pub async fn load_pull_diff( number: i64, path: &str, ) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - apis::repository_api::repo_download_pull_diff_or_patch( - &client.configuration(), - owner, - repository, - number, - "diff", - Some(false), - ) - .await - .map(|text| diff::parse_file(&text, path)) - .map_err(|error| error.to_string()) -} - -async fn load_pull_refs( - server: &Server, - owner: &str, - repository: &str, -) -> Result>, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let mut refs: HashMap> = HashMap::new(); - for page in 1.. { - let batch = apis::repository_api::repo_list_pull_requests( - &configuration, - owner, - repository, - None, - Some("all"), - None, - None, - None, - None, - Some(page), - Some(100), - ) + let client = client(server)?; + let diff = client + .pull_diff(&scope(owner, repository)?, number) .await - .map_err(|error| error.to_string())?; - let done = batch.len() < 100; - for pull in batch { - let Some(head) = pull.head else { continue }; - let Some(sha) = head.sha else { continue }; - let Some(label) = head.label.or(head.r#ref) else { - continue; - }; - if !label.is_empty() { - refs.entry(sha).or_default().push(label); - } - } - if done { - break; - } - } - Ok(refs) -} - -fn build_graph( - histories: Vec<(usize, String, Vec)>, - mut extra_refs: HashMap>, -) -> Vec { - let mut commits = HashMap::new(); - let mut ranks: HashMap = HashMap::new(); - let mut refs: HashMap> = 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> = 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>, 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) { - for lane in left.min(right) + 1..=left.max(right) { - connections.insert(lane); - } -} - -fn commit_parents(commit: &models::Commit) -> impl Iterator { - commit - .parents - .as_deref() - .unwrap_or_default() - .iter() - .filter_map(|parent| parent.sha.as_deref()) -} - -async fn load_commits_for_ref( - server: &Server, - owner: &str, - repository: &str, - branch: Option<&str>, - path: Option<&str>, - pages: u32, -) -> Result, String> { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let mut commits = Vec::new(); - let mut has_more = false; - for page in 1..=pages.max(1) { - let batch = apis::repository_api::repo_get_all_commits( - &configuration, - owner, - repository, - branch, - path, - None, - None, - None, - None, - Some(false), - Some(page as i32), - Some(PAGE_SIZE), - None, - ) - .await - .map_err(|error| error.to_string())?; - has_more = batch.len() == PAGE_SIZE as usize; - commits.extend(batch); - if !has_more { - break; - } - } - Ok(Page { - items: commits, - has_more, - }) + .map_err(message)?; + Ok(diff::parse_file(&diff, path)) } pub async fn load_home(server: &Server, page: i32) -> Result { - let client = - Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; - let configuration = client.configuration(); - let login = client - .current_user() - .await - .map_err(|error| error.to_string())? - .login - .ok_or("The server account has no username.")?; - let activities = apis::user_api::user_list_activity_feeds( - &configuration, - &login, - Some(true), - None, - Some(page), - Some(PAGE_SIZE), - ); - let (activities, heatmap) = tokio::join!( - activities, - apis::user_api::user_get_heatmap_data(&configuration, &login), - ); - Ok(HomeData { - has_more: activities - .as_ref() - .is_ok_and(|items| items.len() == PAGE_SIZE as usize), - activities: activities.map_err(|error| error.to_string())?, - heatmap: heatmap.map_err(|error| error.to_string())?, - }) + client(server)?.home(page).await.map_err(message) } -#[cfg(test)] -mod tests { - use super::*; +fn client(server: &Server) -> Result { + Client::new(&server.url, Some(&server.token)).map_err(message) +} - 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() - } - } +fn scope(owner: &str, repository: &str) -> Result { + RepositoryId::new(owner, repository).map_err(message) +} - #[test] - fn lays_out_merged_pull_request_on_its_parent_lane() { - let commits = build_graph( - vec![( - 0, - "main".into(), - vec![ - commit("tip", &["merge"]), - commit("merge", &["main-before", "feature"]), - commit("feature", &["base"]), - commit("main-before", &["base"]), - commit("base", &[]), - ], - )], - HashMap::from([("feature".into(), vec!["linux-refactoring-base".into()])]), - ); - - assert_eq!( - commits - .iter() - .map(|commit| commit.commit.sha.as_deref().unwrap()) - .collect::>(), - ["tip", "merge", "feature", "main-before", "base"] - ); - assert_eq!(commits[0].refs, ["main"]); - assert_eq!(commits[1].connections, [1]); - assert_eq!(commits[2].refs, ["linux-refactoring-base"]); - assert_eq!(commits[2].node_lane, Some(1)); - assert_eq!(commits[1].top_lanes, [0]); - assert_eq!(commits[1].bottom_lanes, [0, 1]); - assert_eq!(commits[2].top_lanes, [0, 1]); - assert_eq!(commits[2].bottom_lanes, [0, 1]); - assert!(commits[3].connections.is_empty()); - assert_eq!(commits[3].bottom_lanes, [0, 1]); - assert_eq!(commits[4].top_lanes, [0, 1]); - assert_eq!(commits[4].connections, [1]); - assert!(commits[4].bottom_lanes.is_empty()); - assert_eq!(commits[4].node_lane, Some(0)); - } - - #[test] - fn builds_complete_create_and_edit_issue_requests() { - let create = create_issue_option(IssueDraft { - title: "Title".into(), - body: "**Body**".into(), - label_ids: vec![2, 3], - milestone_id: Some(5), - due_date: Some(1_709_164_800), - }); - assert_eq!(create.title, "Title"); - assert_eq!(create.body.as_deref(), Some("**Body**")); - assert_eq!(create.labels, Some(vec![2, 3])); - assert_eq!(create.milestone, Some(5)); - assert_eq!(create.due_date.as_deref(), Some("2024-02-29T00:00:00Z")); - - let edit = edit_issue_option(&IssueDraft { - title: "New".into(), - body: String::new(), - label_ids: Vec::new(), - milestone_id: None, - due_date: None, - }); - assert_eq!(edit.title.as_deref(), Some("New")); - assert_eq!(edit.body.as_deref(), Some("")); - assert_eq!(edit.milestone, Some(0)); - assert_eq!(edit.unset_due_date, Some(true)); - } - - #[test] - fn scopes_comment_edits_to_the_selected_issue() { - let comment = models::Comment { - issue_url: Some("https://gitea.example/api/v1/repos/o/r/issues/4/".into()), - ..Default::default() - }; - assert!(comment_belongs_to_issue(&comment, 4)); - assert!(!comment_belongs_to_issue(&comment, 5)); - assert!(!comment_belongs_to_issue(&models::Comment::default(), 4)); - } - - #[test] - fn milestone_requests_can_set_and_clear_due_dates() { - let dated = serde_json::to_value(milestone_request(MilestoneDraft { - title: "Version 1".into(), - description: "Description".into(), - due_date: Some(1_709_164_800), - })) - .unwrap(); - assert_eq!(dated["title"], "Version 1"); - assert_eq!(dated["description"], "Description"); - assert_eq!(dated["due_on"], "2024-02-29T00:00:00Z"); - - let cleared = serde_json::to_value(milestone_request(MilestoneDraft { - title: "Version 1".into(), - description: String::new(), - due_date: None, - })) - .unwrap(); - assert!(cleared["due_on"].is_null()); +fn shared_issue_draft(draft: IssueDraft) -> gotcha_gitea::IssueDraft { + gotcha_gitea::IssueDraft { + title: draft.title, + body: draft.body, + label_ids: draft.label_ids, + milestone_id: draft.milestone_id, + due_date: draft.due_date.map(api_date), } } + +fn message(error: impl std::fmt::Display) -> String { + error.to_string() +} diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 142fd23..9788db4 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -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 { - pub items: Vec, - pub has_more: bool, -} - -impl Page { - pub fn from_items(items: Vec) -> 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, - pub bottom_lanes: Vec, - pub node_lane: Option, - pub connections: Vec, - pub refs: Vec, -} - #[derive(Default)] pub struct State { pub preferences: Preferences, @@ -145,25 +122,6 @@ pub struct State { pub repositories: Vec, } -pub struct HomeData { - pub activities: Vec, - pub heatmap: Vec, - pub has_more: bool, -} - -pub struct IssueDetails { - pub issue: models::Issue, - pub comments: Vec, - pub viewer_id: Option, - pub has_more: bool, -} - -pub struct IssueEditorData { - pub issue: Option, - pub labels: Vec, - pub milestones: Vec, -} - pub struct IssueDraft { pub title: String, pub body: String, @@ -172,26 +130,12 @@ pub struct IssueDraft { pub due_date: Option, } -pub struct MilestoneDetails { - pub milestone: models::Milestone, - pub issues: Vec, - pub pulls: Vec, - pub has_more: bool, -} - pub struct MilestoneDraft { pub title: String, pub description: String, pub due_date: Option, } -pub struct PullDetails { - pub pull: models::PullRequest, - pub comments: Vec, - pub files: Vec, - 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 { - 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); - } } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 961d2a1..65db511 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -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; diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 7e93573..2ba0b5e 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -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) -> 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) -> 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 { } 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(), } } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 4078563..049f4e8 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -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 = std::result::Result; @@ -36,26 +36,7 @@ pub struct Selection { pub repository: Option, } -#[derive(Clone)] -pub struct RepositoryScope { - pub owner: String, - pub repository: String, -} - -impl RepositoryScope { - pub fn parse(value: &str) -> Result { - 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 { diff --git a/crates/cli/src/work_items.rs b/crates/cli/src/work_items.rs index e4f8a8c..930dc06 100644 --- a/crates/cli/src/work_items.rs +++ b/crates/cli/src/work_items.rs @@ -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> { - 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(input: &str) -> Result> { Ok(serde_yaml::from_str(input)?) } -async fn create_issue( - configuration: &apis::configuration::Configuration, - scope: &RepositoryScope, -) -> Result<(), Box> { - 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> { + 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> { - 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> { - 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::>(); - 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, Box> { - 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> { + 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> { - 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> { - 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> { - 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> { - 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> { 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> { - 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> { + 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> { - 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> { +async fn create_milestone(client: &Client, scope: &RepositoryScope) -> Result<(), Box> { 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> { - let milestone = apis::issue_api::issue_edit_milestone( - configuration, - &scope.owner, - &scope.repository, - id, - Some(read_yaml::()?), - ) - .await?; + let milestone = client + .edit_milestone(scope, id, read_yaml::()?) + .await?; print_milestone(&milestone); Ok(()) } async fn delete_milestone( - configuration: &apis::configuration::Configuration, + client: &Client, scope: &RepositoryScope, id: &str, ) -> Result<(), Box> { - 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> { - 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> { + 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> { - 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> { +async fn create_pull(client: &Client, scope: &RepositoryScope) -> Result<(), Box> { 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> { - let pull = apis::repository_api::repo_edit_pull_request( - configuration, - &scope.owner, - &scope.repository, - index, - Some(read_yaml::()?), - ) - .await?; + let pull = client + .edit_pull(scope, index, read_yaml::()?) + .await?; print_pull(&pull); Ok(()) } async fn merge_pull( - configuration: &apis::configuration::Configuration, + client: &Client, scope: &RepositoryScope, index: i64, ) -> Result<(), Box> { - apis::repository_api::repo_merge_pull_request( - configuration, - &scope.owner, - &scope.repository, - index, - Some(read_yaml::()?), - ) - .await?; + client + .merge_pull(scope, index, read_yaml::()?) + .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> { 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()); diff --git a/crates/gitea/Cargo.toml b/crates/gitea/Cargo.toml index 3348237..6b9341b 100644 --- a/crates/gitea/Cargo.toml +++ b/crates/gitea/Cargo.toml @@ -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 diff --git a/crates/app/src/activity.rs b/crates/gitea/src/activity.rs similarity index 79% rename from crates/app/src/activity.rs rename to crates/gitea/src/activity.rs index c8aae91..71e13e7 100644 --- a/crates/app/src/activity.rs +++ b/crates/gitea/src/activity.rs @@ -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 { + 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 { use models::activity::OpType; diff --git a/crates/app/src/diff.rs b/crates/gitea/src/diff.rs similarity index 100% rename from crates/app/src/diff.rs rename to crates/gitea/src/diff.rs diff --git a/crates/gitea/src/domain.rs b/crates/gitea/src/domain.rs new file mode 100644 index 0000000..3bc8138 --- /dev/null +++ b/crates/gitea/src/domain.rs @@ -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, repository: impl Into) -> crate::Result { + 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 { + 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 { + pub items: Vec, + pub has_more: bool, +} + +impl Page { + pub fn from_items(items: Vec, 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, + pub keyword: Option, + pub kind: String, + pub milestones: Option, + pub from: Option, + pub until: Option, + pub author: Option, + pub assignee: Option, + pub mentions: Option, + 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, + pub milestone_name: Option, +} + +#[derive(Clone, Debug)] +pub struct EditIssue { + pub option: models::EditIssueOption, + pub replace_labels: Option>, + pub add_labels: Vec, + pub remove_labels: Vec, + pub add_assignees: Vec, + pub milestone_name: Option, +} + +#[derive(Clone, Debug)] +pub struct IssueDraft { + pub title: String, + pub body: String, + pub label_ids: Vec, + pub milestone_id: Option, + pub due_date: Option, +} + +#[derive(Clone, Debug)] +pub struct IssueDetails { + pub issue: models::Issue, + pub comments: Vec, + pub viewer_id: Option, + pub has_more: bool, +} + +#[derive(Clone, Debug)] +pub struct IssueEditorData { + pub issue: Option, + pub labels: Vec, + pub milestones: Vec, +} + +#[derive(Clone, Debug)] +pub struct MilestoneDraft { + pub title: String, + pub description: String, + pub due_on: Option, +} + +#[derive(Clone, Debug)] +pub struct MilestoneDetails { + pub milestone: models::Milestone, + pub issues: Vec, + pub pulls: Vec, + pub has_more: bool, +} + +#[derive(Clone, Debug)] +pub struct PullDetails { + pub pull: models::PullRequest, + pub comments: Vec, + pub files: Vec, + pub has_more: bool, +} + +#[derive(Clone, Debug)] +pub struct HistoryCommit { + pub commit: models::Commit, + pub top_lanes: Vec, + pub bottom_lanes: Vec, + pub node_lane: Option, + pub connections: Vec, + pub refs: Vec, +} + +#[derive(Clone, Debug)] +pub struct HomeData { + pub activities: Vec, + pub heatmap: Vec, + pub has_more: bool, +} + +pub type PullRefs = HashMap>; + +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 { + 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); + } +} diff --git a/crates/gitea/src/issues.rs b/crates/gitea/src/issues.rs new file mode 100644 index 0000000..887202f --- /dev/null +++ b/crates/gitea/src/issues.rs @@ -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> { + 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 { + 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 { + 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, + ) -> Result { + 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> { + 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> { + 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 { + 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 { + 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> { + 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 { + 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::>(); + 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 { + 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> { + 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> { + 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 { + 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, + body: String, + ) -> Result { + 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) -> 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])); + } +} diff --git a/crates/gitea/src/lib.rs b/crates/gitea/src/lib.rs index 349b5e6..f7ed3ee 100644 --- a/crates/gitea/src/lib.rs +++ b/crates/gitea/src/lib.rs @@ -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 = std::result::Result; #[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 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(); } } diff --git a/crates/gitea/src/milestones.rs b/crates/gitea/src/milestones.rs new file mode 100644 index 0000000..4926b83 --- /dev/null +++ b/crates/gitea/src/milestones.rs @@ -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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + draft: MilestoneDraft, + ) -> Result { + 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, +} + +#[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()); + } +} diff --git a/crates/gitea/src/pulls.rs b/crates/gitea/src/pulls.rs new file mode 100644 index 0000000..8cd074a --- /dev/null +++ b/crates/gitea/src/pulls.rs @@ -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> { + 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> { + 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> { + 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::>() + .into_iter() + .collect()) + } + + async fn all_searched_pulls(&self, state: &str) -> Result> { + 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 { + 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 { + 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 { + 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 { + 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> { + 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> { + 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> { + 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> { + 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 { + 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(()) +} diff --git a/crates/gitea/src/repositories.rs b/crates/gitea/src/repositories.rs new file mode 100644 index 0000000..51b329f --- /dev/null +++ b/crates/gitea/src/repositories.rs @@ -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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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> { + 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)>, + mut extra_refs: PullRefs, +) -> Vec { + let mut commits = HashMap::new(); + let mut ranks: HashMap = 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> = 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>, 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) { + for lane in left.min(right) + 1..=left.max(right) { + connections.insert(lane); + } +} + +fn commit_parents(commit: &models::Commit) -> impl Iterator { + 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())); + } +}