use crate::schema::schema;
use serde::{Deserialize, Serialize};
use crate::api::change::MergeMethod;
use crate::api::edit::{Editable, SidebarPermits, reactions};
use crate::api::queries::{self as q, nodes};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Capped<T> {
pub items: Vec<T>,
total: u64,
}
impl<T> Capped<T> {
pub fn new(items: Vec<T>, total: u64) -> Self {
let total = total.max(items.len() as u64);
Self { items, total }
}
pub(crate) fn from_nodes<N>(
total: i32,
list: Option<Vec<Option<N>>>,
f: impl FnMut(N) -> T,
) -> Self {
Self::new(
nodes(list).map(f).collect(),
u64::try_from(total).unwrap_or_default(),
)
}
pub fn map<U>(self, f: impl FnMut(T) -> U) -> Capped<U> {
let items = self.items.into_iter().map(f).collect();
Capped {
items,
total: self.total,
}
}
pub fn total(&self) -> u64 {
self.total.max(self.items.len() as u64)
}
pub fn left_out(&self) -> u64 {
self.total.saturating_sub(self.items.len() as u64)
}
}
impl<T> Default for Capped<T> {
fn default() -> Self {
Self {
items: Vec::new(),
total: 0,
}
}
}
impl<T> From<Vec<T>> for Capped<T> {
fn from(items: Vec<T>) -> Self {
Self::new(items, 0)
}
}
impl<T> std::ops::Deref for Capped<T> {
type Target = [T];
fn deref(&self) -> &[T] {
&self.items
}
}
impl<T> std::ops::DerefMut for Capped<T> {
fn deref_mut(&mut self) -> &mut [T] {
&mut self.items
}
}
impl<'a, T> IntoIterator for &'a Capped<T> {
type Item = &'a T;
type IntoIter = std::slice::Iter<'a, T>;
fn into_iter(self) -> Self::IntoIter {
self.items.iter()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct NodeId(String);
impl NodeId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub(crate) fn gql(&self) -> cynic::Id {
cynic::Id::new(&self.0)
}
}
impl From<cynic::Id> for NodeId {
fn from(id: cynic::Id) -> Self {
Self(id.into_inner())
}
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RepoId {
pub owner: String,
pub name: String,
}
impl RepoId {
pub fn new(owner: impl Into<String>, name: impl Into<String>) -> Self {
Self {
owner: owner.into(),
name: name.into(),
}
}
pub fn parse(s: &str) -> Option<Self> {
let (owner, name) = s.split_once('/')?;
let name = name.strip_suffix(".git").unwrap_or(name);
(valid_segment(owner) && valid_segment(name)).then(|| Self::new(owner, name))
}
}
fn valid_segment(s: &str) -> bool {
!s.is_empty()
&& s != "."
&& s != ".."
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
}
impl std::fmt::Display for RepoId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.owner, self.name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PrRef {
pub repo: RepoId,
pub number: u64,
}
impl PrRef {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim();
if !s.contains("://")
&& let Some((repo, number)) = s.split_once('#')
{
return Some(Self {
repo: RepoId::parse(repo)?,
number: parse_number(number)?,
});
}
let url = url::Url::parse(s).ok()?;
if !matches!(url.scheme(), "https" | "http") || !crate::host::current().serves_url(&url) {
return None;
}
let mut segments = url.path_segments()?;
let owner = segments.next()?;
let name = segments.next()?;
if segments.next()? != "pull" {
return None;
}
Some(Self {
repo: RepoId::parse(&format!("{owner}/{name}"))?,
number: parse_number(segments.next()?)?,
})
}
pub fn url(&self) -> String {
crate::ui::pages::url::pull(self)
}
}
fn parse_number(s: &str) -> Option<u64> {
s.parse().ok().filter(|n| *n > 0)
}
impl std::fmt::Display for PrRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}#{}", self.repo, self.number)
}
}
#[derive(cynic::Enum, Debug, Clone, Copy, PartialEq, Eq)]
#[cynic(graphql_type = "PullRequestReviewDecision")]
pub enum ReviewDecision {
Approved,
ChangesRequested,
ReviewRequired,
}
#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum IssueState {
#[default]
Open,
Closed,
NotPlanned,
}
#[derive(Debug, Clone, Default, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PrState {
#[default]
Open,
Draft,
Closed,
Merged,
}
impl PrState {
pub fn is_open(self) -> bool {
matches!(self, Self::Open | Self::Draft)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Issue(IssueState),
Pr(PrState),
}
#[derive(cynic::QueryFragment, Debug)]
#[cynic(graphql_type = "PullRequest")]
pub struct PrStatus {
is_draft: bool,
state: q::PullRequestState,
}
impl From<PrStatus> for PrState {
fn from(pr: PrStatus) -> Self {
match pr.state {
q::PullRequestState::Open if pr.is_draft => Self::Draft,
q::PullRequestState::Open => Self::Open,
q::PullRequestState::Closed => Self::Closed,
q::PullRequestState::Merged => Self::Merged,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ChecksState {
Passing,
Failing,
Pending,
}
#[derive(cynic::Enum, Debug, Clone, Default, Copy, PartialEq, Eq)]
#[cynic(graphql_type = "MergeStateStatus")]
pub enum MergeState {
Behind,
Blocked,
Clean,
Dirty,
Draft,
HasHooks,
#[default]
Unknown,
Unstable,
}
#[derive(cynic::Enum, Debug, Clone, Default, Copy, PartialEq, Eq)]
#[cynic(graphql_type = "MergeableState")]
pub enum Mergeable {
#[cynic(rename = "MERGEABLE")]
Yes,
Conflicting,
#[default]
Unknown,
}
#[derive(cynic::Enum, Debug, Clone, Copy, PartialEq, Eq)]
#[cynic(graphql_type = "PullRequestReviewState")]
pub enum ReviewState {
Approved,
ChangesRequested,
Commented,
Dismissed,
Pending,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Label {
pub name: String,
pub color: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrDetail {
pub id: NodeId,
pub title: String,
pub author: String,
pub state: PrState,
pub updated_at: String,
pub additions: u64,
pub deletions: u64,
pub review: Option<ReviewDecision>,
pub checks: Option<ChecksState>,
pub body: String,
pub created_at: String,
pub base_ref: String,
pub head_ref: String,
pub base_oid: String,
pub head_oid: String,
pub head_repo: Option<String>,
pub changed_files: u64,
pub mergeable: Mergeable,
pub merge_state: MergeState,
pub behind_by: Option<u64>,
pub merge_methods: Vec<MergeMethod>,
pub auto_merge_allowed: bool,
pub auto_merge: Option<AutoMerge>,
pub merge_queue: bool,
pub queued: Option<Queued>,
pub labels: Capped<Label>,
pub milestone: Option<MilestoneRef>,
pub assignees: Capped<String>,
pub may: PrPermits,
pub editable: Editable,
pub sidebar: SidebarPermits,
pub head_gone: bool,
pub locked: Option<crate::api::manage::Locked>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingReview {
pub id: NodeId,
pub commit: Option<String>,
pub comments: u64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrPermits {
pub merge: bool,
pub merge_as_admin: bool,
pub close: bool,
pub reopen: bool,
pub edit: bool,
pub push_head: bool,
pub enable_auto_merge: bool,
pub disable_auto_merge: bool,
pub delete_head: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutoMerge {
pub by: String,
pub method: MergeMethod,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Queued {
pub position: u64,
pub state: QueueState,
}
#[derive(cynic::Enum, Debug, Clone, Copy, PartialEq, Eq)]
#[cynic(graphql_type = "MergeQueueEntryState")]
pub enum QueueState {
AwaitingChecks,
Locked,
Mergeable,
Queued,
Unmergeable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Readiness {
Merged,
Closed,
Draft,
Conflicts,
Checking,
Behind,
Blocked,
Ready,
}
impl PrDetail {
pub fn readiness(&self) -> Readiness {
match (self.state, self.mergeable, self.merge_state) {
(PrState::Merged, ..) => Readiness::Merged,
(PrState::Closed, ..) => Readiness::Closed,
(PrState::Draft, ..) | (_, _, MergeState::Draft) => Readiness::Draft,
(_, Mergeable::Conflicting, _) | (_, _, MergeState::Dirty) => Readiness::Conflicts,
(_, Mergeable::Unknown, _) | (_, _, MergeState::Unknown) => Readiness::Checking,
(.., MergeState::Behind) => Readiness::Behind,
(.., MergeState::Blocked) => Readiness::Blocked,
(.., MergeState::Clean | MergeState::HasHooks | MergeState::Unstable) => {
Readiness::Ready
}
}
}
pub fn settling(&self) -> bool {
self.state.is_open()
&& (self.mergeable == Mergeable::Unknown
|| self.merge_state == MergeState::Unknown
|| self.checks == Some(ChecksState::Pending)
|| self.queued.is_some())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MilestoneRef {
pub number: u64,
pub title: String,
}
impl MilestoneRef {
pub(crate) fn from_wire(m: q::MilestoneName) -> Self {
Self {
number: count(m.number),
title: m.title,
}
}
}
#[derive(cynic::Enum, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cynic(graphql_type = "FileViewedState")]
pub enum ViewedState {
#[default]
Unviewed,
Viewed,
Dismissed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewedFiles {
pub pull_request_id: NodeId,
pub states: std::collections::HashMap<String, ViewedState>,
}
#[derive(cynic::Enum, Debug, Clone, Default, Copy, PartialEq, Eq, Hash)]
#[cynic(graphql_type = "DiffSide")]
pub enum Side {
Left,
#[default]
Right,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewComment {
pub id: NodeId,
pub author: String,
pub body: String,
pub created_at: String,
pub url: String,
pub original_commit: Option<String>,
pub pending: bool,
pub editable: Editable,
pub review: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewThread {
pub id: NodeId,
pub path: String,
pub side: Side,
pub start_side: Option<Side>,
pub line: Option<u32>,
pub start_line: Option<u32>,
pub original_line: Option<u32>,
pub original_start_line: Option<u32>,
pub outdated: bool,
pub resolved: bool,
pub file_level: bool,
pub can_reply: bool,
pub can_resolve: bool,
pub can_unresolve: bool,
pub comments: Capped<ReviewComment>,
pub diff_hunk: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct PatchFile {
pub filename: String,
#[serde(default)]
pub previous_filename: Option<String>,
#[serde(default)]
pub patch: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReviewEvent {
Comment,
Approve,
RequestChanges,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewThread {
pub path: String,
pub body: String,
pub line: Option<u32>,
pub side: Side,
pub start_line: Option<u32>,
pub start_side: Option<Side>,
}
pub(crate) fn checks(commits: &q::CommitRollupConnection) -> Option<ChecksState> {
let rollup = commits
.nodes
.as_ref()?
.iter()
.flatten()
.last()?
.commit
.status_check_rollup
.as_ref()?;
Some(rollup.state.into())
}
impl From<q::StatusState> for ChecksState {
fn from(state: q::StatusState) -> Self {
match state {
q::StatusState::Success => Self::Passing,
q::StatusState::Failure | q::StatusState::Error => Self::Failing,
q::StatusState::Pending | q::StatusState::Expected => Self::Pending,
}
}
}
pub(crate) fn author(actor: Option<q::Actor>) -> String {
actor.map_or_else(|| "ghost".to_owned(), |a| a.login)
}
pub(crate) fn labels(labels: Option<q::LabelConnection>) -> Capped<Label> {
labels.map_or_else(Capped::default, |l| {
Capped::from_nodes(l.total_count, l.nodes, |l| Label {
name: l.name,
color: l.color,
})
})
}
pub(crate) fn count(n: i32) -> u64 {
u64::try_from(n).unwrap_or(0)
}
impl PrDetail {
pub(crate) fn from_wire(repo: q::RepositoryWithPr) -> Option<Self> {
let default = repo.viewer_default_merge_method;
let allowed = [
(MergeMethod::Merge, repo.merge_commit_allowed),
(MergeMethod::Squash, repo.squash_merge_allowed),
(MergeMethod::Rebase, repo.rebase_merge_allowed),
];
let mut merge_methods: Vec<MergeMethod> = allowed
.into_iter()
.filter_map(|(m, ok)| ok.then_some(m))
.collect();
merge_methods.sort_by_key(|m| *m != default);
let writes = q::RepositoryPermission::writes(repo.viewer_permission);
let pr = repo.pull_request?;
Some(Self {
id: pr.id.clone().into(),
title: pr.summary.title,
author: author(pr.summary.author),
state: pr.summary.status.into(),
updated_at: pr.summary.updated_at.0,
additions: count(pr.summary.additions),
deletions: count(pr.summary.deletions),
review: pr.summary.review_decision,
checks: checks(&pr.summary.commits),
body: pr.body,
created_at: pr.created_at.0,
base_ref: pr.base_ref_name,
head_ref: pr.head_ref_name,
base_oid: pr.base_ref_oid.0,
head_oid: pr.head_ref_oid.0,
head_repo: pr.head_repository.map(|r| r.name_with_owner),
changed_files: count(pr.changed_files),
mergeable: pr.mergeable,
merge_state: pr.merge_state_status,
behind_by: None,
merge_methods,
auto_merge_allowed: repo.auto_merge_allowed,
auto_merge: pr.auto_merge_request.map(|a| AutoMerge {
by: author(a.enabled_by),
method: a.merge_method,
}),
merge_queue: pr.is_merge_queue_enabled,
queued: pr.merge_queue_entry.map(|e| Queued {
position: count(e.position),
state: e.state,
}),
labels: labels(pr.labels),
milestone: pr.milestone.map(MilestoneRef::from_wire),
assignees: Capped::from_nodes(pr.assignees.total_count, pr.assignees.nodes, |u| {
u.login
}),
editable: Editable {
id: pr.id.into(),
can_update: pr.viewer_can_update,
can_delete: false,
can_react: pr.viewer_can_react,
reactions: reactions(pr.reaction_groups),
},
sidebar: SidebarPermits {
labels: pr.viewer_can_label,
assignees: pr.viewer_can_assign,
milestone: pr.viewer_can_label,
reviewers: pr.viewer_can_label,
},
may: PrPermits {
merge: writes,
merge_as_admin: pr.viewer_can_merge_as_admin,
close: pr.viewer_can_close,
reopen: pr.viewer_can_reopen,
edit: pr.viewer_can_update,
push_head: if pr.is_cross_repository {
pr.viewer_did_author || (pr.maintainer_can_modify && writes)
} else {
writes
},
enable_auto_merge: pr.viewer_can_enable_auto_merge,
disable_auto_merge: pr.viewer_can_disable_auto_merge,
delete_head: pr.viewer_can_delete_head_ref,
},
head_gone: pr.head_ref.is_none(),
locked: (pr.locked).then_some(crate::api::manage::Locked {
reason: pr.active_lock_reason,
}),
})
}
}
impl ReviewThread {
pub(crate) fn from_wire(t: q::ReviewThread) -> Self {
let line = |n: Option<i32>| n.and_then(|n| u32::try_from(n).ok());
let mut diff_hunk = String::new();
Self {
id: t.id.into(),
path: t.path,
side: t.diff_side,
start_side: t.start_diff_side,
line: line(t.line),
start_line: line(t.start_line),
original_line: line(t.original_line),
original_start_line: line(t.original_start_line),
outdated: t.is_outdated,
resolved: t.is_resolved,
file_level: t.subject_type == q::ThreadSubjectType::File,
can_reply: t.viewer_can_reply,
can_resolve: t.viewer_can_resolve,
can_unresolve: t.viewer_can_unresolve,
comments: {
let comment = |c: q::ReviewComment| ReviewComment {
editable: Editable {
id: c.id.clone().into(),
can_update: c.viewer_can_update,
can_delete: c.viewer_can_delete,
can_react: c.viewer_can_react,
reactions: reactions(c.reaction_groups),
},
id: c.id.into(),
author: author(c.author),
body: c.body,
created_at: c.created_at.0,
url: c.url.0,
original_commit: c.original_commit.map(|o| o.oid.0),
pending: c.state == q::ReviewCommentState::Pending,
review: (c.pull_request_review)
.and_then(|r| r.full_database_id?.0.parse().ok()),
};
let mut items: Vec<ReviewComment> = nodes(t.first_comment.nodes)
.map(|first| {
diff_hunk = first.diff_hunk;
comment(first.comment)
})
.collect();
let first = items.first().map(|c| c.id.clone());
items.extend(
nodes(t.comments.nodes)
.map(comment)
.filter(|c| first.as_ref() != Some(&c.id)),
);
Capped::new(
items,
u64::try_from(t.comments.total_count).unwrap_or_default(),
)
},
diff_hunk,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_open_draft_is_a_draft() {
let state = |json| PrState::from(serde_json::from_value::<PrStatus>(json).unwrap());
let open = serde_json::json!({ "isDraft": true, "state": "OPEN" });
assert_eq!(state(open), PrState::Draft);
let open = serde_json::json!({ "isDraft": false, "state": "OPEN" });
assert_eq!(state(open), PrState::Open);
let merged = serde_json::json!({ "isDraft": true, "state": "MERGED" });
assert_eq!(state(merged), PrState::Merged);
}
#[test]
fn a_decoded_count_is_never_fewer_than_its_items() {
let list: Capped<u8> = serde_json::from_str(r#"{"items":[1,2,3],"total":1}"#).unwrap();
assert_eq!((list.total(), list.left_out()), (3, 0));
}
#[test]
fn parses_short_refs() {
let pr = PrRef::parse("rust-lang/rust#12345").unwrap();
assert_eq!(pr.repo, RepoId::new("rust-lang", "rust"));
assert_eq!(pr.number, 12345);
assert_eq!(pr.to_string(), "rust-lang/rust#12345");
}
#[test]
fn parses_urls() {
for url in [
"https://github.com/ratatui/ratatui/pull/42",
"https://github.com/ratatui/ratatui/pull/42/files",
"https://github.com/ratatui/ratatui/pull/42/files#diff-abc",
"https://www.github.com/ratatui/ratatui/pull/42?w=1",
] {
let pr = PrRef::parse(url).unwrap_or_else(|| panic!("{url}"));
assert_eq!(pr.to_string(), "ratatui/ratatui#42");
}
}
#[test]
fn rejects_malformed_refs() {
for bad in [
"",
"rust",
"rust#1",
"a/b#",
"a/b#0",
"a/b#x",
"a/b/c#1",
"../b#1",
"https://gitlab.com/a/b/pull/1",
"https://github.com/a/b/issues/1",
"https://github.com/a/b",
"ftp://github.com/a/b/pull/1",
] {
assert_eq!(PrRef::parse(bad), None, "{bad}");
}
}
#[test]
fn repo_parse_strips_git_suffix() {
assert_eq!(RepoId::parse("a/b.git"), Some(RepoId::new("a", "b")));
}
}