Add issue creation and editing
This commit is contained in:
@@ -9,8 +9,8 @@ use tokio::task::JoinSet;
|
||||
use crate::{
|
||||
diff,
|
||||
domain::{
|
||||
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
|
||||
Server,
|
||||
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails,
|
||||
PullDetails, RepositoryData, Server, api_date,
|
||||
},
|
||||
presentation::compact_date,
|
||||
};
|
||||
@@ -430,6 +430,115 @@ pub async fn load_issue(
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn load_issue_editor(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
number: Option<i64>,
|
||||
) -> Result<IssueEditorData, String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_issue(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
draft: IssueDraft,
|
||||
) -> Result<models::Issue, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
pub async fn edit_issue(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
repository: &str,
|
||||
number: i64,
|
||||
draft: IssueDraft,
|
||||
) -> Result<models::Issue, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
pub async fn load_pull(
|
||||
server: &Server,
|
||||
owner: &str,
|
||||
@@ -845,4 +954,32 @@ mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,20 @@ pub struct IssueDetails {
|
||||
pub comments: Vec<models::Comment>,
|
||||
}
|
||||
|
||||
pub struct IssueEditorData {
|
||||
pub issue: Option<models::Issue>,
|
||||
pub labels: Vec<models::Label>,
|
||||
pub milestones: Vec<models::Milestone>,
|
||||
}
|
||||
|
||||
pub struct IssueDraft {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub label_ids: Vec<i64>,
|
||||
pub milestone_id: Option<i64>,
|
||||
pub due_date: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct MilestoneDetails {
|
||||
pub milestone: models::Milestone,
|
||||
pub issues: Vec<models::Issue>,
|
||||
@@ -155,6 +169,49 @@ pub fn open_status() -> String {
|
||||
"open".into()
|
||||
}
|
||||
|
||||
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;
|
||||
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)
|
||||
}
|
||||
|
||||
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;
|
||||
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
|
||||
}
|
||||
|
||||
pub fn api_date(timestamp: i64) -> String {
|
||||
let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400));
|
||||
format!("{year:04}-{month:02}-{day:02}T00:00:00Z")
|
||||
}
|
||||
|
||||
pub fn parse_api_date(value: &str) -> Option<i64> {
|
||||
let date = value.get(..10)?;
|
||||
let mut parts = date.split('-');
|
||||
let year = parts.next()?.parse().ok()?;
|
||||
let month = parts.next()?.parse().ok()?;
|
||||
let day = parts.next()?.parse().ok()?;
|
||||
if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||
return None;
|
||||
}
|
||||
let days = days_from_civil(year, month, day);
|
||||
(civil_from_days(days) == (year, month, day)).then_some(days * 86_400)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -179,4 +236,12 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,66 @@ impl GotchaCore {
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn issue_editor(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: Option<i64>,
|
||||
) -> Result<IssueEditorPage, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
if number.is_some_and(|number| number <= 0) {
|
||||
return Err("Invalid issue number.".into());
|
||||
}
|
||||
Ok(issue_editor_page(
|
||||
load_issue_editor(&self.server()?, owner, repository, number).await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn save_issue(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: Option<i64>,
|
||||
title: String,
|
||||
body: String,
|
||||
label_ids: Vec<i64>,
|
||||
milestone_id: Option<i64>,
|
||||
due_date: Option<i64>,
|
||||
) -> Result<i64, GotchaError> {
|
||||
let (owner, repository) = validate_repository(&owner, &repository)?;
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return Err("Enter an issue title.".into());
|
||||
}
|
||||
if number.is_some_and(|number| number <= 0)
|
||||
|| milestone_id.is_some_and(|id| id <= 0)
|
||||
|| label_ids.iter().any(|id| *id <= 0)
|
||||
{
|
||||
return Err("Invalid issue editor selection.".into());
|
||||
}
|
||||
let label_ids: Vec<_> = label_ids
|
||||
.into_iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let draft = IssueDraft {
|
||||
title: title.into(),
|
||||
body,
|
||||
label_ids,
|
||||
milestone_id,
|
||||
due_date,
|
||||
};
|
||||
let server = self.server()?;
|
||||
let issue = match number {
|
||||
Some(number) => edit_issue(&server, owner, repository, number, draft).await?,
|
||||
None => create_issue(&server, owner, repository, draft).await?,
|
||||
};
|
||||
issue
|
||||
.number
|
||||
.ok_or_else(|| "The saved issue has no number.".into())
|
||||
}
|
||||
|
||||
pub async fn milestones(
|
||||
&self,
|
||||
owner: String,
|
||||
@@ -502,6 +562,18 @@ impl GotchaCore {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_repository<'a>(
|
||||
owner: &'a str,
|
||||
repository: &'a str,
|
||||
) -> Result<(&'a str, &'a str), GotchaError> {
|
||||
let owner = owner.trim();
|
||||
let repository = repository.trim();
|
||||
if owner.is_empty() || repository.is_empty() {
|
||||
return Err("Select a repository first.".into());
|
||||
}
|
||||
Ok((owner, repository))
|
||||
}
|
||||
|
||||
impl GotchaCore {
|
||||
fn server(&self) -> Result<Server, GotchaError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
|
||||
@@ -3,8 +3,8 @@ use gotcha_gitea::models;
|
||||
use crate::{
|
||||
activity,
|
||||
domain::{
|
||||
HistoryCommit, HomeData, IssueDetails, IssueFilter, MilestoneDetails, PullDetails,
|
||||
PullFilter, RepositoryData,
|
||||
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
|
||||
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -67,6 +67,29 @@ pub struct IssuePage {
|
||||
pub comments: Vec<CommentRow>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct IssueEditorLabel {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct IssueEditorMilestone {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct IssueEditorPage {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub due_date: Option<i64>,
|
||||
pub labels: Vec<IssueEditorLabel>,
|
||||
pub milestones: Vec<IssueEditorMilestone>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct IssueFilterOptions {
|
||||
pub milestones: Vec<String>,
|
||||
@@ -457,6 +480,70 @@ pub fn issue_page(details: IssueDetails) -> IssuePage {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn issue_editor_page(data: IssueEditorData) -> IssueEditorPage {
|
||||
let selected_labels: std::collections::BTreeSet<_> = data
|
||||
.issue
|
||||
.as_ref()
|
||||
.and_then(|issue| issue.labels.as_deref())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|label| label.id)
|
||||
.collect();
|
||||
let selected_milestone = data
|
||||
.issue
|
||||
.as_ref()
|
||||
.and_then(|issue| issue.milestone.as_ref())
|
||||
.and_then(|milestone| milestone.id);
|
||||
let mut labels: Vec<_> = data
|
||||
.labels
|
||||
.into_iter()
|
||||
.filter_map(|label| {
|
||||
let id = label.id?;
|
||||
Some(IssueEditorLabel {
|
||||
id,
|
||||
name: label.name?,
|
||||
selected: selected_labels.contains(&id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
labels.sort_by_key(|label| label.name.to_lowercase());
|
||||
let mut milestones: Vec<_> = data
|
||||
.milestones
|
||||
.into_iter()
|
||||
.filter(|milestone| {
|
||||
milestone.state.as_deref() != Some("closed") || milestone.id == selected_milestone
|
||||
})
|
||||
.filter_map(|milestone| {
|
||||
let id = milestone.id?;
|
||||
Some(IssueEditorMilestone {
|
||||
id,
|
||||
title: milestone.title?,
|
||||
selected: Some(id) == selected_milestone,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
milestones.sort_by_key(|milestone| milestone.title.to_lowercase());
|
||||
IssueEditorPage {
|
||||
title: data
|
||||
.issue
|
||||
.as_ref()
|
||||
.and_then(|issue| issue.title.clone())
|
||||
.unwrap_or_default(),
|
||||
body: data
|
||||
.issue
|
||||
.as_ref()
|
||||
.and_then(|issue| issue.body.clone())
|
||||
.unwrap_or_default(),
|
||||
due_date: data
|
||||
.issue
|
||||
.as_ref()
|
||||
.and_then(|issue| issue.due_date.as_deref())
|
||||
.and_then(parse_api_date),
|
||||
labels,
|
||||
milestones,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pull_page(details: PullDetails) -> PullPage {
|
||||
let pull = &details.pull;
|
||||
let author = pull
|
||||
@@ -840,31 +927,6 @@ fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
|
||||
(cells, contribution_count)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fn label_row(label: &models::Label) -> Option<LabelRow> {
|
||||
let name = label.name.as_deref()?.trim();
|
||||
let color = label.color.as_deref()?.trim().trim_start_matches('#');
|
||||
@@ -1172,6 +1234,42 @@ mod tests {
|
||||
assert_eq!(page.state, "closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_editor_exposes_selected_metadata_and_due_date() {
|
||||
let selected_label = models::Label {
|
||||
id: Some(2),
|
||||
name: Some("bug".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let page = issue_editor_page(IssueEditorData {
|
||||
issue: Some(models::Issue {
|
||||
title: Some("Title".into()),
|
||||
body: Some("Body".into()),
|
||||
due_date: Some("2024-02-29T18:00:00Z".into()),
|
||||
labels: Some(vec![selected_label.clone()]),
|
||||
milestone: Some(Box::new(models::Milestone {
|
||||
id: Some(4),
|
||||
title: Some("Current".into()),
|
||||
state: Some("closed".into()),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
}),
|
||||
labels: vec![selected_label],
|
||||
milestones: vec![models::Milestone {
|
||||
id: Some(4),
|
||||
title: Some("Current".into()),
|
||||
state: Some("closed".into()),
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
|
||||
assert_eq!((page.title.as_str(), page.body.as_str()), ("Title", "Body"));
|
||||
assert_eq!(page.due_date, Some(1_709_164_800));
|
||||
assert!(page.labels[0].selected);
|
||||
assert!(page.milestones[0].selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sorted_issue_filter_options() {
|
||||
let filter = IssueFilter {
|
||||
|
||||
Reference in New Issue
Block a user