864 lines
26 KiB
Rust
864 lines
26 KiB
Rust
use gotcha_gitea::models;
|
||
|
||
use crate::{
|
||
activity,
|
||
domain::{
|
||
HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData,
|
||
},
|
||
};
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct ServerRow {
|
||
pub name: String,
|
||
pub url: String,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct RepositoryRow {
|
||
pub name: String,
|
||
pub owner: String,
|
||
pub description: String,
|
||
pub meta: String,
|
||
pub favorite: bool,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct LabelRow {
|
||
pub name: String,
|
||
pub color: String,
|
||
pub light: bool,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct IssueRow {
|
||
pub number: i64,
|
||
pub title: String,
|
||
pub summary: String,
|
||
pub meta: String,
|
||
pub milestone: String,
|
||
pub labels: Vec<LabelRow>,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct PullRow {
|
||
pub number: i64,
|
||
pub owner: String,
|
||
pub repository: String,
|
||
pub title: String,
|
||
pub summary: String,
|
||
pub meta: String,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct CommentRow {
|
||
pub author: String,
|
||
pub body: String,
|
||
pub meta: String,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct IssuePage {
|
||
pub title: String,
|
||
pub meta: String,
|
||
pub milestone: String,
|
||
pub body: String,
|
||
pub comments: Vec<CommentRow>,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct MilestoneRow {
|
||
pub id: i64,
|
||
pub title: String,
|
||
pub description: String,
|
||
pub meta: String,
|
||
pub open_issues: i64,
|
||
pub closed_issues: i64,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct MilestonePage {
|
||
pub milestone: MilestoneRow,
|
||
pub issues: Vec<IssueRow>,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct PullPage {
|
||
pub title: String,
|
||
pub meta: String,
|
||
pub body: String,
|
||
pub files_ref: String,
|
||
pub files: Vec<FileRow>,
|
||
pub comments: Vec<CommentRow>,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct CommitRow {
|
||
pub sha: String,
|
||
pub title: String,
|
||
pub meta: String,
|
||
pub refs: String,
|
||
pub top_lanes: Vec<u32>,
|
||
pub bottom_lanes: Vec<u32>,
|
||
pub node_lane: Option<u32>,
|
||
pub connections: Vec<u32>,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct CommitPage {
|
||
pub branches: Vec<String>,
|
||
pub commits: Vec<CommitRow>,
|
||
pub lane_count: u32,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct FileRow {
|
||
pub path: String,
|
||
pub status: String,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct RepositoryContentRow {
|
||
pub name: String,
|
||
pub path: String,
|
||
pub kind: String,
|
||
pub size: i64,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct DiffLine {
|
||
pub old_number: String,
|
||
pub new_number: String,
|
||
pub text: String,
|
||
pub kind: String,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct DiffPage {
|
||
pub title: String,
|
||
pub columns: u32,
|
||
pub lines: Vec<DiffLine>,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct ActivityRow {
|
||
pub icon: String,
|
||
pub title: String,
|
||
pub detail: String,
|
||
pub meta: String,
|
||
pub target: String,
|
||
pub owner: String,
|
||
pub repository: String,
|
||
pub number: i64,
|
||
pub sha: String,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct HeatCell {
|
||
pub week: u32,
|
||
pub day: u32,
|
||
pub level: u32,
|
||
pub timestamp: i64,
|
||
pub contributions: i64,
|
||
}
|
||
|
||
#[derive(Clone, uniffi::Record)]
|
||
pub struct HomePage {
|
||
pub server_name: String,
|
||
pub activities: Vec<ActivityRow>,
|
||
pub heat_cells: Vec<HeatCell>,
|
||
pub contribution_count: i64,
|
||
}
|
||
|
||
pub fn repository_rows(
|
||
repositories: &[RepositoryData],
|
||
is_favorite: impl Fn(&RepositoryData) -> bool,
|
||
) -> Vec<RepositoryRow> {
|
||
let mut repositories = repositories.to_vec();
|
||
repositories
|
||
.sort_by_key(|repository| (!is_favorite(repository), repository.name.to_lowercase()));
|
||
repositories
|
||
.into_iter()
|
||
.map(|repository| RepositoryRow {
|
||
favorite: is_favorite(&repository),
|
||
meta: format!(
|
||
"{} · {} open · {}",
|
||
repository.language, repository.open_issues, repository.updated
|
||
),
|
||
name: repository.name,
|
||
owner: repository.owner,
|
||
description: repository.description,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub fn issue_rows(issues: &[models::Issue]) -> Vec<IssueRow> {
|
||
issues
|
||
.iter()
|
||
.map(|issue| IssueRow {
|
||
number: issue.number.unwrap_or_default(),
|
||
title: issue
|
||
.title
|
||
.clone()
|
||
.unwrap_or_else(|| "Untitled issue".into()),
|
||
summary: issue
|
||
.body
|
||
.as_deref()
|
||
.map(summary)
|
||
.unwrap_or_else(|| "No description".into()),
|
||
meta: issue_meta(issue),
|
||
milestone: issue_milestone(issue),
|
||
labels: issue
|
||
.labels
|
||
.as_deref()
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.filter_map(label_row)
|
||
.collect(),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub fn milestone_rows(milestones: &[models::Milestone]) -> Vec<MilestoneRow> {
|
||
let mut milestones = milestones.to_vec();
|
||
milestones.sort_by_key(|milestone| {
|
||
(
|
||
milestone.state.as_deref() == Some("closed"),
|
||
milestone
|
||
.title
|
||
.as_deref()
|
||
.unwrap_or_default()
|
||
.to_lowercase(),
|
||
)
|
||
});
|
||
milestones.iter().map(milestone_row).collect()
|
||
}
|
||
|
||
pub fn milestone_page(details: MilestoneDetails) -> MilestonePage {
|
||
MilestonePage {
|
||
milestone: milestone_row(&details.milestone),
|
||
issues: issue_rows(&details.issues),
|
||
}
|
||
}
|
||
|
||
pub fn pull_rows(pulls: &[models::Issue]) -> Vec<PullRow> {
|
||
pulls
|
||
.iter()
|
||
.filter_map(|pull| {
|
||
let repository = pull.repository.as_ref()?;
|
||
let author = pull
|
||
.user
|
||
.as_ref()
|
||
.and_then(|user| user.login.as_deref())
|
||
.unwrap_or("unknown");
|
||
let kind = if pull
|
||
.pull_request
|
||
.as_ref()
|
||
.and_then(|pull| pull.draft)
|
||
.unwrap_or(false)
|
||
{
|
||
"Draft"
|
||
} else {
|
||
pull.state.as_deref().unwrap_or("unknown")
|
||
};
|
||
Some(PullRow {
|
||
number: pull.number.unwrap_or_default(),
|
||
owner: repository.owner.clone()?,
|
||
repository: repository.name.clone()?,
|
||
title: pull
|
||
.title
|
||
.clone()
|
||
.unwrap_or_else(|| "Untitled pull request".into()),
|
||
summary: pull
|
||
.body
|
||
.as_deref()
|
||
.map(summary)
|
||
.filter(|body| !body.is_empty())
|
||
.unwrap_or_else(|| "No description".into()),
|
||
meta: format!(
|
||
"{kind} · {author} · {} · {} comments",
|
||
compact_date(pull.updated_at.as_deref()),
|
||
pull.comments.unwrap_or_default()
|
||
),
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub fn commit_page(
|
||
branches: Vec<String>,
|
||
commits: &[HistoryCommit],
|
||
show_graph: bool,
|
||
) -> CommitPage {
|
||
let lane_count = if show_graph {
|
||
commits
|
||
.iter()
|
||
.flat_map(|commit| {
|
||
commit
|
||
.top_lanes
|
||
.iter()
|
||
.chain(commit.bottom_lanes.iter())
|
||
.chain(commit.connections.iter())
|
||
.chain(commit.node_lane.iter())
|
||
})
|
||
.max()
|
||
.map_or(0, |lane| lane + 1)
|
||
} else {
|
||
0
|
||
};
|
||
let commits = commits
|
||
.iter()
|
||
.filter_map(|row| {
|
||
let commit = &row.commit;
|
||
let sha = commit.sha.as_deref()?;
|
||
let details = commit.commit.as_ref();
|
||
let author = details
|
||
.and_then(|commit| commit.author.as_ref())
|
||
.and_then(|author| author.name.as_deref())
|
||
.or_else(|| {
|
||
commit
|
||
.author
|
||
.as_ref()
|
||
.and_then(|author| author.login.as_deref())
|
||
})
|
||
.unwrap_or("unknown");
|
||
let date = details
|
||
.and_then(|commit| commit.author.as_ref())
|
||
.and_then(|author| author.date.as_deref())
|
||
.or(commit.created.as_deref());
|
||
Some(CommitRow {
|
||
sha: sha.into(),
|
||
title: details
|
||
.and_then(|commit| commit.message.as_deref())
|
||
.map(|message| message.lines().next().unwrap_or(message))
|
||
.unwrap_or("Commit")
|
||
.into(),
|
||
meta: format!("{author} · {}", compact_date(date)),
|
||
refs: row.refs.join(" · "),
|
||
top_lanes: indices(&row.top_lanes),
|
||
bottom_lanes: indices(&row.bottom_lanes),
|
||
node_lane: row.node_lane.map(|lane| lane as u32),
|
||
connections: indices(&row.connections),
|
||
})
|
||
})
|
||
.collect();
|
||
CommitPage {
|
||
branches,
|
||
commits,
|
||
lane_count: lane_count as u32,
|
||
}
|
||
}
|
||
|
||
pub fn issue_page(details: IssueDetails) -> IssuePage {
|
||
IssuePage {
|
||
title: details
|
||
.issue
|
||
.title
|
||
.clone()
|
||
.unwrap_or_else(|| "Untitled issue".into()),
|
||
meta: issue_meta(&details.issue),
|
||
milestone: issue_milestone(&details.issue),
|
||
body: details
|
||
.issue
|
||
.body
|
||
.filter(|body| !body.is_empty())
|
||
.unwrap_or_else(|| "No description provided.".into()),
|
||
comments: details.comments.iter().map(comment_row).collect(),
|
||
}
|
||
}
|
||
|
||
pub fn pull_page(details: PullDetails) -> PullPage {
|
||
let pull = &details.pull;
|
||
let author = pull
|
||
.user
|
||
.as_ref()
|
||
.and_then(|user| user.login.as_deref())
|
||
.unwrap_or("unknown");
|
||
let head = pull
|
||
.head
|
||
.as_ref()
|
||
.and_then(|branch| branch.label.as_deref().or(branch.r#ref.as_deref()))
|
||
.unwrap_or("head");
|
||
let base = pull
|
||
.base
|
||
.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")
|
||
};
|
||
PullPage {
|
||
title: pull
|
||
.title
|
||
.clone()
|
||
.unwrap_or_else(|| "Untitled pull request".into()),
|
||
meta: format!(
|
||
"#{} · {state} · {author} · {head} → {base}\n{} files · +{} −{} · {} comments",
|
||
pull.number.unwrap_or_default(),
|
||
pull.changed_files.unwrap_or_default(),
|
||
pull.additions.unwrap_or_default(),
|
||
pull.deletions.unwrap_or_default(),
|
||
pull.comments.unwrap_or_default(),
|
||
),
|
||
body: pull
|
||
.body
|
||
.clone()
|
||
.filter(|body| !body.is_empty())
|
||
.unwrap_or_else(|| "No description provided.".into()),
|
||
files_ref: pull_files_ref(pull),
|
||
files: details.files.iter().filter_map(file_row).collect(),
|
||
comments: details.comments.iter().map(comment_row).collect(),
|
||
}
|
||
}
|
||
|
||
pub fn file_rows(files: Vec<models::ChangedFile>) -> Vec<FileRow> {
|
||
files.iter().filter_map(file_row).collect()
|
||
}
|
||
|
||
pub fn commit_file_rows(files: Vec<models::CommitAffectedFiles>) -> Vec<FileRow> {
|
||
files
|
||
.into_iter()
|
||
.filter_map(|file| {
|
||
Some(FileRow {
|
||
path: file.filename?,
|
||
status: file.status.unwrap_or_else(|| "modified".into()),
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub fn repository_content_rows(
|
||
contents: Vec<models::ContentsResponse>,
|
||
) -> Vec<RepositoryContentRow> {
|
||
let mut rows: Vec<_> = contents
|
||
.into_iter()
|
||
.filter_map(|content| {
|
||
Some(RepositoryContentRow {
|
||
name: content.name?,
|
||
path: content.path?,
|
||
kind: content.r#type.unwrap_or_else(|| "file".into()),
|
||
size: content.size.unwrap_or_default(),
|
||
})
|
||
})
|
||
.collect();
|
||
rows.sort_by_key(|row| (row.kind != "dir", row.name.to_lowercase()));
|
||
rows
|
||
}
|
||
|
||
pub fn diff_page(path: &str, diff: crate::diff::Parsed) -> DiffPage {
|
||
DiffPage {
|
||
title: path.rsplit('/').next().unwrap_or(path).into(),
|
||
columns: diff.columns.min(u32::MAX as usize) as u32,
|
||
lines: diff
|
||
.lines
|
||
.into_iter()
|
||
.map(|line| DiffLine {
|
||
old_number: line.old_number,
|
||
new_number: line.new_number,
|
||
text: line.text,
|
||
kind: line.kind.into(),
|
||
})
|
||
.collect(),
|
||
}
|
||
}
|
||
|
||
pub fn home_page(server_name: String, home: HomeData) -> HomePage {
|
||
let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
|
||
HomePage {
|
||
server_name,
|
||
activities: home.activities.iter().map(activity_row).collect(),
|
||
heat_cells,
|
||
contribution_count,
|
||
}
|
||
}
|
||
|
||
fn activity_row(activity: &models::Activity) -> ActivityRow {
|
||
use models::activity::OpType;
|
||
|
||
let repository = activity
|
||
.repo
|
||
.as_ref()
|
||
.and_then(|repo| repo.full_name.as_deref())
|
||
.unwrap_or("repository");
|
||
let branch = activity
|
||
.ref_name
|
||
.as_deref()
|
||
.and_then(|name| name.strip_prefix("refs/heads/").or(Some(name)))
|
||
.unwrap_or("default branch");
|
||
let (icon, title) = match activity.op_type {
|
||
Some(OpType::CommitRepo) if activity.content.as_deref().unwrap_or("").is_empty() => (
|
||
"branch-create",
|
||
format!("Created branch {branch} in {repository}"),
|
||
),
|
||
Some(OpType::CommitRepo | OpType::MirrorSyncPush) => {
|
||
("push", format!("Pushed to {branch} in {repository}"))
|
||
}
|
||
Some(OpType::CreateRepo) => ("repo-create", format!("Created {repository}")),
|
||
Some(OpType::RenameRepo) => ("repo-rename", format!("Renamed {repository}")),
|
||
Some(OpType::StarRepo) => ("repo-star", format!("Starred {repository}")),
|
||
Some(OpType::WatchRepo) => ("repo-watch", format!("Started watching {repository}")),
|
||
Some(OpType::CreateIssue) => ("issue-open", format!("Opened an issue in {repository}")),
|
||
Some(OpType::CloseIssue) => ("issue-close", format!("Closed an issue in {repository}")),
|
||
Some(OpType::ReopenIssue) => ("issue-reopen", format!("Reopened an issue in {repository}")),
|
||
Some(OpType::CommentIssue) => (
|
||
"issue-comment",
|
||
format!("Commented on an issue in {repository}"),
|
||
),
|
||
Some(OpType::CreatePullRequest) => (
|
||
"pull-open",
|
||
format!("Opened a pull request in {repository}"),
|
||
),
|
||
Some(OpType::MergePullRequest | OpType::AutoMergePullRequest) => (
|
||
"pull-merge",
|
||
format!("Merged a pull request in {repository}"),
|
||
),
|
||
Some(OpType::ClosePullRequest) => (
|
||
"pull-close",
|
||
format!("Closed a pull request in {repository}"),
|
||
),
|
||
Some(OpType::ReopenPullRequest) => (
|
||
"pull-reopen",
|
||
format!("Reopened a pull request in {repository}"),
|
||
),
|
||
Some(OpType::CommentPull) => (
|
||
"pull-comment",
|
||
format!("Commented on a pull request in {repository}"),
|
||
),
|
||
Some(OpType::ApprovePullRequest) => (
|
||
"pull-approve",
|
||
format!("Approved a pull request in {repository}"),
|
||
),
|
||
Some(OpType::RejectPullRequest) => {
|
||
("pull-reject", format!("Requested changes in {repository}"))
|
||
}
|
||
Some(OpType::PushTag) => ("tag-push", format!("Pushed tag {branch} in {repository}")),
|
||
Some(OpType::DeleteTag) => (
|
||
"tag-delete",
|
||
format!("Deleted tag {branch} in {repository}"),
|
||
),
|
||
Some(OpType::DeleteBranch) => (
|
||
"branch-delete",
|
||
format!("Deleted branch {branch} in {repository}"),
|
||
),
|
||
Some(OpType::PublishRelease) => ("release", format!("Published a release in {repository}")),
|
||
Some(_) | None => ("update", format!("Updated {repository}")),
|
||
};
|
||
let target = activity::target(activity);
|
||
let (target, owner, repository, number, sha) = match target {
|
||
Some(activity::Target::Repository { owner, repository }) => {
|
||
("repository", owner, repository, 0, String::new())
|
||
}
|
||
Some(activity::Target::Issue {
|
||
owner,
|
||
repository,
|
||
number,
|
||
}) => ("issue", owner, repository, number, String::new()),
|
||
Some(activity::Target::Pull {
|
||
owner,
|
||
repository,
|
||
number,
|
||
}) => ("pull", owner, repository, number, String::new()),
|
||
Some(activity::Target::Commit {
|
||
owner,
|
||
repository,
|
||
sha,
|
||
}) => ("commit", owner, repository, 0, sha),
|
||
None => ("", String::new(), String::new(), 0, String::new()),
|
||
};
|
||
ActivityRow {
|
||
icon: icon.into(),
|
||
title,
|
||
detail: activity_detail(activity),
|
||
meta: compact_date(activity.created.as_deref()),
|
||
target: target.into(),
|
||
owner,
|
||
repository,
|
||
number,
|
||
sha,
|
||
}
|
||
}
|
||
|
||
fn activity_detail(activity: &models::Activity) -> String {
|
||
let text = activity
|
||
.comment
|
||
.as_ref()
|
||
.and_then(|comment| comment.body.as_deref())
|
||
.or(activity.content.as_deref())
|
||
.unwrap_or("");
|
||
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(text) {
|
||
let commits = payload.get("Len").and_then(|value| value.as_i64());
|
||
let message = payload
|
||
.get("HeadCommit")
|
||
.and_then(|commit| commit.get("Message"))
|
||
.and_then(|message| message.as_str())
|
||
.map(summary)
|
||
.unwrap_or_default();
|
||
if !message.is_empty() {
|
||
return match commits {
|
||
Some(1) => format!("1 commit · {message}"),
|
||
Some(count) => format!("{count} commits · {message}"),
|
||
None => message,
|
||
};
|
||
}
|
||
}
|
||
let text = summary(text);
|
||
if text.is_empty() {
|
||
"Server activity".into()
|
||
} else {
|
||
text
|
||
}
|
||
}
|
||
|
||
fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec<HeatCell>, i64) {
|
||
const DAYS: i64 = 9 * 31;
|
||
let latest = data
|
||
.iter()
|
||
.filter_map(|entry| entry.timestamp)
|
||
.max()
|
||
.unwrap_or(0)
|
||
/ 86_400;
|
||
let start = latest - DAYS + 1;
|
||
let mut counts = vec![0_i64; DAYS as usize];
|
||
for entry in data {
|
||
let day = entry.timestamp.unwrap_or_default() / 86_400;
|
||
if (start..=latest).contains(&day) {
|
||
counts[(day - start) as usize] += entry.contributions.unwrap_or_default();
|
||
}
|
||
}
|
||
let maximum = counts.iter().copied().max().unwrap_or_default();
|
||
let contribution_count = counts.iter().sum();
|
||
let cells = counts
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(index, count)| HeatCell {
|
||
week: (index / 7) as u32,
|
||
day: (index % 7) as u32,
|
||
level: if count == 0 || maximum == 0 {
|
||
0
|
||
} else {
|
||
((count * 4 + maximum - 1) / maximum).clamp(1, 4) as u32
|
||
},
|
||
timestamp: (start + index as i64) * 86_400,
|
||
contributions: count,
|
||
})
|
||
.collect();
|
||
(cells, contribution_count)
|
||
}
|
||
|
||
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('#');
|
||
if name.is_empty() || color.len() != 6 {
|
||
return None;
|
||
}
|
||
let value = u32::from_str_radix(color, 16).ok()?;
|
||
let red = (value >> 16) & 0xff;
|
||
let green = (value >> 8) & 0xff;
|
||
let blue = value & 0xff;
|
||
Some(LabelRow {
|
||
name: name.into(),
|
||
color: format!("#{color}"),
|
||
light: red * 299 + green * 587 + blue * 114 > 150_000,
|
||
})
|
||
}
|
||
|
||
fn comment_row(comment: &models::Comment) -> CommentRow {
|
||
CommentRow {
|
||
author: comment
|
||
.user
|
||
.as_ref()
|
||
.and_then(|user| user.login.as_deref())
|
||
.or(comment.original_author.as_deref())
|
||
.unwrap_or("unknown")
|
||
.into(),
|
||
body: comment
|
||
.body
|
||
.clone()
|
||
.filter(|body| !body.is_empty())
|
||
.unwrap_or_else(|| "No comment text.".into()),
|
||
meta: compact_date(
|
||
comment
|
||
.updated_at
|
||
.as_deref()
|
||
.or(comment.created_at.as_deref()),
|
||
),
|
||
}
|
||
}
|
||
|
||
fn file_row(file: &models::ChangedFile) -> Option<FileRow> {
|
||
Some(FileRow {
|
||
path: file.filename.clone()?,
|
||
status: file.status.clone().unwrap_or_else(|| "modified".into()),
|
||
})
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
fn issue_meta(issue: &models::Issue) -> String {
|
||
let author = issue
|
||
.user
|
||
.as_ref()
|
||
.and_then(|user| user.login.as_deref())
|
||
.unwrap_or("unknown");
|
||
format!(
|
||
"#{} · {} · updated {} · {} comments",
|
||
issue.number.unwrap_or_default(),
|
||
author,
|
||
compact_date(issue.updated_at.as_deref()),
|
||
issue.comments.unwrap_or_default()
|
||
)
|
||
}
|
||
|
||
fn issue_milestone(issue: &models::Issue) -> String {
|
||
issue
|
||
.milestone
|
||
.as_ref()
|
||
.and_then(|milestone| milestone.title.as_deref())
|
||
.unwrap_or_default()
|
||
.into()
|
||
}
|
||
|
||
fn milestone_row(milestone: &models::Milestone) -> MilestoneRow {
|
||
let open = milestone.open_issues.unwrap_or_default();
|
||
let closed = milestone.closed_issues.unwrap_or_default();
|
||
let due = milestone
|
||
.due_on
|
||
.as_deref()
|
||
.map(|date| format!("due {}", compact_date(Some(date))))
|
||
.unwrap_or_else(|| "no due date".into());
|
||
MilestoneRow {
|
||
id: milestone.id.unwrap_or_default(),
|
||
title: milestone
|
||
.title
|
||
.clone()
|
||
.unwrap_or_else(|| "Untitled milestone".into()),
|
||
description: milestone
|
||
.description
|
||
.clone()
|
||
.filter(|description| !description.is_empty())
|
||
.unwrap_or_else(|| "No description".into()),
|
||
meta: format!(
|
||
"{} · {closed} of {} closed · {due}",
|
||
milestone.state.as_deref().unwrap_or("unknown"),
|
||
open + closed
|
||
),
|
||
open_issues: open,
|
||
closed_issues: closed,
|
||
}
|
||
}
|
||
|
||
fn summary(body: &str) -> String {
|
||
body.split_whitespace()
|
||
.take(24)
|
||
.collect::<Vec<_>>()
|
||
.join(" ")
|
||
}
|
||
|
||
fn indices(values: &[usize]) -> Vec<u32> {
|
||
values.iter().map(|value| *value as u32).collect()
|
||
}
|
||
|
||
pub fn compact_date(date: Option<&str>) -> String {
|
||
date.and_then(|date| date.get(..10))
|
||
.unwrap_or("unknown")
|
||
.to_string()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn repository_directories_sort_before_files() {
|
||
let contents = [
|
||
("z.swift", "file"),
|
||
("Sources", "dir"),
|
||
("README.md", "file"),
|
||
]
|
||
.into_iter()
|
||
.map(|(name, kind)| models::ContentsResponse {
|
||
name: Some(name.into()),
|
||
path: Some(name.into()),
|
||
r#type: Some(kind.into()),
|
||
..Default::default()
|
||
})
|
||
.collect();
|
||
|
||
assert_eq!(
|
||
repository_content_rows(contents)
|
||
.into_iter()
|
||
.map(|row| row.name)
|
||
.collect::<Vec<_>>(),
|
||
["Sources", "README.md", "z.swift"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn maps_heat_levels_and_labels() {
|
||
let heatmap = vec![
|
||
models::UserHeatmapData {
|
||
timestamp: Some(100 * 86_400),
|
||
contributions: Some(1),
|
||
},
|
||
models::UserHeatmapData {
|
||
timestamp: Some(101 * 86_400),
|
||
contributions: Some(4),
|
||
},
|
||
];
|
||
let (cells, total) = heat_cells(&heatmap);
|
||
assert_eq!((cells.len(), total), (279, 5));
|
||
assert_eq!(
|
||
(
|
||
cells[277].level,
|
||
cells[277].timestamp,
|
||
cells[277].contributions,
|
||
cells[278].level,
|
||
),
|
||
(1, 100 * 86_400, 1, 4)
|
||
);
|
||
|
||
let label = models::Label {
|
||
name: Some("bug".into()),
|
||
color: Some("d73a4a".into()),
|
||
..Default::default()
|
||
};
|
||
assert!(label_row(&label).is_some());
|
||
assert!(label_row(&models::Label::default()).is_none());
|
||
|
||
let milestone = models::Milestone {
|
||
id: Some(1),
|
||
title: Some("Version 1".into()),
|
||
open_issues: Some(3),
|
||
closed_issues: Some(2),
|
||
state: Some("open".into()),
|
||
..Default::default()
|
||
};
|
||
let milestone_row = milestone_rows(std::slice::from_ref(&milestone)).remove(0);
|
||
assert_eq!(
|
||
(milestone_row.open_issues, milestone_row.closed_issues),
|
||
(3, 2)
|
||
);
|
||
let issue = models::Issue {
|
||
milestone: Some(Box::new(milestone)),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(issue_rows(&[issue])[0].milestone, "Version 1");
|
||
}
|
||
}
|