Centralize Gitea domain workflows

This commit is contained in:
Georg Bauer
2026-07-31 16:44:16 +02:00
parent b468a2670c
commit f38c3c4939
19 changed files with 2248 additions and 1624 deletions

View File

@@ -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

View File

@@ -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"
);
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -1,11 +1,10 @@
use gotcha_gitea::models;
use gotcha_gitea::{
PullFileSource, activity, comment_can_edit, diff, models, pull_file_source, pull_state,
};
use crate::{
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(),
}
}