feat: configure personal pull request review indicators

This commit is contained in:
Valdemar Lorenzen
2026-10-21 11:14:50 +02:00
parent bd71d463a4
commit 0202644efa
18 changed files with 901 additions and 88 deletions
+25 -5
View File
@@ -101,11 +101,19 @@ pub struct Catalog {
pub entries: Vec<Revision>,
pub notice: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum PullRequestReviewState {
#[default]
None,
Approved,
Requested,
}
#[derive(Debug)]
pub struct PullRequestPage {
pub kind: RepoKind,
pub remote: String,
pub entries: Vec<(Revision, String)>,
pub entries: Vec<(Revision, String, PullRequestReviewState)>,
pub has_more: bool,
}
@@ -572,7 +580,7 @@ pub struct DiffState {
pub catalog: Option<Catalog>,
pub pull_request_remote: Option<String>,
pull_request_categories: [PullRequestCategory; 2],
pull_request_order: BTreeMap<u64, (String, usize)>,
pull_request_order: BTreeMap<u64, (String, usize, PullRequestReviewState)>,
pub(crate) pull_request_retries: [u64; 2],
pub phase: Phase,
pub word_wrap: bool,
@@ -934,16 +942,18 @@ impl DiffState {
entries: Vec::new(),
notice: Some(format!("Pull requests from {}", page.remote)),
});
for (revision, created_at) in page.entries {
for (revision, created_at, review_state) in page.entries {
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
continue;
};
if let Some((date, index)) = self.pull_request_order.get_mut(number) {
if let Some((date, index, state)) = self.pull_request_order.get_mut(number)
{
*date = created_at;
*state = review_state;
catalog.entries[*index] = revision;
} else {
self.pull_request_order
.insert(*number, (created_at, catalog.entries.len()));
.insert(*number, (created_at, catalog.entries.len(), review_state));
catalog.entries.push(revision);
}
}
@@ -1222,6 +1232,16 @@ impl DiffState {
}
}
pub(crate) fn pull_request_review_state(&self, revision: &Revision) -> PullRequestReviewState {
if let RevisionTarget::PullRequest { number, .. } = revision.target {
self.pull_request_order
.get(&number)
.map_or(PullRequestReviewState::None, |entry| entry.2)
} else {
PullRequestReviewState::None
}
}
pub(crate) fn pull_request_partial(&self) -> bool {
self.pull_request_remote.is_some()
&& self
+4
View File
@@ -2284,6 +2284,8 @@ pub struct Editor {
diff_return_buffer: Option<usize>,
diff_context_options: (usize, usize),
diff_display_options: (ViewMode, SplitAxis),
pub(crate) pull_request_review_requested_symbol: String,
pub(crate) pull_request_approved_symbol: String,
next_diff_request: u64,
outstanding_diff_request: Option<u64>,
pending_lsp_navigation: Option<PendingLspNavigation>,
@@ -2424,6 +2426,8 @@ impl Editor {
diff_return_buffer: None,
diff_context_options: (10, 15),
diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
pull_request_review_requested_symbol: "🙏".into(),
pull_request_approved_symbol: "👍".into(),
next_diff_request: 0,
outstanding_diff_request: None,
pending_lsp_navigation: None,
+196 -58
View File
@@ -15,12 +15,14 @@ pub(crate) struct PullRequestSummary {
pub closed: bool,
pub draft: bool,
pub approved: bool,
pub review_requested: bool,
pub created_at: String,
}
struct PullRequestMetadata {
size: Option<(u64, u64)>,
approved: bool,
review_requested: bool,
}
pub(crate) struct PullRequestPage {
@@ -432,6 +434,7 @@ impl Forge {
let details = metadata.get(&id);
let mut entry = summary(self.provider, value, details.and_then(|d| d.size))?;
entry.approved = details.is_some_and(|d| d.approved);
entry.review_requested = details.is_some_and(|d| d.review_requested);
if entry.closed != closed {
return Err("Forge API returned a pull request in the wrong category".into());
}
@@ -478,28 +481,63 @@ impl Forge {
.and_then(Value::as_str)
.filter(|name| !name.is_empty())
});
if self.provider == Provider::Gitea {
let viewer = viewer.as_ref().and_then(|viewer| number(viewer, "id").ok());
return values
.iter()
.filter_map(|value| {
let id = number(value, "number").ok()?;
Some((
id,
PullRequestMetadata {
size: value
.get("additions")
.and_then(Value::as_u64)
.zip(value.get("deletions").and_then(Value::as_u64)),
// ponytail: Gitea has no batch reviews API; per-PR reads until it does.
approved: viewer.is_some_and(|viewer| {
self.gitea_approved_with(id, viewer, request)
.unwrap_or(false)
let mut metadata: BTreeMap<_, _> = values
.iter()
.filter_map(|value| {
let id = number(
value,
if self.provider == Provider::Gitlab {
"iid"
} else {
"number"
},
)
.ok()?;
Some((
id,
PullRequestMetadata {
size: value
.get("additions")
.and_then(Value::as_u64)
.zip(value.get("deletions").and_then(Value::as_u64)),
approved: false,
// GitHub's list contains only outstanding direct requests, not teams.
review_requested: self.provider == Provider::Github
&& username.is_some_and(|username| {
value
.get("requested_reviewers")
.and_then(Value::as_array)
.is_some_and(|reviewers| {
reviewers.iter().any(|reviewer| {
reviewer
.get("login")
.and_then(Value::as_str)
.is_some_and(|login| {
login.eq_ignore_ascii_case(username)
})
})
})
}),
},
))
})
.collect();
},
))
})
.collect();
if self.provider == Provider::Gitea {
if let Some(viewer) = viewer.as_ref().and_then(|viewer| number(viewer, "id").ok()) {
for (&id, details) in &mut metadata {
// ponytail: Gitea has no batch reviews API; per-PR reads until it does.
// List requested_reviewers also includes completed reviews.
(details.approved, details.review_requested) = self
.gitea_reviews_with(id, viewer, request)
.unwrap_or_default();
}
}
return metadata;
}
if self.provider == Provider::Gitlab {
if let Some(username) = username {
self.gitlab_requests_with(values, username, &mut metadata, request);
}
}
let reviews = if self.provider == Provider::Github {
username.map_or_else(String::new, |username| {
@@ -583,9 +621,9 @@ impl Forge {
)
};
let body = serde_json::json!({ "query": query }).to_string();
// Missing/denied metadata must not prevent browsing or imply approval.
// Missing/denied metadata must not discard independently known list data.
let Ok(response) = request(&url, Some(&body)) else {
return BTreeMap::new();
return metadata;
};
let container = if self.provider == Provider::Github {
"/data/repository"
@@ -593,42 +631,44 @@ impl Forge {
"/data/project"
};
let Some(entries) = response.pointer(container).and_then(Value::as_object) else {
return BTreeMap::new();
return metadata;
};
let mut metadata: BTreeMap<_, _> = entries
.iter()
.filter_map(|(alias, value)| {
let stats = if self.provider == Provider::Gitlab {
value.get("diffStatsSummary")
} else {
Some(value)
};
let size = stats.and_then(|stats| {
for (alias, value) in entries {
let Some(details) = alias
.strip_prefix('p')
.and_then(|id| id.parse::<u64>().ok())
.and_then(|id| metadata.get_mut(&id))
else {
continue;
};
let stats = if self.provider == Provider::Gitlab {
value.get("diffStatsSummary")
} else {
Some(value)
};
details.size = stats
.and_then(|stats| {
stats
.get("additions")?
.as_u64()
.zip(stats.get("deletions")?.as_u64())
})
.or(details.size);
details.approved = self.provider == Provider::Github
&& username.is_some_and(|username| {
value
.pointer("/reviews/nodes")
.and_then(Value::as_array)
.is_some_and(|reviews| {
reviews.len() == 1
&& reviews[0]["state"] == "APPROVED"
&& reviews[0]
.pointer("/author/login")
.and_then(Value::as_str)
.is_some_and(|author| author.eq_ignore_ascii_case(username))
})
});
let approved = self.provider == Provider::Github
&& username.is_some_and(|username| {
value
.pointer("/reviews/nodes")
.and_then(Value::as_array)
.is_some_and(|reviews| {
reviews.len() == 1
&& reviews[0]["state"] == "APPROVED"
&& reviews[0]
.pointer("/author/login")
.and_then(Value::as_str)
.is_some_and(|author| author.eq_ignore_ascii_case(username))
})
});
Some((
alias.strip_prefix('p')?.parse::<u64>().ok()?,
PullRequestMetadata { size, approved },
))
})
.collect();
}
if let Some(args) = approval_args {
let mut response = response;
let mut cursor = None::<String>;
@@ -675,13 +715,90 @@ impl Forge {
metadata
}
fn gitea_approved_with(
fn gitlab_requests_with(
&self,
values: &[Value],
username: &str,
metadata: &mut BTreeMap<u64, PullRequestMetadata>,
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) {
let mut pending: BTreeMap<_, _> = values
.iter()
.filter_map(|value| {
let reviewers = value.get("reviewers")?.as_array()?;
if !reviewers.iter().any(|reviewer| {
reviewer.get("username").and_then(Value::as_str) == Some(username)
}) {
return None;
}
Some((number(value, "iid").ok()?, None::<String>))
})
.collect();
// Keep optional reviewer-state reads separate: unsupported/denied fields
// must not invalidate the independent statistics and approval query.
while !pending.is_empty() {
use std::fmt::Write;
let mut fields = String::new();
// Ten aliases stay below GitLab's default query-complexity limit.
let batch: BTreeMap<_, _> = (0..10).filter_map(|_| pending.pop_first()).collect();
for (id, cursor) in &batch {
let after = cursor.as_ref().map_or_else(String::new, |cursor| {
format!(",after:{}", serde_json::to_string(cursor).unwrap())
});
write!(fields, "p{id}:mergeRequest(iid:\"{id}\"){{reviewers(first:20{after}){{nodes{{username mergeRequestInteraction{{reviewState}}}} pageInfo{{hasNextPage endCursor}}}}}}").unwrap();
}
let body = serde_json::json!({
"query": format!("query{{project(fullPath:{}){{{fields}}}}}",
serde_json::to_string(&self.repository).unwrap())
})
.to_string();
let Ok(response) = request(&format!("https://{}/api/graphql", self.host), Some(&body))
else {
continue;
};
for (id, cursor) in batch {
let Some(reviewers) = response.pointer(&format!("/data/project/p{id}/reviewers"))
else {
continue;
};
let own = reviewers
.get("nodes")
.and_then(Value::as_array)
.and_then(|nodes| {
nodes.iter().find(|node| {
node.get("username").and_then(Value::as_str) == Some(username)
})
});
if let Some(own) = own {
if let Some(details) = metadata.get_mut(&id) {
details.review_requested = matches!(
own.pointer("/mergeRequestInteraction/reviewState")
.and_then(Value::as_str),
Some("UNREVIEWED" | "REVIEW_STARTED")
);
}
} else if reviewers.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(true)) {
if let Some(after) = reviewers
.pointer("/pageInfo/endCursor")
.and_then(Value::as_str)
.filter(|after| !after.is_empty() && Some(*after) != cursor.as_deref())
{
pending.insert(id, Some(after.to_owned()));
}
}
}
}
}
fn gitea_reviews_with(
&self,
id: u64,
viewer: u64,
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) -> Result<bool, String> {
) -> Result<(bool, bool), String> {
let mut latest = None::<(u64, bool)>;
let mut requested = None::<u64>;
let mut submitted = 0;
for page in 1.. {
let response = request(
&format!("{}/{id}/reviews?limit=50&page={page}", self.endpoint),
@@ -691,19 +808,39 @@ impl Forge {
.as_array()
.ok_or("Forge API returned a non-array review list")?;
if reviews.is_empty() {
return Ok(latest.is_some_and(|(_, approved)| approved));
return Ok((
latest.is_some_and(|(_, approved)| approved),
requested.is_some_and(|id| id > submitted),
));
}
for review in reviews {
if review.pointer("/user/id").and_then(Value::as_u64) != Some(viewer) {
if review.pointer("/user/id").and_then(Value::as_u64) != Some(viewer)
|| review.get("team").is_some_and(|team| !team.is_null())
{
continue;
}
let state = field(review, "state")?;
match state {
"COMMENT" | "PENDING" | "REQUEST_REVIEW" => continue,
"PENDING" => continue,
"REQUEST_REVIEW" => {
if review.get("dismissed").and_then(Value::as_bool) == Some(false) {
if let Some(id) = review.get("id").and_then(Value::as_u64) {
requested = Some(requested.map_or(id, |previous| previous.max(id)));
}
}
continue;
}
"COMMENT" => {
if let Some(id) = review.get("id").and_then(Value::as_u64) {
submitted = submitted.max(id);
}
continue;
}
"APPROVED" | "REQUEST_CHANGES" => {}
_ => return Err("Forge API returned invalid review state".into()),
}
let id = number(review, "id")?;
submitted = submitted.max(id);
let dismissed = review
.get("dismissed")
.and_then(Value::as_bool)
@@ -917,6 +1054,7 @@ fn summary(
closed,
draft,
approved: false,
review_requested: false,
created_at: created_at(value)?,
})
}
+2 -1
View File
@@ -107,7 +107,8 @@ Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
✓ before the PR number marks your API account's active approval; blank may mean unavailable
🙏 marks a personal review request; 👍 marks your active approval (API account)
Symbols: config.pull_request_review_requested_symbol / config.pull_request_approved_symbol
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base)
+9 -7
View File
@@ -1,7 +1,7 @@
//! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks,
PullRequestPage, PullRequestReviewState, RepoKind, Revision, RevisionTarget, line_hunks,
};
use std::{
collections::{BTreeMap, BTreeSet},
@@ -1631,12 +1631,7 @@ impl Repository {
remote: remote.clone(),
number: pull.number,
},
label: format!(
"{}#{} {}",
if pull.approved { "✓ " } else { " " },
pull.number,
pull.title
),
label: format!("#{} {}", pull.number, pull.title),
description: pull.description,
category: if pull.closed {
Category::Closed
@@ -1647,6 +1642,13 @@ impl Repository {
},
},
pull.created_at,
if pull.review_requested {
PullRequestReviewState::Requested
} else if pull.approved {
PullRequestReviewState::Approved
} else {
PullRequestReviewState::None
},
)
})
.collect(),
+24
View File
@@ -58,6 +58,8 @@ pub struct Config {
pub diff_view_mode: crate::diff::ViewMode,
pub diff_split_orientation: crate::panes::SplitAxis,
pub diff_follow_working_copy: bool,
pub pull_request_review_requested_symbol: String,
pub pull_request_approved_symbol: String,
pub resolve_history_layout: crate::editor::ResolverLayout,
}
@@ -112,6 +114,8 @@ impl Default for Config {
diff_view_mode: crate::diff::ViewMode::Split,
diff_split_orientation: crate::panes::SplitAxis::Vertical,
diff_follow_working_copy: true,
pull_request_review_requested_symbol: "🙏".into(),
pull_request_approved_symbol: "👍".into(),
resolve_history_layout: crate::editor::ResolverLayout::Columns,
}
}
@@ -153,6 +157,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
diff_view_mode,
diff_split_orientation,
diff_follow_working_copy,
pull_request_review_requested_symbol,
pull_request_approved_symbol,
resolve_history_layout,
..
} = *response;
@@ -195,6 +201,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.diff_view_mode = diff_view_mode;
config.diff_split_orientation = diff_split_orientation;
config.diff_follow_working_copy = diff_follow_working_copy;
config.pull_request_review_requested_symbol = pull_request_review_requested_symbol;
config.pull_request_approved_symbol = pull_request_approved_symbol;
config.resolve_history_layout = resolve_history_layout;
Ok(config)
}
@@ -556,9 +564,25 @@ struct ConfigFrame {
diff_view_mode: crate::diff::ViewMode,
diff_split_orientation: crate::panes::SplitAxis,
diff_follow_working_copy: bool,
#[serde(deserialize_with = "pull_request_symbol")]
pull_request_review_requested_symbol: String,
#[serde(deserialize_with = "pull_request_symbol")]
pull_request_approved_symbol: String,
resolve_history_layout: crate::editor::ResolverLayout,
}
fn pull_request_symbol<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<String, D::Error> {
let symbol = String::deserialize(deserializer)?;
if symbol.chars().any(char::is_control) {
return Err(serde::de::Error::custom(
"pull request symbols must not contain control characters",
));
}
Ok(symbol)
}
fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<char, char>, D::Error> {
+23
View File
@@ -157,6 +157,8 @@ module Vamp
@diff_view_mode = "split"
@diff_split_orientation = "vertical"
@diff_follow_working_copy = true
@pull_request_review_requested_symbol = "🙏"
@pull_request_approved_symbol = "👍"
@resolve_history_layout = "columns"
end
@@ -327,6 +329,18 @@ module Vamp
@diff_follow_working_copy = enabled
end
# @param symbol [String] empty to hide the review-request indicator
# @return [String]
def pull_request_review_requested_symbol=(symbol)
@pull_request_review_requested_symbol = pull_request_symbol!(symbol, "pull_request_review_requested_symbol")
end
# @param symbol [String] empty to hide the approval indicator
# @return [String]
def pull_request_approved_symbol=(symbol)
@pull_request_approved_symbol = pull_request_symbol!(symbol, "pull_request_approved_symbol")
end
# @param layout [String] "columns" or "base_below"
# @return [String]
def resolve_history_layout=(layout)
@@ -382,10 +396,19 @@ module Vamp
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation,
"diff_follow_working_copy" => @diff_follow_working_copy,
"pull_request_review_requested_symbol" => @pull_request_review_requested_symbol,
"pull_request_approved_symbol" => @pull_request_approved_symbol,
"resolve_history_layout" => @resolve_history_layout }
end
private
def pull_request_symbol!(symbol, label)
symbol = Vamp.string!(symbol, label)
raise ArgumentError, "#{label} must not contain control characters" if symbol.match?(/[[:cntrl:]]/)
symbol
end
# @param spaces [Integer, nil]
# @return [Integer, nil]
def tab_spaces!(spaces)
+2
View File
@@ -542,6 +542,8 @@ impl EditorSession {
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
editor.pull_request_review_requested_symbol = config.pull_request_review_requested_symbol;
editor.pull_request_approved_symbol = config.pull_request_approved_symbol;
editor.resolve_history_layout = config.resolve_history_layout;
if let Err(error) =
editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels)
+29 -5
View File
@@ -3536,6 +3536,18 @@ fn draw_diff(
panels.picker_scroll.distance,
);
let mut lines = Vec::with_capacity(count * row_height);
let (requested_symbol, approved_symbol) = editor.map_or(("🙏", "👍"), |editor| {
(
editor.pull_request_review_requested_symbol.as_str(),
editor.pull_request_approved_symbol.as_str(),
)
});
let symbol_width = if pull_requests {
requested_symbol.width().max(approved_symbol.width())
} else {
0
};
let decoration_width = symbol_width + usize::from(symbol_width > 0);
for (index, revision) in entries
.enumerate()
.skip(first)
@@ -3549,10 +3561,21 @@ fn draw_diff(
} else {
Style::default()
};
let symbol = if pull_requests {
match diff.pull_request_review_state(revision) {
crate::diff::PullRequestReviewState::Requested => requested_symbol,
crate::diff::PullRequestReviewState::Approved => approved_symbol,
crate::diff::PullRequestReviewState::None => "",
}
} else {
""
};
let label = format!(
"{} {}",
"{} {symbol}{:padding$}{}",
if index == diff.selected { ">" } else { " " },
display_text(&revision.label)
"",
display_text(&revision.label),
padding = decoration_width.saturating_sub(symbol.width()),
);
let width = usize::from(results.width);
if row_height == 2 {
@@ -3560,9 +3583,10 @@ fn draw_diff(
lines.push(Line::styled(
visible_line(
&format!(
"{}{}",
if pull_requests { " " } else { " " },
display_text(&revision.description)
"{:indent$}{}",
"",
display_text(&revision.description),
indent = 2 + decoration_width,
),
0,
width,