Prepare Gotcha 1.0 for release

This commit is contained in:
Georg Bauer
2026-08-03 17:54:07 +02:00
parent 1c4a6efd13
commit 46cca8fdd2
60 changed files with 7916 additions and 7173 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "gotcha_gitea"
version = "0.1.0"
version = "1.0.0"
description = "A small, reusable Rust client for the Gitea API"
edition.workspace = true
license.workspace = true

View File

@@ -187,7 +187,7 @@ pub fn parse_api_date(value: &str) -> Option<i64> {
(civil_from_days(days) == (year, month, day)).then_some(days * 86_400)
}
fn civil_from_days(days: i64) -> (i64, i64, i64) {
pub 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;
@@ -202,7 +202,7 @@ fn civil_from_days(days: i64) -> (i64, i64, i64) {
(year, month, day)
}
fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
pub 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;

View File

@@ -4,7 +4,7 @@ use crate::{
CreateIssue, EditIssue, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, Page,
RepositoryId,
},
models,
models, positive,
};
use gitea_openapi::apis;
@@ -460,111 +460,8 @@ pub fn comment_can_edit(comment: &models::Comment, viewer_id: Option<i64>) -> bo
)
}
fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption {
models::CreateIssueOption {
body: Some(draft.body),
closed: Some(draft.closed),
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()),
state: Some(if draft.closed { "closed" } else { "open" }.into()),
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(())
}
mod helpers;
use helpers::*;
#[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,
closed: true,
};
let create = create_issue_option(draft.clone());
assert_eq!(create.title, "Title");
assert_eq!(create.body.as_deref(), Some("Body"));
assert_eq!(create.closed, Some(true));
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.state.as_deref(), Some("closed"));
assert_eq!(edit.option.unset_due_date, Some(true));
assert_eq!(edit.replace_labels, Some(vec![2, 4]));
}
}
mod tests;

View File

@@ -0,0 +1,56 @@
use crate::{
domain::{EditIssue, IssueDraft},
models,
};
pub(super) fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption {
models::CreateIssueOption {
body: Some(draft.body),
closed: Some(draft.closed),
due_date: draft.due_date,
labels: Some(draft.label_ids),
milestone: draft.milestone_id,
..models::CreateIssueOption::new(draft.title)
}
}
pub(super) 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()),
state: Some(if draft.closed { "closed" } else { "open" }.into()),
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,
}
}
pub(super) fn label_option(labels: &[i64]) -> models::IssueLabelsOption {
models::IssueLabelsOption {
labels: Some(
labels
.iter()
.copied()
.map(serde_json::Value::from)
.collect(),
),
}
}
pub(super) 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)
}

View File

@@ -0,0 +1,44 @@
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,
closed: true,
};
let create = create_issue_option(draft.clone());
assert_eq!(create.title, "Title");
assert_eq!(create.body.as_deref(), Some("Body"));
assert_eq!(create.closed, Some(true));
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.state.as_deref(), Some("closed"));
assert_eq!(edit.option.unset_due_date, Some(true));
assert_eq!(edit.replace_labels, Some(vec![2, 4]));
}

View File

@@ -23,13 +23,31 @@ pub use activity::ActivityFilter;
pub use domain::{
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,
api_date, parse_api_date,
api_date, civil_from_days, days_from_civil, parse_api_date,
};
pub use issues::comment_can_edit;
pub use pulls::{PullFileSource, pull_file_source, pull_state};
pub type Result<T> = std::result::Result<T, Error>;
pub(crate) 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(())
}
pub(crate) fn positive(value: i64, name: &str) -> Result<()> {
if value < 1 {
return Err(Error::InvalidInput(format!(
"{name} must be a positive integer"
)));
}
Ok(())
}
#[derive(Debug)]
pub enum Error {
Configuration(String),

View File

@@ -3,7 +3,7 @@ use std::collections::BTreeSet;
use crate::{
Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, Page, PullDetails, RepositoryId},
models,
models, positive, validate_page,
};
use gitea_openapi::apis;
@@ -323,21 +323,3 @@ impl Client {
.map_err(Error::generated)
}
}
fn validate_page(page: i32, limit: i32) -> Result<()> {
if page < 1 || limit < 1 {
return Err(Error::InvalidInput(
"page and limit must be positive".into(),
));
}
Ok(())
}
fn positive(value: i64, name: &str) -> Result<()> {
if value < 1 {
return Err(Error::InvalidInput(format!(
"{name} must be a positive integer"
)));
}
Ok(())
}

View File

@@ -8,7 +8,7 @@ use tokio::task::JoinSet;
use crate::{
Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, HistoryCommit, Page, RepositoryId},
models,
models, validate_page,
};
use gitea_openapi::apis;
@@ -481,112 +481,5 @@ fn commit_parents(commit: &models::Commit) -> impl Iterator<Item = &str> {
.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", &["base"]),
commit("base", &[]),
],
),
(
1,
"feature".into(),
vec![commit("right", &["base"]), commit("base", &[])],
),
],
PullMetadata::default(),
);
assert_eq!(rows.len(), 4);
assert!(rows.iter().any(|row| row.refs == ["main"]));
assert!(rows.iter().any(|row| row.refs == ["feature"]));
let merge = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("merge"))
.unwrap();
assert_eq!(merge.node_lane, Some(0));
assert_eq!(merge.bottom_connections, [1]);
let right = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("right"))
.unwrap();
assert_eq!(right.node_lane, Some(1));
let base = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("base"))
.unwrap();
assert_eq!(base.node_lane, Some(0));
assert_eq!(base.top_connections, [1]);
}
#[test]
fn labels_first_commit_of_pull_branch() {
let rows = build_graph(
vec![(
0,
"main".into(),
vec![
commit("merge", &["main", "head"]),
commit("head", &["first"]),
commit("first", &["base"]),
commit("main", &["base"]),
commit("base", &[]),
],
)],
PullMetadata {
tips: HashMap::from([("head".into(), vec!["feature".into()])]),
starts: vec![PullBranch {
head: "head".into(),
base: "base".into(),
label: "feature".into(),
}],
},
);
let first = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("first"))
.unwrap();
assert_eq!(first.branch_starts, ["feature"]);
assert!(
rows.iter()
.find(|row| row.commit.sha.as_deref() == Some("head"))
.unwrap()
.refs
.contains(&"feature".into())
);
}
}
mod tests;

View File

@@ -0,0 +1,97 @@
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", &["base"]),
commit("base", &[]),
],
),
(
1,
"feature".into(),
vec![commit("right", &["base"]), commit("base", &[])],
),
],
PullMetadata::default(),
);
assert_eq!(rows.len(), 4);
assert!(rows.iter().any(|row| row.refs == ["main"]));
assert!(rows.iter().any(|row| row.refs == ["feature"]));
let merge = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("merge"))
.unwrap();
assert_eq!(merge.node_lane, Some(0));
assert_eq!(merge.bottom_connections, [1]);
let right = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("right"))
.unwrap();
assert_eq!(right.node_lane, Some(1));
let base = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("base"))
.unwrap();
assert_eq!(base.node_lane, Some(0));
assert_eq!(base.top_connections, [1]);
}
#[test]
fn labels_first_commit_of_pull_branch() {
let rows = build_graph(
vec![(
0,
"main".into(),
vec![
commit("merge", &["main", "head"]),
commit("head", &["first"]),
commit("first", &["base"]),
commit("main", &["base"]),
commit("base", &[]),
],
)],
PullMetadata {
tips: HashMap::from([("head".into(), vec!["feature".into()])]),
starts: vec![PullBranch {
head: "head".into(),
base: "base".into(),
label: "feature".into(),
}],
},
);
let first = rows
.iter()
.find(|row| row.commit.sha.as_deref() == Some("first"))
.unwrap();
assert_eq!(first.branch_starts, ["feature"]);
assert!(
rows.iter()
.find(|row| row.commit.sha.as_deref() == Some("head"))
.unwrap()
.refs
.contains(&"feature".into())
);
}