Centralize Gitea domain workflows
This commit is contained in:
@@ -1,171 +0,0 @@
|
||||
use gotcha_gitea::models;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum Target {
|
||||
Repository {
|
||||
owner: String,
|
||||
repository: String,
|
||||
},
|
||||
Commit {
|
||||
owner: String,
|
||||
repository: String,
|
||||
sha: String,
|
||||
},
|
||||
Issue {
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
},
|
||||
Pull {
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn target(activity: &models::Activity) -> Option<Target> {
|
||||
use models::activity::OpType;
|
||||
|
||||
let (owner, repository) = activity
|
||||
.repo
|
||||
.as_ref()?
|
||||
.full_name
|
||||
.as_deref()?
|
||||
.split_once('/')?;
|
||||
let pair = || (owner.to_string(), repository.to_string());
|
||||
match activity.op_type? {
|
||||
OpType::CreateRepo => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Repository { owner, repository })
|
||||
}
|
||||
OpType::CommitRepo | OpType::MirrorSyncPush => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Commit {
|
||||
owner,
|
||||
repository,
|
||||
sha: commit_sha(activity.content.as_deref()?)?,
|
||||
})
|
||||
}
|
||||
OpType::CreateIssue | OpType::CloseIssue | OpType::ReopenIssue | OpType::CommentIssue => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Issue {
|
||||
owner,
|
||||
repository,
|
||||
number: issue_number(activity)?,
|
||||
})
|
||||
}
|
||||
OpType::CreatePullRequest
|
||||
| OpType::MergePullRequest
|
||||
| OpType::AutoMergePullRequest
|
||||
| OpType::ClosePullRequest
|
||||
| OpType::ReopenPullRequest
|
||||
| OpType::CommentPull
|
||||
| OpType::ApprovePullRequest
|
||||
| OpType::RejectPullRequest
|
||||
| OpType::PullReviewDismissed
|
||||
| OpType::PullRequestReadyForReview => {
|
||||
let (owner, repository) = pair();
|
||||
Some(Target::Pull {
|
||||
owner,
|
||||
repository,
|
||||
number: issue_number(activity)?,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn issue_number(activity: &models::Activity) -> Option<i64> {
|
||||
activity
|
||||
.comment
|
||||
.as_ref()
|
||||
.and_then(|comment| {
|
||||
comment
|
||||
.pull_request_url
|
||||
.as_deref()
|
||||
.or(comment.issue_url.as_deref())
|
||||
})
|
||||
.and_then(url_number)
|
||||
.or_else(|| {
|
||||
activity
|
||||
.content
|
||||
.as_deref()?
|
||||
.split(|character: char| !character.is_ascii_digit())
|
||||
.find(|part| !part.is_empty())?
|
||||
.parse()
|
||||
.ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn url_number(url: &str) -> Option<i64> {
|
||||
url.trim_end_matches('/').rsplit('/').next()?.parse().ok()
|
||||
}
|
||||
|
||||
fn commit_sha(content: &str) -> Option<String> {
|
||||
let payload: serde_json::Value = serde_json::from_str(content).ok()?;
|
||||
payload.get("HeadCommit").and_then(find_sha).or_else(|| {
|
||||
payload
|
||||
.get("Commits")?
|
||||
.as_array()?
|
||||
.last()
|
||||
.and_then(find_sha)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_sha(value: &serde_json::Value) -> Option<String> {
|
||||
value.as_object()?.iter().find_map(|(key, value)| {
|
||||
(key.to_ascii_lowercase().contains("sha"))
|
||||
.then(|| value.as_str())
|
||||
.flatten()
|
||||
.filter(|sha| sha.len() >= 7)
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use models::activity::OpType;
|
||||
|
||||
fn activity(op_type: OpType, content: &str) -> models::Activity {
|
||||
models::Activity {
|
||||
op_type: Some(op_type),
|
||||
content: Some(content.into()),
|
||||
repo: Some(Box::new(models::Repository {
|
||||
full_name: Some("octo/demo".into()),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_linkable_activity_targets() {
|
||||
assert_eq!(
|
||||
target(&activity(OpType::CreateRepo, "")),
|
||||
Some(Target::Repository {
|
||||
owner: "octo".into(),
|
||||
repository: "demo".into(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
target(&activity(OpType::CreatePullRequest, "42|feature")),
|
||||
Some(Target::Pull {
|
||||
owner: "octo".into(),
|
||||
repository: "demo".into(),
|
||||
number: 42,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
target(&activity(
|
||||
OpType::CommitRepo,
|
||||
r#"{"HeadCommit":{"Sha1":"0123456789abcdef"}}"#,
|
||||
)),
|
||||
Some(Target::Commit {
|
||||
owner: "octo".into(),
|
||||
repository: "demo".into(),
|
||||
sha: "0123456789abcdef".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,190 +0,0 @@
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct Line {
|
||||
pub old_number: String,
|
||||
pub new_number: String,
|
||||
pub text: String,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
pub struct Parsed {
|
||||
pub lines: Vec<Line>,
|
||||
pub columns: usize,
|
||||
}
|
||||
|
||||
pub fn parse_file(diff: &str, path: &str) -> Parsed {
|
||||
let section = sections(diff)
|
||||
.into_iter()
|
||||
.find(|section| section_path(section).as_deref() == Some(path))
|
||||
.unwrap_or("");
|
||||
if section.is_empty() {
|
||||
let text = format!("No diff data is available for {path}.");
|
||||
return Parsed {
|
||||
columns: text.chars().count(),
|
||||
lines: vec![Line {
|
||||
old_number: String::new(),
|
||||
new_number: String::new(),
|
||||
text,
|
||||
kind: "header",
|
||||
}],
|
||||
};
|
||||
}
|
||||
let mut old = 0;
|
||||
let mut new = 0;
|
||||
let mut columns = 0;
|
||||
let lines = section
|
||||
.lines()
|
||||
.map(|text| {
|
||||
columns = columns.max(display_columns(text));
|
||||
let (old_number, new_number, kind) = if text.starts_with("@@") {
|
||||
if let Some((old_start, new_start)) = hunk_starts(text) {
|
||||
old = old_start;
|
||||
new = new_start;
|
||||
}
|
||||
(String::new(), String::new(), "hunk")
|
||||
} else if text.starts_with('+') && !text.starts_with("+++") {
|
||||
let number = new.to_string();
|
||||
new += 1;
|
||||
(String::new(), number, "addition")
|
||||
} else if text.starts_with('-') && !text.starts_with("---") {
|
||||
let number = old.to_string();
|
||||
old += 1;
|
||||
(number, String::new(), "removal")
|
||||
} else if text.starts_with(' ') {
|
||||
let numbers = (old.to_string(), new.to_string());
|
||||
old += 1;
|
||||
new += 1;
|
||||
(numbers.0, numbers.1, "context")
|
||||
} else {
|
||||
(String::new(), String::new(), "header")
|
||||
};
|
||||
Line {
|
||||
old_number,
|
||||
new_number,
|
||||
text: text.into(),
|
||||
kind,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Parsed { lines, columns }
|
||||
}
|
||||
|
||||
fn sections(diff: &str) -> Vec<&str> {
|
||||
let mut starts: Vec<_> = diff
|
||||
.match_indices("diff --git ")
|
||||
.map(|(index, _)| index)
|
||||
.collect();
|
||||
starts.push(diff.len());
|
||||
starts
|
||||
.windows(2)
|
||||
.map(move |bounds| &diff[bounds[0]..bounds[1]])
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn section_path(section: &str) -> Option<String> {
|
||||
section
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("+++ "))
|
||||
.filter(|path| *path != "/dev/null")
|
||||
.or_else(|| {
|
||||
section
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("--- "))
|
||||
.filter(|path| *path != "/dev/null")
|
||||
})
|
||||
.map(|path| {
|
||||
decode_path(path)
|
||||
.trim_start_matches("a/")
|
||||
.trim_start_matches("b/")
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_path(path: &str) -> String {
|
||||
let path = path.trim_matches('"');
|
||||
let mut bytes = Vec::with_capacity(path.len());
|
||||
let mut chars = path.bytes();
|
||||
while let Some(byte) = chars.next() {
|
||||
if byte != b'\\' {
|
||||
bytes.push(byte);
|
||||
continue;
|
||||
}
|
||||
let Some(escaped) = chars.next() else { break };
|
||||
match escaped {
|
||||
b'n' => bytes.push(b'\n'),
|
||||
b'r' => bytes.push(b'\r'),
|
||||
b't' => bytes.push(b'\t'),
|
||||
b'\\' | b'"' => bytes.push(escaped),
|
||||
b'0'..=b'7' => {
|
||||
let mut value = escaped - b'0';
|
||||
for _ in 0..2 {
|
||||
let Some(next) = chars.next() else { break };
|
||||
if !(b'0'..=b'7').contains(&next) {
|
||||
bytes.push(value);
|
||||
bytes.push(next);
|
||||
value = 0;
|
||||
break;
|
||||
}
|
||||
value = value * 8 + next - b'0';
|
||||
}
|
||||
if value != 0 {
|
||||
bytes.push(value);
|
||||
}
|
||||
}
|
||||
other => bytes.push(other),
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
fn hunk_starts(line: &str) -> Option<(i32, i32)> {
|
||||
let mut parts = line.split_whitespace();
|
||||
(parts.next()? == "@@").then_some(())?;
|
||||
Some((
|
||||
range_start(parts.next()?, '-'),
|
||||
range_start(parts.next()?, '+'),
|
||||
))
|
||||
}
|
||||
|
||||
fn range_start(value: &str, prefix: char) -> i32 {
|
||||
value
|
||||
.strip_prefix(prefix)
|
||||
.and_then(|value| value.split(',').next())
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn display_columns(line: &str) -> usize {
|
||||
line.chars().fold(0, |column, character| {
|
||||
if character == '\t' {
|
||||
(column / 8 + 1) * 8
|
||||
} else {
|
||||
column + 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selects_and_numbers_a_file_diff() {
|
||||
let diff = "diff --git a/one b/one\n--- a/one\n+++ b/one\n@@ -1 +1 @@\n-old\n+new\n\
|
||||
diff --git a/two b/two\n--- a/two\n+++ b/two\n@@ -4,2 +8,3 @@\n context\n-removed\n+added\n";
|
||||
let parsed = parse_file(diff, "two");
|
||||
assert_eq!(parsed.lines[4].old_number, "4");
|
||||
assert_eq!(parsed.lines[4].new_number, "8");
|
||||
assert_eq!(parsed.lines[5].kind, "removal");
|
||||
assert_eq!(parsed.lines[6].kind, "addition");
|
||||
assert!(parsed.lines.iter().all(|line| !line.text.contains("old")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_git_quoted_paths() {
|
||||
let diff = "diff --git \"a/caf\\303\\251\" \"b/caf\\303\\251\"\n--- \"a/caf\\303\\251\"\n+++ \"b/caf\\303\\251\"\n@@ -0,0 +1 @@\n+hello\n";
|
||||
assert_eq!(
|
||||
parse_file(diff, "café").lines.last().unwrap().kind,
|
||||
"addition"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,11 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use gotcha_gitea::models;
|
||||
pub use gotcha_gitea::{
|
||||
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
|
||||
parse_api_date,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PAGE_SIZE: i32 = 30;
|
||||
|
||||
pub struct Page<T> {
|
||||
pub items: Vec<T>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
impl<T> Page<T> {
|
||||
pub fn from_items(items: Vec<T>) -> Self {
|
||||
Self {
|
||||
has_more: items.len() == PAGE_SIZE as usize,
|
||||
items,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppearanceMode {
|
||||
@@ -128,16 +115,6 @@ pub struct RepositoryData {
|
||||
pub default_branch: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HistoryCommit {
|
||||
pub commit: models::Commit,
|
||||
pub top_lanes: Vec<usize>,
|
||||
pub bottom_lanes: Vec<usize>,
|
||||
pub node_lane: Option<usize>,
|
||||
pub connections: Vec<usize>,
|
||||
pub refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub preferences: Preferences,
|
||||
@@ -145,25 +122,6 @@ pub struct State {
|
||||
pub repositories: Vec<RepositoryData>,
|
||||
}
|
||||
|
||||
pub struct HomeData {
|
||||
pub activities: Vec<models::Activity>,
|
||||
pub heatmap: Vec<models::UserHeatmapData>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
pub struct IssueDetails {
|
||||
pub issue: models::Issue,
|
||||
pub comments: Vec<models::Comment>,
|
||||
pub viewer_id: Option<i64>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
pub struct IssueEditorData {
|
||||
pub issue: Option<models::Issue>,
|
||||
pub labels: Vec<models::Label>,
|
||||
pub milestones: Vec<models::Milestone>,
|
||||
}
|
||||
|
||||
pub struct IssueDraft {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
@@ -172,26 +130,12 @@ pub struct IssueDraft {
|
||||
pub due_date: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct MilestoneDetails {
|
||||
pub milestone: models::Milestone,
|
||||
pub issues: Vec<models::Issue>,
|
||||
pub pulls: Vec<models::Issue>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
pub struct MilestoneDraft {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub due_date: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct PullDetails {
|
||||
pub pull: models::PullRequest,
|
||||
pub comments: Vec<models::Comment>,
|
||||
pub files: Vec<models::ChangedFile>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
pub fn open_status() -> String {
|
||||
"open".into()
|
||||
}
|
||||
@@ -221,24 +165,6 @@ pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 {
|
||||
era * 146_097 + day_of_era - 719_468
|
||||
}
|
||||
|
||||
pub fn api_date(timestamp: i64) -> String {
|
||||
let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400));
|
||||
format!("{year:04}-{month:02}-{day:02}T00:00:00Z")
|
||||
}
|
||||
|
||||
pub fn parse_api_date(value: &str) -> Option<i64> {
|
||||
let date = value.get(..10)?;
|
||||
let mut parts = date.split('-');
|
||||
let year = parts.next()?.parse().ok()?;
|
||||
let month = parts.next()?.parse().ok()?;
|
||||
let day = parts.next()?.parse().ok()?;
|
||||
if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) {
|
||||
return None;
|
||||
}
|
||||
let days = days_from_civil(year, month, day);
|
||||
(civil_from_days(days) == (year, month, day)).then_some(days * 86_400)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -263,18 +189,4 @@ mod tests {
|
||||
.is_active("open")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_dates_round_trip_and_reject_invalid_dates() {
|
||||
let leap_day = parse_api_date("2024-02-29T12:34:56Z").unwrap();
|
||||
assert_eq!(api_date(leap_day), "2024-02-29T00:00:00Z");
|
||||
assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None);
|
||||
assert_eq!(parse_api_date("not-a-date"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_api_pages_expose_more_results() {
|
||||
assert!(Page::from_items(vec![(); PAGE_SIZE as usize]).has_more);
|
||||
assert!(!Page::from_items(vec![(); PAGE_SIZE as usize - 1]).has_more);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use gotcha_gitea::models;
|
||||
use gotcha_gitea::{
|
||||
PullFileSource, activity, comment_can_edit, diff, models, pull_file_source, pull_state,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
activity,
|
||||
domain::{
|
||||
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
|
||||
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
|
||||
},
|
||||
use crate::domain::{
|
||||
HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails,
|
||||
PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date,
|
||||
};
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
@@ -630,13 +629,7 @@ pub fn pull_page(details: PullDetails) -> PullPage {
|
||||
.as_ref()
|
||||
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
|
||||
.unwrap_or("base");
|
||||
let state = if pull.merged.unwrap_or(false) {
|
||||
"merged"
|
||||
} else if pull.draft.unwrap_or(false) {
|
||||
"draft"
|
||||
} else {
|
||||
pull.state.as_deref().unwrap_or("unknown")
|
||||
};
|
||||
let state = pull_state(pull);
|
||||
PullPage {
|
||||
title: pull
|
||||
.title
|
||||
@@ -760,7 +753,7 @@ pub fn repository_file_page(path: &str, data: Vec<u8>) -> RepositoryFilePage {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diff_page(path: &str, diff: crate::diff::Parsed) -> DiffPage {
|
||||
pub fn diff_page(path: &str, diff: diff::Parsed) -> DiffPage {
|
||||
DiffPage {
|
||||
title: path.rsplit('/').next().unwrap_or(path).into(),
|
||||
columns: diff.columns.min(u32::MAX as usize) as u32,
|
||||
@@ -1040,14 +1033,7 @@ fn comment_row(comment: &models::Comment, viewer_id: Option<i64>) -> CommentRow
|
||||
.as_deref()
|
||||
.or(comment.created_at.as_deref()),
|
||||
),
|
||||
can_edit: matches!(
|
||||
(
|
||||
comment.id,
|
||||
comment.user.as_ref().and_then(|user| user.id),
|
||||
viewer_id,
|
||||
),
|
||||
(Some(_), Some(author), Some(viewer)) if author == viewer
|
||||
),
|
||||
can_edit: comment_can_edit(comment, viewer_id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1059,17 +1045,10 @@ fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
|
||||
}
|
||||
|
||||
fn pull_files_ref(pull: &models::PullRequest) -> String {
|
||||
if pull.state.as_deref() == Some("open") {
|
||||
let branch = pull
|
||||
.head
|
||||
.as_ref()
|
||||
.and_then(|head| head.r#ref.as_deref().or(head.label.as_deref()))
|
||||
.unwrap_or("head branch");
|
||||
format!("Files on {branch}")
|
||||
} else if let Some(sha) = pull.merge_commit_sha.as_deref() {
|
||||
format!("Files at merge commit {}", &sha[..sha.len().min(8)])
|
||||
} else {
|
||||
"Files from pull request".into()
|
||||
match pull_file_source(pull) {
|
||||
PullFileSource::Branch(branch) => format!("Files on {branch}"),
|
||||
PullFileSource::Commit(sha) => format!("Files at merge commit {sha}"),
|
||||
PullFileSource::Request => "Files from pull request".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user