feat: browse GitHub GitLab and Gitea pull requests
This commit is contained in:
+292
-18
@@ -19,6 +19,9 @@ pub enum Category {
|
||||
Commits,
|
||||
Branches,
|
||||
Tags,
|
||||
Open,
|
||||
Closed,
|
||||
All,
|
||||
}
|
||||
impl Category {
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -28,6 +31,9 @@ impl Category {
|
||||
Self::Commits => "Commits",
|
||||
Self::Branches => "Branches",
|
||||
Self::Tags => "Tags",
|
||||
Self::Open => "Open",
|
||||
Self::Closed => "Closed",
|
||||
Self::All => "All",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +63,23 @@ impl ComparisonMode {
|
||||
pub enum RevisionTarget {
|
||||
Working,
|
||||
Commit(String),
|
||||
Remote { remote: String, reference: String },
|
||||
Remote {
|
||||
remote: String,
|
||||
reference: String,
|
||||
},
|
||||
RemoteCommit {
|
||||
remote: String,
|
||||
reference: String,
|
||||
id: String,
|
||||
},
|
||||
PullRequest {
|
||||
remote: String,
|
||||
base_ref: String,
|
||||
base_sha: String,
|
||||
head_ref: String,
|
||||
head_sha: String,
|
||||
},
|
||||
UnavailablePullRequest(String),
|
||||
ListRemote(String),
|
||||
FetchRemote(String),
|
||||
}
|
||||
@@ -114,6 +136,7 @@ pub struct Comparison {
|
||||
#[derive(Debug)]
|
||||
pub enum Operation {
|
||||
Discover,
|
||||
PullRequests(String),
|
||||
ListRemote(String),
|
||||
FetchRemote(String),
|
||||
Compare {
|
||||
@@ -208,6 +231,7 @@ enum RequestKind {
|
||||
#[derive(Debug)]
|
||||
pub struct DiffState {
|
||||
pub catalog: Option<Catalog>,
|
||||
pub pull_request_remote: Option<String>,
|
||||
pub phase: Phase,
|
||||
pub word_wrap: bool,
|
||||
pub view_mode: ViewMode,
|
||||
@@ -256,6 +280,7 @@ impl DiffState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
catalog: None,
|
||||
pull_request_remote: None,
|
||||
phase: Phase::Before,
|
||||
word_wrap: false,
|
||||
view_mode: ViewMode::Split,
|
||||
@@ -301,6 +326,24 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pull_requests(remote: String) -> Self {
|
||||
Self {
|
||||
pull_request_remote: Some(remote.clone()),
|
||||
pending: Some(Operation::PullRequests(remote)),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn categories(&self) -> &'static [Category] {
|
||||
if self.pull_request_remote.is_some() {
|
||||
&[Category::Open, Category::Closed, Category::All]
|
||||
} else {
|
||||
self.catalog
|
||||
.as_ref()
|
||||
.map_or(&[], |catalog| catalog.kind.categories())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl ExactSizeIterator<Item = &Revision> {
|
||||
self.visible
|
||||
.iter()
|
||||
@@ -507,8 +550,8 @@ impl DiffState {
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
self.category = self.category.min(catalog.kind.categories().len() - 1);
|
||||
self.catalog = Some(catalog);
|
||||
self.category = self.category.min(self.categories().len() - 1);
|
||||
self.refilter();
|
||||
if let Some(target) = selected {
|
||||
self.select_target(&target);
|
||||
@@ -588,20 +631,26 @@ impl DiffState {
|
||||
self.visible.clear();
|
||||
self.search_matches.clear();
|
||||
if let Some(catalog) = &self.catalog {
|
||||
let category = catalog.kind.categories()[self.category];
|
||||
let category = self.categories()[self.category];
|
||||
let query = self.query.to_lowercase();
|
||||
let tokens: Vec<_> = query.split_whitespace().collect();
|
||||
// Working is always pinned, even outside its category or query.
|
||||
self.visible.extend(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
// Repository revisions pin Working outside its category and query.
|
||||
if self.pull_request_remote.is_none() {
|
||||
self.visible.extend(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
}
|
||||
for (index, entry) in catalog.entries.iter().enumerate() {
|
||||
if entry.target == RevisionTarget::Working || entry.category != category {
|
||||
if entry.target == RevisionTarget::Working
|
||||
|| (entry.category != category
|
||||
&& !(category == Category::All
|
||||
&& matches!(entry.category, Category::Open | Category::Closed)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if tokens.is_empty() {
|
||||
@@ -704,8 +753,8 @@ impl DiffState {
|
||||
}
|
||||
match input {
|
||||
Input::Char('\t') | Input::BackTab => {
|
||||
if let Some(catalog) = &self.catalog {
|
||||
let count = catalog.kind.categories().len();
|
||||
let count = self.categories().len();
|
||||
if count > 0 {
|
||||
self.category = if input == Input::BackTab {
|
||||
(self.category + count - 1) % count
|
||||
} else {
|
||||
@@ -742,16 +791,53 @@ impl DiffState {
|
||||
fn choose(&mut self) {
|
||||
let revision = self.entries().nth(self.selected).cloned();
|
||||
let Some(revision) = revision else {
|
||||
// Discovery can be retried without closing the modal.
|
||||
// Listing can be retried without closing the modal.
|
||||
if self.catalog.is_none() {
|
||||
self.queue(Operation::Discover);
|
||||
self.queue(match &self.pull_request_remote {
|
||||
Some(remote) => Operation::PullRequests(remote.clone()),
|
||||
None => Operation::Discover,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
if let RevisionTarget::UnavailablePullRequest(error) = &revision.target {
|
||||
self.before = None;
|
||||
self.after = None;
|
||||
self.error = Some(error.clone());
|
||||
return;
|
||||
}
|
||||
if revision.target == RevisionTarget::Working {
|
||||
self.comparison_mode = ComparisonMode::Snapshots;
|
||||
}
|
||||
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||
if let RevisionTarget::PullRequest {
|
||||
remote,
|
||||
base_ref,
|
||||
base_sha,
|
||||
head_ref,
|
||||
head_sha,
|
||||
} = &revision.target
|
||||
{
|
||||
self.before = Some(Revision {
|
||||
target: RevisionTarget::RemoteCommit {
|
||||
remote: remote.clone(),
|
||||
reference: base_ref.clone(),
|
||||
id: base_sha.clone(),
|
||||
},
|
||||
label: base_ref.clone(),
|
||||
description: String::new(),
|
||||
category: revision.category,
|
||||
});
|
||||
self.after = Some(Revision {
|
||||
target: RevisionTarget::RemoteCommit {
|
||||
remote: remote.clone(),
|
||||
reference: head_ref.clone(),
|
||||
id: head_sha.clone(),
|
||||
},
|
||||
..revision
|
||||
});
|
||||
self.comparison_mode = ComparisonMode::BranchChanges;
|
||||
self.queue_comparison();
|
||||
} else if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||
self.queue(Operation::FetchRemote(remote.clone()));
|
||||
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
|
||||
self.queue(Operation::ListRemote(remote.clone()));
|
||||
@@ -776,6 +862,9 @@ impl DiffState {
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_comparison_mode(&mut self) -> Result<(), &'static str> {
|
||||
if self.pull_request_remote.is_some() && self.after.is_none() {
|
||||
return Err("Select a pull request before changing comparison mode");
|
||||
}
|
||||
if self.working_selected() {
|
||||
return Err("Working checkout requires Snapshots");
|
||||
}
|
||||
@@ -2332,6 +2421,191 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn pull_request_catalog() -> Catalog {
|
||||
Catalog {
|
||||
kind: RepoKind::Git,
|
||||
entries: [
|
||||
("#1 Fix Ünicode", Category::Open),
|
||||
("#2 Fix Ünicode", Category::Closed),
|
||||
("#3 Other work", Category::Open),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(label, category)| {
|
||||
revision(
|
||||
RevisionTarget::PullRequest {
|
||||
remote: "origin".into(),
|
||||
base_ref: "refs/heads/main".into(),
|
||||
base_sha: "base-sha".into(),
|
||||
head_ref: "refs/pull/1/head".into(),
|
||||
head_sha: "head-sha".into(),
|
||||
},
|
||||
label,
|
||||
"Alice · main ← topic",
|
||||
category,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
notice: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_categories_keep_query_and_select_both_pinned_branch_tips() {
|
||||
let mut state = DiffState::pull_requests("origin".into());
|
||||
assert_eq!(
|
||||
state.categories(),
|
||||
[Category::Open, Category::Closed, Category::All]
|
||||
);
|
||||
state.take_request();
|
||||
state.handle(Input::Char('\t'));
|
||||
for ch in "FIX üni Alice".chars() {
|
||||
state.handle(Input::Char(ch));
|
||||
}
|
||||
state.apply_result(1, Ok(Outcome::Catalog(pull_request_catalog())));
|
||||
assert_eq!(
|
||||
state
|
||||
.entries()
|
||||
.map(|entry| entry.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["#2 Fix Ünicode"]
|
||||
);
|
||||
state.handle(Input::Char('\t'));
|
||||
assert_eq!(state.entries().len(), 2);
|
||||
state.handle(Input::Char('\t'));
|
||||
assert_eq!(state.query, "FIX üni Alice");
|
||||
assert_eq!(
|
||||
state
|
||||
.entries()
|
||||
.map(|entry| entry.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["#1 Fix Ünicode"]
|
||||
);
|
||||
state.handle(Input::Ctrl('u'));
|
||||
assert_eq!(state.entries().len(), 2);
|
||||
state.comparison_mode = ComparisonMode::Snapshots;
|
||||
state.handle(Input::Enter);
|
||||
assert_eq!(state.phase, Phase::After);
|
||||
let Operation::Compare {
|
||||
before,
|
||||
after,
|
||||
mode,
|
||||
buffers,
|
||||
} = state.take_request().unwrap()
|
||||
else {
|
||||
panic!("expected direct paired comparison");
|
||||
};
|
||||
assert_eq!(mode, ComparisonMode::BranchChanges);
|
||||
assert!(buffers.is_empty());
|
||||
assert_eq!(before.label, "refs/heads/main");
|
||||
assert_eq!(after.label, "#1 Fix Ünicode");
|
||||
assert_eq!(
|
||||
before.target,
|
||||
RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: "refs/heads/main".into(),
|
||||
id: "base-sha".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
after.target,
|
||||
RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: "refs/pull/1/head".into(),
|
||||
id: "head-sha".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_loading_retry_and_escape_reject_stale_results() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("unsaved source".into())])
|
||||
.unwrap();
|
||||
editor.execute("pull_requests origin");
|
||||
let (first, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::PullRequests(remote) if remote == "origin"));
|
||||
editor.handle(Input::Ctrl('b'));
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.diff().unwrap().loading);
|
||||
assert!(editor.take_diff_request().is_none());
|
||||
text(&mut editor, "alice");
|
||||
assert!(editor.apply_diff_result(first, Err("API unavailable".into())));
|
||||
editor.handle(Input::Enter);
|
||||
let (retry, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::PullRequests(remote) if remote == "origin"));
|
||||
assert_eq!(editor.diff().unwrap().query, "alice");
|
||||
assert!(!editor.apply_diff_result(first, Ok(Outcome::Catalog(pull_request_catalog()))));
|
||||
assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(pull_request_catalog()))));
|
||||
editor.handle(Input::Enter);
|
||||
let (compare, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(
|
||||
operation,
|
||||
Operation::Compare {
|
||||
mode: ComparisonMode::BranchChanges,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(editor.apply_diff_result(compare, Err("fetch unavailable".into())));
|
||||
editor.handle(Input::Enter);
|
||||
let (compare_retry, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::Compare { .. }));
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_none());
|
||||
assert!(!editor.apply_diff_result(compare_retry, Ok(Outcome::Comparison(comparison()))));
|
||||
assert_eq!(editor.lines(), ["unsaved source"]);
|
||||
assert!(editor.dirty());
|
||||
editor.execute("pull_requests");
|
||||
let (reopened, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::PullRequests(remote) if remote.is_empty()));
|
||||
assert!(!editor.apply_diff_result(retry, Ok(Outcome::Catalog(pull_request_catalog()))));
|
||||
let mut empty = pull_request_catalog();
|
||||
empty.entries.clear();
|
||||
assert!(editor.apply_diff_result(reopened, Ok(Outcome::Catalog(empty))));
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.take_diff_request().is_none());
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_pull_request_does_not_block_other_catalog_entries() {
|
||||
let mut state = DiffState::pull_requests("origin".into());
|
||||
state.take_request();
|
||||
let mut catalog = pull_request_catalog();
|
||||
catalog.entries.insert(
|
||||
0,
|
||||
revision(
|
||||
RevisionTarget::UnavailablePullRequest("Historical head no longer retained".into()),
|
||||
"#194 Deleted head",
|
||||
"unavailable",
|
||||
Category::Open,
|
||||
),
|
||||
);
|
||||
state.apply_result(1, Ok(Outcome::Catalog(catalog)));
|
||||
state.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
state.error.as_deref(),
|
||||
Some("Historical head no longer retained")
|
||||
);
|
||||
assert!(state.take_request().is_none());
|
||||
state.handle(Input::Down);
|
||||
state.handle(Input::Enter);
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::Compare {
|
||||
mode: ComparisonMode::BranchChanges,
|
||||
..
|
||||
})
|
||||
));
|
||||
state.apply_result(2, Err("fetch failed".into()));
|
||||
state.handle(Input::Home);
|
||||
state.handle(Input::Enter);
|
||||
assert!(state.before.is_none() && state.after.is_none());
|
||||
assert!(state.toggle_comparison_mode().is_err());
|
||||
assert!(state.take_request().is_none());
|
||||
}
|
||||
|
||||
fn editor() -> Editor {
|
||||
Editor::open(None, vec![]).unwrap()
|
||||
}
|
||||
|
||||
+18
-5
@@ -793,6 +793,12 @@ core_actions![
|
||||
true
|
||||
),
|
||||
("core.diff", Action::Diff, &[Mode::Normal], true),
|
||||
(
|
||||
"core.pull_requests",
|
||||
Action::PullRequests,
|
||||
&[Mode::Normal],
|
||||
true
|
||||
),
|
||||
(
|
||||
"core.diff.mode.toggle",
|
||||
Action::DiffModeToggle,
|
||||
@@ -2911,7 +2917,7 @@ impl Editor {
|
||||
true
|
||||
}
|
||||
|
||||
fn open_diff(&mut self) {
|
||||
fn open_diff(&mut self, mut diff: DiffState) {
|
||||
if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) {
|
||||
self.with_pane(owner, |editor| {
|
||||
editor.release_diff_buffers();
|
||||
@@ -2933,7 +2939,6 @@ impl Editor {
|
||||
self.active.view.mode = Mode::Normal;
|
||||
}
|
||||
self.outstanding_diff_request = None;
|
||||
let mut diff = DiffState::new();
|
||||
diff.word_wrap = self.active.view.word_wrap;
|
||||
diff.view_mode = self.diff_display_options.0;
|
||||
diff.split_axis = self.diff_display_options.1;
|
||||
@@ -3522,6 +3527,7 @@ impl Editor {
|
||||
| Action::Palette
|
||||
| Action::FilePicker
|
||||
| Action::FileExplorer
|
||||
| Action::PullRequests
|
||||
| Action::DiffClose
|
||||
| Action::DiffModeToggle
|
||||
| Action::DiffViewToggle
|
||||
@@ -6595,6 +6601,7 @@ impl Editor {
|
||||
| Action::BufferDelete
|
||||
| Action::BufferForceDelete
|
||||
| Action::Diff
|
||||
| Action::PullRequests
|
||||
| Action::DiffClose
|
||||
| Action::DiffModeToggle
|
||||
| Action::DiffViewToggle
|
||||
@@ -7541,9 +7548,15 @@ impl Editor {
|
||||
self.set_message("Diff pane is read-only");
|
||||
return None;
|
||||
}
|
||||
if entry.action == Action::Diff {
|
||||
if args.is_empty() {
|
||||
self.open_diff();
|
||||
if matches!(entry.action, Action::Diff | Action::PullRequests) {
|
||||
if entry.action == Action::PullRequests {
|
||||
if args.split_whitespace().count() > 1 {
|
||||
self.set_message("Usage: pull_requests [remote]");
|
||||
} else {
|
||||
self.open_diff(DiffState::pull_requests(args.into()));
|
||||
}
|
||||
} else if args.is_empty() {
|
||||
self.open_diff(DiffState::new());
|
||||
} else {
|
||||
self.set_message("The diff command takes no arguments");
|
||||
}
|
||||
|
||||
+729
@@ -0,0 +1,729 @@
|
||||
//! Read-only forge catalogs; fetch targets always stay on the configured remote.
|
||||
use crate::repository::{checked, run};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::Path,
|
||||
process::Command,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
pub(crate) struct PullRequest {
|
||||
pub number: u64,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub closed: bool,
|
||||
pub revisions: Result<PullRequestRevisions, String>,
|
||||
}
|
||||
|
||||
pub(crate) struct PullRequestRevisions {
|
||||
pub base_ref: String,
|
||||
pub base_sha: String,
|
||||
pub head_ref: String,
|
||||
pub head_sha: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Provider {
|
||||
Github,
|
||||
Gitlab,
|
||||
Gitea,
|
||||
}
|
||||
struct Forge {
|
||||
provider: Provider,
|
||||
host: String,
|
||||
endpoint: String,
|
||||
}
|
||||
|
||||
fn encode(text: &str) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut encoded = String::new();
|
||||
for byte in text.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
write!(encoded, "%{byte:02X}").unwrap();
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn decode(path: &str) -> Result<String, String> {
|
||||
let mut bytes = path.bytes();
|
||||
let mut decoded = Vec::with_capacity(path.len());
|
||||
while let Some(byte) = bytes.next() {
|
||||
decoded.push(if byte == b'%' {
|
||||
let hi = bytes.next().and_then(|b| (b as char).to_digit(16));
|
||||
let lo = bytes.next().and_then(|b| (b as char).to_digit(16));
|
||||
let (hi, lo) = hi.zip(lo).ok_or("Invalid escaped remote path")?;
|
||||
(hi * 16 + lo) as u8
|
||||
} else {
|
||||
byte
|
||||
});
|
||||
}
|
||||
String::from_utf8(decoded).map_err(|_| "Remote path is not UTF-8".into())
|
||||
}
|
||||
|
||||
impl Forge {
|
||||
fn parse(url: &str, provider: Option<&str>) -> Result<Self, String> {
|
||||
if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains(['?', '#', '\\']) {
|
||||
return Err("Invalid forge remote URL".into());
|
||||
}
|
||||
let (authority, path, ssh) = if let Some((scheme, rest)) = url.split_once("://") {
|
||||
if !matches!(scheme, "https" | "ssh" | "git+ssh") {
|
||||
return Err("Pull requests require an HTTPS or SSH remote".into());
|
||||
}
|
||||
let (authority, path) = rest
|
||||
.split_once('/')
|
||||
.ok_or("Forge remote has no repository path")?;
|
||||
(authority, path, scheme != "https")
|
||||
} else {
|
||||
let separator = if let Some(end) = url.find(']') {
|
||||
end + 1
|
||||
} else {
|
||||
url.find(':')
|
||||
.ok_or("Pull requests require an HTTPS or SSH remote")?
|
||||
};
|
||||
if url.as_bytes().get(separator) != Some(&b':') {
|
||||
return Err("Invalid SSH remote".into());
|
||||
}
|
||||
(&url[..separator], &url[separator + 1..], true)
|
||||
};
|
||||
// Never reuse URL userinfo as an API credential or include it in arguments/errors.
|
||||
let authority = authority.rsplit('@').next().unwrap();
|
||||
let (host, port) = if authority.starts_with('[') {
|
||||
let end = authority.find(']').ok_or("Invalid remote host")?;
|
||||
authority[1..end]
|
||||
.parse::<std::net::Ipv6Addr>()
|
||||
.map_err(|_| "Invalid remote host")?;
|
||||
(&authority[..=end], &authority[end + 1..])
|
||||
} else {
|
||||
let (host, port) = authority.split_at(authority.find(':').unwrap_or(authority.len()));
|
||||
if host.is_empty()
|
||||
|| host.starts_with('-')
|
||||
|| !host
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b".-".contains(&b))
|
||||
{
|
||||
return Err("Invalid remote host".into());
|
||||
}
|
||||
(host, port)
|
||||
};
|
||||
if !port.is_empty()
|
||||
&& port
|
||||
.strip_prefix(':')
|
||||
.and_then(|p| p.parse::<u16>().ok())
|
||||
.filter(|p| *p != 0)
|
||||
.is_none()
|
||||
{
|
||||
return Err("Invalid remote port".into());
|
||||
}
|
||||
let mut host = host.to_ascii_lowercase();
|
||||
if ssh && host == "ssh.github.com" {
|
||||
host = "github.com".into();
|
||||
}
|
||||
if ssh && host == "altssh.gitlab.com" {
|
||||
host = "gitlab.com".into();
|
||||
}
|
||||
let provider = match provider {
|
||||
Some("github") => Provider::Github,
|
||||
Some("gitlab") => Provider::Gitlab,
|
||||
Some("gitea") => Provider::Gitea,
|
||||
Some(_) => {
|
||||
return Err("remote.<name>.vamp-forge must be github, gitlab, or gitea".into());
|
||||
}
|
||||
None if host == "github.com" => Provider::Github,
|
||||
None if host.split('.').any(|label| label == "gitlab") => Provider::Gitlab,
|
||||
None if host == "codeberg.org" || host.split('.').any(|label| label == "gitea") => {
|
||||
Provider::Gitea
|
||||
}
|
||||
None => {
|
||||
return Err(format!(
|
||||
"Unknown forge for {host}; run `git config remote.<name>.vamp-forge github` (or use gitlab or gitea)"
|
||||
));
|
||||
}
|
||||
};
|
||||
let path = decode(path.trim_matches('/'))?;
|
||||
let path = path.strip_suffix(".git").unwrap_or(&path);
|
||||
let parts: Vec<_> = path.split('/').collect();
|
||||
if parts.len() < 2
|
||||
|| (provider != Provider::Gitlab && parts.len() != 2)
|
||||
|| parts.iter().any(|p| {
|
||||
p.is_empty() || matches!(*p, "." | "..") || p.chars().any(char::is_control)
|
||||
})
|
||||
{
|
||||
return Err("Invalid repository path for the configured forge".into());
|
||||
}
|
||||
let authority = format!("{host}{}", if ssh { "" } else { port });
|
||||
let endpoint = match provider {
|
||||
Provider::Github => format!(
|
||||
"{}/repos/{}/{}/pulls",
|
||||
if host == "github.com" && (ssh || port.is_empty() || port == ":443") {
|
||||
"https://api.github.com".into()
|
||||
} else {
|
||||
format!("https://{authority}/api/v3")
|
||||
},
|
||||
encode(parts[0]),
|
||||
encode(parts[1])
|
||||
),
|
||||
Provider::Gitlab => format!(
|
||||
"https://{authority}/api/v4/projects/{}/merge_requests",
|
||||
encode(path)
|
||||
),
|
||||
Provider::Gitea => format!(
|
||||
"https://{authority}/api/v1/repos/{}/{}/pulls",
|
||||
encode(parts[0]),
|
||||
encode(parts[1])
|
||||
),
|
||||
};
|
||||
Ok(Self {
|
||||
provider,
|
||||
host: authority,
|
||||
endpoint,
|
||||
})
|
||||
}
|
||||
|
||||
fn token(&self, root: &Path, cancelled: &AtomicBool) -> Result<Option<String>, String> {
|
||||
let names: &[&str] = match self.provider {
|
||||
Provider::Github if self.host == "github.com" || self.host == "github.com:443" => {
|
||||
&["GH_TOKEN", "GITHUB_TOKEN"]
|
||||
}
|
||||
Provider::Github => &["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"],
|
||||
Provider::Gitlab => &["GITLAB_TOKEN"],
|
||||
Provider::Gitea => &["GITEA_TOKEN"],
|
||||
};
|
||||
let mut token = None;
|
||||
for name in names {
|
||||
match std::env::var(name) {
|
||||
Ok(value) if !value.is_empty() => {
|
||||
token = Some(value);
|
||||
break;
|
||||
}
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
return Err("Forge token is not valid UTF-8".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if token.is_none() && self.provider != Provider::Gitea {
|
||||
let mut command = Command::new(if self.provider == Provider::Github {
|
||||
"gh"
|
||||
} else {
|
||||
"glab"
|
||||
});
|
||||
let host = if self.provider == Provider::Github && self.host == "github.com:443" {
|
||||
"github.com"
|
||||
} else {
|
||||
&self.host
|
||||
};
|
||||
command
|
||||
.current_dir(root)
|
||||
.args(["auth", "token", "--hostname", host]);
|
||||
match run(command, Vec::new(), cancelled) {
|
||||
Ok(output) => {
|
||||
if let Ok(bytes) = checked(output) {
|
||||
token = Some(
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|_| "Stored forge token is not valid UTF-8")?
|
||||
.trim()
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error)
|
||||
if cancelled.load(Ordering::Acquire)
|
||||
|| !error.starts_with("Cannot start repository command:") =>
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
token
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|token| {
|
||||
if token.bytes().all(|b| b.is_ascii_graphic()) {
|
||||
Ok(token)
|
||||
} else {
|
||||
Err("Forge token contains invalid header characters".into())
|
||||
}
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn get(
|
||||
&self,
|
||||
url: &str,
|
||||
token: Option<&str>,
|
||||
root: &Path,
|
||||
cancelled: &AtomicBool,
|
||||
) -> Result<Value, String> {
|
||||
let mut headers = String::from("Accept: application/json\n");
|
||||
if let Some(token) = token {
|
||||
let prefix = match self.provider {
|
||||
Provider::Github => "Authorization: Bearer",
|
||||
Provider::Gitlab => "PRIVATE-TOKEN:",
|
||||
Provider::Gitea => "Authorization: token",
|
||||
};
|
||||
headers.push_str(&format!("{prefix} {token}\n"));
|
||||
}
|
||||
// Ignore curlrc; credentials only travel on stdin. Never follow Location or API-provided URLs.
|
||||
let mut command = Command::new("curl");
|
||||
command.current_dir(root).args([
|
||||
"--disable",
|
||||
"--silent",
|
||||
"--globoff",
|
||||
"--proto",
|
||||
"=https",
|
||||
"--connect-timeout",
|
||||
"15",
|
||||
"--max-time",
|
||||
"55",
|
||||
"--max-redirs",
|
||||
"0",
|
||||
"--user-agent",
|
||||
"vamp",
|
||||
"--header",
|
||||
"@-",
|
||||
"--write-out",
|
||||
"\n%{http_code}",
|
||||
"--url",
|
||||
url,
|
||||
]);
|
||||
let bytes = checked(run(command, headers.into_bytes(), cancelled)?).map_err(
|
||||
|_| "Forge API request failed; check curl, network access, and authentication",
|
||||
)?;
|
||||
let split = bytes
|
||||
.iter()
|
||||
.rposition(|b| *b == b'\n')
|
||||
.ok_or("Invalid forge HTTP response")?;
|
||||
let status = &bytes[split + 1..];
|
||||
if status != b"200" {
|
||||
let code = std::str::from_utf8(status)
|
||||
.ok()
|
||||
.filter(|s| s.len() == 3 && s.bytes().all(|b| b.is_ascii_digit()))
|
||||
.unwrap_or("unknown");
|
||||
return Err(format!(
|
||||
"Forge API returned HTTP {code}; check remote URL and authentication (redirects are not followed)"
|
||||
));
|
||||
}
|
||||
serde_json::from_slice(&bytes[..split])
|
||||
.map_err(|_| "Forge API returned invalid JSON".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn field<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| format!("Forge API returned invalid {key}"))
|
||||
}
|
||||
fn number(value: &Value, key: &str) -> Result<u64, String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_u64)
|
||||
.filter(|n| *n > 0)
|
||||
.ok_or_else(|| format!("Forge API returned invalid {key}"))
|
||||
}
|
||||
fn sha(value: &Value, key: &str) -> Result<String, String> {
|
||||
let sha = field(value, key)?;
|
||||
if matches!(sha.len(), 40 | 64)
|
||||
&& sha.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
&& sha.bytes().any(|b| b != b'0')
|
||||
{
|
||||
Ok(sha.to_ascii_lowercase())
|
||||
} else {
|
||||
Err(format!("Forge API returned invalid {key} commit ID"))
|
||||
}
|
||||
}
|
||||
fn branch<'a>(value: &'a Value, key: &str) -> Result<&'a str, String> {
|
||||
let branch = field(value, key)?;
|
||||
if branch.is_empty()
|
||||
|| branch.contains(['~', '^', ':', '?', '*', '[', '\\'])
|
||||
|| branch.contains("..")
|
||||
|| branch.contains("@{")
|
||||
|| branch.bytes().any(|b| b <= b' ' || b == 127)
|
||||
|| branch
|
||||
.split('/')
|
||||
.any(|p| p.is_empty() || p.starts_with('.') || p.ends_with('.') || p.ends_with(".lock"))
|
||||
{
|
||||
Err(format!("Forge API returned invalid {key} branch"))
|
||||
} else {
|
||||
Ok(branch)
|
||||
}
|
||||
}
|
||||
|
||||
fn pull_request(
|
||||
provider: Provider,
|
||||
value: &Value,
|
||||
preserved_head: Option<String>,
|
||||
) -> Result<PullRequest, String> {
|
||||
let gitlab = provider == Provider::Gitlab;
|
||||
let number = number(value, if gitlab { "iid" } else { "number" })?;
|
||||
let state = field(value, "state")?;
|
||||
let closed = match (gitlab, state) {
|
||||
(false, "open") | (true, "opened" | "locked") => false,
|
||||
(false, "closed") | (true, "closed" | "merged") => true,
|
||||
_ => return Err("Forge API returned invalid pull request state".into()),
|
||||
};
|
||||
let merged = match provider {
|
||||
Provider::Github => match value.get("merged_at") {
|
||||
Some(Value::Null) => false,
|
||||
Some(Value::String(date)) if !date.is_empty() => true,
|
||||
_ => return Err("Forge API returned invalid merged_at".into()),
|
||||
},
|
||||
Provider::Gitlab => state == "merged",
|
||||
Provider::Gitea => value
|
||||
.get("merged")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or("Forge API returned invalid merged flag")?,
|
||||
};
|
||||
if merged && !closed {
|
||||
return Err("Forge API returned inconsistent merged state".into());
|
||||
}
|
||||
let revisions = (|| -> Result<_, String> {
|
||||
let (base, head, base_sha, head_sha) = if gitlab {
|
||||
let refs = value.get("diff_refs").filter(|v| v.is_object()).ok_or(
|
||||
"GitLab merge request has no prepared diff_refs; retry after GitLab prepares it",
|
||||
)?;
|
||||
// Legacy MRs omit the target tip; their recorded merge base yields the same PR diff.
|
||||
(
|
||||
branch(value, "target_branch")?,
|
||||
branch(value, "source_branch")?,
|
||||
sha(
|
||||
refs,
|
||||
if refs.get("start_sha").is_none_or(Value::is_null) {
|
||||
"base_sha"
|
||||
} else {
|
||||
"start_sha"
|
||||
},
|
||||
)?,
|
||||
sha(refs, "head_sha")?,
|
||||
)
|
||||
} else {
|
||||
let base = value
|
||||
.get("base")
|
||||
.ok_or("Forge API returned no base branch")?;
|
||||
let head = value
|
||||
.get("head")
|
||||
.ok_or("Forge API returned no head branch")?;
|
||||
// Gitea base.sha follows the live target branch even after merging.
|
||||
let base_sha = if provider == Provider::Gitea {
|
||||
sha(value, "merge_base")?
|
||||
} else {
|
||||
sha(base, "sha")?
|
||||
};
|
||||
let head_key = if provider == Provider::Gitea
|
||||
&& head
|
||||
.get("label")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| !s.is_empty())
|
||||
{
|
||||
"label"
|
||||
} else {
|
||||
"ref"
|
||||
};
|
||||
(
|
||||
branch(base, "ref")?,
|
||||
if provider == Provider::Gitea {
|
||||
field(head, head_key)?
|
||||
} else {
|
||||
branch(head, head_key)?
|
||||
},
|
||||
base_sha,
|
||||
if provider == Provider::Gitea {
|
||||
preserved_head.ok_or_else(|| {
|
||||
format!("Gitea pull request #{number} has no preserved head reference")
|
||||
})?
|
||||
} else {
|
||||
sha(head, "sha")?
|
||||
},
|
||||
)
|
||||
};
|
||||
Ok((
|
||||
PullRequestRevisions {
|
||||
base_ref: format!("refs/heads/{base}"),
|
||||
base_sha,
|
||||
head_ref: format!(
|
||||
"refs/{}/{number}/head",
|
||||
if gitlab { "merge-requests" } else { "pull" }
|
||||
),
|
||||
head_sha,
|
||||
},
|
||||
base,
|
||||
head,
|
||||
))
|
||||
})();
|
||||
let author = value
|
||||
.get(if gitlab { "author" } else { "user" })
|
||||
.ok_or("Forge API returned no author")?;
|
||||
let author = if author.is_null() {
|
||||
"deleted user"
|
||||
} else {
|
||||
field(author, if gitlab { "username" } else { "login" })?
|
||||
};
|
||||
let state = if merged {
|
||||
"merged"
|
||||
} else if closed {
|
||||
"closed"
|
||||
} else {
|
||||
"open"
|
||||
};
|
||||
Ok(PullRequest {
|
||||
number,
|
||||
title: field(value, "title")?.to_owned(),
|
||||
description: match &revisions {
|
||||
Ok((_, base, head)) => format!("{author} · {state} · {head} → {base}"),
|
||||
Err(error) => format!("{author} · {state} · unavailable: {error}"),
|
||||
},
|
||||
closed,
|
||||
revisions: revisions.map(|(revisions, _, _)| revisions),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn list(
|
||||
url: &str,
|
||||
provider: Option<&str>,
|
||||
root: &Path,
|
||||
cancelled: &AtomicBool,
|
||||
) -> Result<Vec<PullRequest>, String> {
|
||||
let forge = Forge::parse(url, provider)?;
|
||||
let token = forge.token(root, cancelled)?;
|
||||
let mut gitea_heads = BTreeMap::new();
|
||||
let query = match forge.provider {
|
||||
Provider::Github => "state=all&per_page=100&sort=created&direction=asc",
|
||||
Provider::Gitlab => "state=all&scope=all&per_page=100&order_by=created_at&sort=asc",
|
||||
Provider::Gitea => "state=all&limit=100&sort=oldest",
|
||||
};
|
||||
let mut requests = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for page in 1u64.. {
|
||||
let values = forge.get(
|
||||
&format!("{}?{query}&page={page}", forge.endpoint),
|
||||
token.as_deref(),
|
||||
root,
|
||||
cancelled,
|
||||
)?;
|
||||
let values = values
|
||||
.as_array()
|
||||
.ok_or("Forge API returned a non-array pull request list")?;
|
||||
// A server may cap the requested page size. Only an empty page proves exhaustion.
|
||||
if values.is_empty() {
|
||||
return Ok(requests);
|
||||
}
|
||||
if page == 1 && forge.provider == Provider::Gitea {
|
||||
let repo = forge.endpoint.strip_suffix("/pulls").unwrap();
|
||||
let refs = forge.get(
|
||||
&format!("{repo}/git/refs/pull"),
|
||||
token.as_deref(),
|
||||
root,
|
||||
cancelled,
|
||||
)?;
|
||||
for reference in refs
|
||||
.as_array()
|
||||
.ok_or("Gitea API returned a non-array reference list")?
|
||||
{
|
||||
let name = field(reference, "ref")?;
|
||||
if let Some(id) = name
|
||||
.strip_prefix("refs/pull/")
|
||||
.and_then(|s| s.strip_suffix("/head"))
|
||||
{
|
||||
let id = id
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|n| *n > 0)
|
||||
.ok_or("Gitea API returned an invalid pull request reference")?;
|
||||
let object = reference
|
||||
.get("object")
|
||||
.ok_or("Gitea API returned no reference object")?;
|
||||
if field(object, "type")? != "commit"
|
||||
|| gitea_heads.insert(id, sha(object, "sha")?).is_some()
|
||||
{
|
||||
return Err(
|
||||
"Gitea API returned an invalid or repeated pull request head".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for value in values {
|
||||
let id = number(
|
||||
value,
|
||||
if forge.provider == Provider::Gitlab {
|
||||
"iid"
|
||||
} else {
|
||||
"number"
|
||||
},
|
||||
)?;
|
||||
if !seen.insert(id) {
|
||||
return Err(
|
||||
"Forge API repeated a pull request while paginating; retry the list".into(),
|
||||
);
|
||||
}
|
||||
let detail;
|
||||
// ponytail: GitLab needs one detail read per MR; batch if large histories become slow.
|
||||
let value = if forge.provider == Provider::Gitlab
|
||||
&& value.get("diff_refs").is_none_or(Value::is_null)
|
||||
{
|
||||
detail = forge.get(
|
||||
&format!("{}/{id}", forge.endpoint),
|
||||
token.as_deref(),
|
||||
root,
|
||||
cancelled,
|
||||
)?;
|
||||
if number(&detail, "iid")? != id {
|
||||
return Err("GitLab API returned a different merge request".into());
|
||||
}
|
||||
&detail
|
||||
} else {
|
||||
value
|
||||
};
|
||||
let preserved_head = if forge.provider == Provider::Gitea {
|
||||
gitea_heads.remove(&id)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
requests.push(pull_request(forge.provider, value, preserved_head)?);
|
||||
}
|
||||
}
|
||||
Err("Forge pagination overflow".into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn remote_urls_preserve_namespaces_and_keep_ssh_ports_out_of_https() {
|
||||
let gitlab =
|
||||
Forge::parse("ssh://git@gitlab.example.org:2222/group/sub/repo.git", None).unwrap();
|
||||
assert_eq!(
|
||||
gitlab.endpoint,
|
||||
"https://gitlab.example.org/api/v4/projects/group%2Fsub%2Frepo/merge_requests"
|
||||
);
|
||||
assert_eq!(
|
||||
Forge::parse("git@github.com:owner/repo.git", None)
|
||||
.unwrap()
|
||||
.endpoint,
|
||||
"https://api.github.com/repos/owner/repo/pulls"
|
||||
);
|
||||
assert_eq!(
|
||||
Forge::parse(
|
||||
"https://user:secret@forge.example:8443/owner/repo.git",
|
||||
Some("gitea")
|
||||
)
|
||||
.unwrap()
|
||||
.endpoint,
|
||||
"https://forge.example:8443/api/v1/repos/owner/repo/pulls"
|
||||
);
|
||||
assert!(
|
||||
Forge::parse("https://forge.example/owner/repo", None)
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("git config remote.<name>.vamp-forge github")
|
||||
);
|
||||
for url in [
|
||||
"https://github.com/owner/repo?token=secret",
|
||||
"https://github.com/owner/%2e%2e",
|
||||
"https://github.com/owner/repo%0a",
|
||||
"https://github.com:0/owner/repo",
|
||||
"file:///owner/repo",
|
||||
] {
|
||||
assert!(Forge::parse(url, Some("github")).is_err(), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_pull_requests_pin_historical_shas_not_merge_commits_or_response_urls() {
|
||||
let value = json!({"number": 7, "title": "Merged fork", "state": "closed", "merged_at": "2026-01-01T00:00:00Z", "merged": true,
|
||||
"user": {"login": "author"}, "base": {"ref": "release/next", "sha": "1".repeat(40)},
|
||||
"head": {"ref": "feature", "label": "fork:topic", "sha": "2".repeat(40), "repo": null}, "merge_base": "4".repeat(40), "merge_commit_sha": "3".repeat(40), "url": "https://evil.example/token"});
|
||||
for provider in [Provider::Github, Provider::Gitea] {
|
||||
let pr = pull_request(provider, &value, Some("5".repeat(40))).unwrap();
|
||||
let revisions = pr.revisions.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(revisions.base_ref.as_str(), revisions.head_ref.as_str()),
|
||||
("refs/heads/release/next", "refs/pull/7/head")
|
||||
);
|
||||
assert_eq!(
|
||||
revisions.base_sha,
|
||||
if provider == Provider::Gitea {
|
||||
"4"
|
||||
} else {
|
||||
"1"
|
||||
}
|
||||
.repeat(40)
|
||||
);
|
||||
assert_eq!(
|
||||
revisions.head_sha,
|
||||
if provider == Provider::Gitea {
|
||||
"5"
|
||||
} else {
|
||||
"2"
|
||||
}
|
||||
.repeat(40)
|
||||
);
|
||||
assert!(pr.closed && pr.description.contains("merged"));
|
||||
if provider == Provider::Gitea {
|
||||
assert!(pr.description.contains("fork:topic"));
|
||||
let mut deleted = value.clone();
|
||||
deleted["head"]["sha"] = Value::String(String::new());
|
||||
assert_eq!(
|
||||
pull_request(provider, &deleted, Some("5".repeat(40)))
|
||||
.unwrap()
|
||||
.revisions
|
||||
.unwrap()
|
||||
.head_sha,
|
||||
"5".repeat(40)
|
||||
);
|
||||
let unavailable = pull_request(provider, &deleted, None).unwrap();
|
||||
assert_eq!(unavailable.number, 7);
|
||||
assert!(unavailable.closed);
|
||||
assert!(unavailable.description.contains("unavailable"));
|
||||
assert!(unavailable.revisions.is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitlab_uses_target_start_sha_and_rejects_unprepared_or_unsafe_metadata() {
|
||||
let mut value = json!({"iid": 9, "title": "MR", "state": "merged", "author": {"username": "author"},
|
||||
"target_branch": "main", "source_branch": "topic", "diff_refs": {"base_sha": "1".repeat(40), "start_sha": "2".repeat(40), "head_sha": "3".repeat(40)}});
|
||||
let pr = pull_request(Provider::Gitlab, &value, None).unwrap();
|
||||
let revisions = pr.revisions.unwrap();
|
||||
assert_eq!(revisions.base_sha, "2".repeat(40));
|
||||
assert_eq!(revisions.head_ref, "refs/merge-requests/9/head");
|
||||
value["diff_refs"]["start_sha"] = Value::Null;
|
||||
assert_eq!(
|
||||
pull_request(Provider::Gitlab, &value, None)
|
||||
.unwrap()
|
||||
.revisions
|
||||
.unwrap()
|
||||
.base_sha,
|
||||
"1".repeat(40)
|
||||
);
|
||||
value["target_branch"] = json!("main:refs/heads/injected");
|
||||
assert!(
|
||||
pull_request(Provider::Gitlab, &value, None)
|
||||
.unwrap()
|
||||
.revisions
|
||||
.is_err()
|
||||
);
|
||||
value["target_branch"] = json!("main");
|
||||
value["diff_refs"]["head_sha"] = json!("HEAD");
|
||||
assert!(
|
||||
pull_request(Provider::Gitlab, &value, None)
|
||||
.unwrap()
|
||||
.revisions
|
||||
.is_err()
|
||||
);
|
||||
value["diff_refs"] = Value::Null;
|
||||
assert!(
|
||||
pull_request(Provider::Gitlab, &value, None)
|
||||
.unwrap()
|
||||
.revisions
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod clipboard;
|
||||
pub mod diff;
|
||||
pub mod editor;
|
||||
pub mod files;
|
||||
mod forge;
|
||||
pub mod gutter;
|
||||
pub mod lsp;
|
||||
mod lsp_config;
|
||||
|
||||
@@ -81,6 +81,8 @@ Diff: :diff (Normal); choose Before then After; Working checkout After edits liv
|
||||
Esc closes Before/After selectors; :diff.close exits an opened comparison explicitly
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/All tabs, type to search
|
||||
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
|
||||
|
||||
Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb
|
||||
Set VAMP_RUBY to choose the Ruby executable.
|
||||
|
||||
+123
-5
@@ -54,7 +54,7 @@ impl Drop for Temporary {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessOutput {
|
||||
pub(crate) struct ProcessOutput {
|
||||
code: Option<i32>,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
@@ -97,7 +97,7 @@ fn terminate(child: &mut std::process::Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
fn run(
|
||||
pub(crate) fn run(
|
||||
mut command: Command,
|
||||
input: Vec<u8>,
|
||||
cancelled: &AtomicBool,
|
||||
@@ -200,7 +200,7 @@ fn run(
|
||||
})
|
||||
}
|
||||
}
|
||||
fn checked(output: ProcessOutput) -> Result<Vec<u8>, String> {
|
||||
pub(crate) fn checked(output: ProcessOutput) -> Result<Vec<u8>, String> {
|
||||
if output.code == Some(0) {
|
||||
Ok(output.stdout)
|
||||
} else {
|
||||
@@ -1535,9 +1535,69 @@ impl Repository {
|
||||
self.load_remote(remote, None)?;
|
||||
self.catalog()
|
||||
}
|
||||
pub fn pull_requests(&mut self, name: &str) -> Result<Catalog, String> {
|
||||
self.discover_remotes()?;
|
||||
let remote = if name.is_empty() {
|
||||
self.remotes
|
||||
.iter()
|
||||
.find(|remote| remote.name == "origin")
|
||||
.or_else(|| (self.remotes.len() == 1).then(|| &self.remotes[0]))
|
||||
.ok_or("Choose a remote with :pull_requests <remote>")?
|
||||
} else {
|
||||
self.remotes
|
||||
.iter()
|
||||
.find(|remote| remote.name == name)
|
||||
.ok_or_else(|| format!("Unknown configured remote: {name}"))?
|
||||
};
|
||||
let mut config = git_command(&self.store);
|
||||
config.args([
|
||||
"config",
|
||||
"--get",
|
||||
&format!("remote.{}.vamp-forge", remote.name),
|
||||
]);
|
||||
let output = run(config, vec![], &self.cancelled)?;
|
||||
let provider = if output.code == Some(1) {
|
||||
None
|
||||
} else {
|
||||
Some(text(checked(output)?)?.trim().to_owned())
|
||||
};
|
||||
let pulls = crate::forge::list(
|
||||
&remote.url,
|
||||
provider.as_deref(),
|
||||
&self.root,
|
||||
&self.cancelled,
|
||||
)
|
||||
.map_err(|error| error.replace("remote.<name>", &format!("remote.{}", remote.name)))?;
|
||||
Ok(Catalog {
|
||||
kind: self.kind,
|
||||
notice: Some(format!("Pull requests from {}", remote.name)),
|
||||
entries: pulls
|
||||
.into_iter()
|
||||
.map(|pull| Revision {
|
||||
target: match pull.revisions {
|
||||
Ok(revisions) => RevisionTarget::PullRequest {
|
||||
remote: remote.name.clone(),
|
||||
base_ref: revisions.base_ref,
|
||||
base_sha: revisions.base_sha,
|
||||
head_ref: revisions.head_ref,
|
||||
head_sha: revisions.head_sha,
|
||||
},
|
||||
Err(error) => RevisionTarget::UnavailablePullRequest(error),
|
||||
},
|
||||
label: format!("#{} {}", pull.number, pull.title),
|
||||
description: pull.description,
|
||||
category: if pull.closed {
|
||||
Category::Closed
|
||||
} else {
|
||||
Category::Open
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
fn resolve(&mut self, target: &RevisionTarget) -> Result<(PathBuf, String), String> {
|
||||
let id = match target {
|
||||
RevisionTarget::Commit(id) => {
|
||||
RevisionTarget::Commit(id) | RevisionTarget::RemoteCommit { id, .. } => {
|
||||
if !matches!(id.len(), 40 | 64)
|
||||
|| (self.kind == RepoKind::Jujutsu && id.len() != 40)
|
||||
|| !id.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
@@ -1560,7 +1620,10 @@ impl Repository {
|
||||
})?
|
||||
.clone(),
|
||||
RevisionTarget::Working => return Err("Working checkout is not a commit".into()),
|
||||
RevisionTarget::FetchRemote(_) | RevisionTarget::ListRemote(_) => {
|
||||
RevisionTarget::UnavailablePullRequest(error) => return Err(error.clone()),
|
||||
RevisionTarget::FetchRemote(_)
|
||||
| RevisionTarget::ListRemote(_)
|
||||
| RevisionTarget::PullRequest { .. } => {
|
||||
return Err("Select a revision, not a history loader".into());
|
||||
}
|
||||
};
|
||||
@@ -1573,6 +1636,61 @@ impl Repository {
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
if let RevisionTarget::RemoteCommit {
|
||||
remote, reference, ..
|
||||
} = target
|
||||
{
|
||||
if !reference.starts_with("refs/") {
|
||||
return Err("Invalid pull request reference".into());
|
||||
}
|
||||
self.git(&self.store, &["check-ref-format", reference])?;
|
||||
self.discover_remotes()?;
|
||||
let url = self
|
||||
.remotes
|
||||
.iter()
|
||||
.find(|entry| &entry.name == remote)
|
||||
.ok_or_else(|| format!("Unknown configured remote: {remote}"))?
|
||||
.url
|
||||
.clone();
|
||||
self.ensure_private()?;
|
||||
let store = &self.private.as_ref().unwrap().1;
|
||||
// Closed PR refs can disappear; servers may still serve their immutable commits.
|
||||
if self
|
||||
.git(
|
||||
store,
|
||||
&[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
"--",
|
||||
&url,
|
||||
&format!("+{id}:refs/vamp-pull-request/selected"),
|
||||
],
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
self.git(
|
||||
store,
|
||||
&[
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
"--",
|
||||
&url,
|
||||
&format!("+{reference}:refs/vamp-pull-request/selected"),
|
||||
],
|
||||
)
|
||||
.map_err(|error| {
|
||||
format!("Pull request commit unavailable; reopen :pull_requests: {error}")
|
||||
})?;
|
||||
}
|
||||
if let Some(resolved) = self.resolve_stored(&id)? {
|
||||
return Ok(resolved);
|
||||
}
|
||||
return Err(
|
||||
"Pull request changed or its commit is unavailable; reopen :pull_requests".into(),
|
||||
);
|
||||
}
|
||||
Err(format!(
|
||||
"Commit {id} is unavailable locally or in loaded remote history"
|
||||
))
|
||||
|
||||
@@ -70,6 +70,9 @@ impl DiffWorker {
|
||||
DiffOperation::Discover => {
|
||||
repository.catalog().map(DiffOutcome::Catalog)
|
||||
}
|
||||
DiffOperation::PullRequests(remote) => {
|
||||
repository.pull_requests(&remote).map(DiffOutcome::Catalog)
|
||||
}
|
||||
DiffOperation::ListRemote(remote) => {
|
||||
repository.list_remote(&remote).map(DiffOutcome::Catalog)
|
||||
}
|
||||
|
||||
+93
-18
@@ -2594,6 +2594,7 @@ fn draw_diff(
|
||||
return;
|
||||
}
|
||||
if diff.phase != Phase::View {
|
||||
let pull_requests = diff.pull_request_remote.is_some();
|
||||
let panel = floating_panel(area);
|
||||
frame.render_widget(Clear, panel);
|
||||
let stage = if diff.phase == Phase::Before {
|
||||
@@ -2601,12 +2602,18 @@ fn draw_diff(
|
||||
} else {
|
||||
"After"
|
||||
};
|
||||
let title = format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label());
|
||||
let title = if pull_requests {
|
||||
" Pull requests ".into()
|
||||
} else {
|
||||
format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label())
|
||||
};
|
||||
let content = diff_panel(frame, panel, &title, "", "", true);
|
||||
let notice = diff.error.as_deref().or_else(|| {
|
||||
if diff.loading {
|
||||
Some(if diff.after.is_some() {
|
||||
"Loading comparison…"
|
||||
} else if pull_requests {
|
||||
"Loading pull requests…"
|
||||
} else {
|
||||
"Loading repository…"
|
||||
})
|
||||
@@ -2629,7 +2636,7 @@ fn draw_diff(
|
||||
Constraint::Length(u16::from(content.height >= 4)),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(u16::from(notice.is_some() && content.height >= 3)),
|
||||
Constraint::Length(u16::from(content.height >= 2)),
|
||||
Constraint::Length(u16::from(!pull_requests && content.height >= 2)),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(u16::from(content.height >= 7)),
|
||||
])
|
||||
@@ -2638,17 +2645,18 @@ fn draw_diff(
|
||||
.before
|
||||
.as_ref()
|
||||
.map_or("not selected", |revision| revision.label.as_str());
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
let stage_text = if pull_requests {
|
||||
"Select a pull request to compare its branch changes".into()
|
||||
} else {
|
||||
format!(
|
||||
"{} · Ctrl+b toggle · Before: {}",
|
||||
diff.comparison_mode.label(),
|
||||
display_text(before)
|
||||
)),
|
||||
stage_area,
|
||||
);
|
||||
if let Some(catalog) = &diff.catalog {
|
||||
let tabs: Vec<Span> = catalog
|
||||
.kind
|
||||
)
|
||||
};
|
||||
frame.render_widget(Paragraph::new(stage_text), stage_area);
|
||||
if pull_requests || diff.catalog.is_some() {
|
||||
let tabs: Vec<Span> = diff
|
||||
.categories()
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -2696,7 +2704,7 @@ fn draw_diff(
|
||||
);
|
||||
}
|
||||
let mut entries = diff.entries();
|
||||
if let Some(working) = entries.next() {
|
||||
if !pull_requests && let Some(working) = entries.next() {
|
||||
let label = format!(
|
||||
"{} {} · pinned",
|
||||
if diff.selected == 0 { ">" } else { " " },
|
||||
@@ -2721,6 +2729,7 @@ fn draw_diff(
|
||||
1
|
||||
};
|
||||
let count = usize::from(results.height) / row_height;
|
||||
let pinned = usize::from(!pull_requests);
|
||||
let mut key = std::collections::hash_map::DefaultHasher::new();
|
||||
(
|
||||
diff.phase as u8,
|
||||
@@ -2735,17 +2744,16 @@ fn draw_diff(
|
||||
let first = PickerViewport::follow(
|
||||
&mut panels.picker_scroll.revisions,
|
||||
key.finish(),
|
||||
diff.selected.saturating_sub(1),
|
||||
diff.selected.saturating_sub(pinned),
|
||||
usize::from(results.height),
|
||||
row_height,
|
||||
panels.picker_scroll.distance,
|
||||
)
|
||||
.saturating_add(1);
|
||||
);
|
||||
let mut lines = Vec::with_capacity(count * row_height);
|
||||
for (index, revision) in entries
|
||||
.enumerate()
|
||||
.map(|(index, revision)| (index + 1, revision))
|
||||
.skip(first - 1)
|
||||
.skip(first)
|
||||
.map(|(index, revision)| (index + pinned, revision))
|
||||
.take(count)
|
||||
{
|
||||
let style = if index == diff.selected {
|
||||
@@ -2783,13 +2791,23 @@ fn draw_diff(
|
||||
}
|
||||
}
|
||||
if lines.is_empty() && !diff.loading {
|
||||
frame.render_widget(Paragraph::new("No matching revisions"), results);
|
||||
let empty = if pull_requests {
|
||||
"No matching pull requests"
|
||||
} else {
|
||||
"No matching revisions"
|
||||
};
|
||||
frame.render_widget(Paragraph::new(empty), results);
|
||||
} else {
|
||||
frame.render_widget(Paragraph::new(lines), results);
|
||||
}
|
||||
let accept = if diff.error.is_some() {
|
||||
"retry"
|
||||
} else {
|
||||
"select"
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"{navigation_hint} · Tab categories · type search · Enter select · Esc close · Ctrl+p actions"
|
||||
"{navigation_hint} · Tab categories · type search · Enter {accept} · Esc close · Ctrl+p actions"
|
||||
))
|
||||
.style(Style::default().fg(Color::DarkGray)),
|
||||
help,
|
||||
@@ -6719,6 +6737,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
|
||||
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
|
||||
for (width, height) in [(100, 16), (44, 12)] {
|
||||
let mut diff = DiffState::pull_requests("origin".into());
|
||||
diff.take_request();
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("Loading pull requests"));
|
||||
diff.apply_result(1, Err("offline".into()));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("offline"));
|
||||
diff.handle(Input::Enter);
|
||||
diff.take_request();
|
||||
diff.apply_result(
|
||||
2,
|
||||
Ok(crate::diff::Outcome::Catalog(Catalog {
|
||||
kind: RepoKind::Git,
|
||||
entries: (0..20)
|
||||
.map(|index| Revision {
|
||||
target: RevisionTarget::PullRequest {
|
||||
remote: "origin".into(),
|
||||
base_ref: "refs/heads/main".into(),
|
||||
base_sha: "base".into(),
|
||||
head_ref: format!("refs/pull/{index}/head"),
|
||||
head_sha: format!("head-{index}"),
|
||||
},
|
||||
label: format!("#{index:02} Pull request"),
|
||||
description: "open · main ← topic".into(),
|
||||
category: Category::Open,
|
||||
})
|
||||
.collect(),
|
||||
notice: None,
|
||||
})),
|
||||
);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Pull requests"));
|
||||
assert!(text.contains("Open") && text.contains("Closed") && text.contains("All"));
|
||||
assert!(text.contains("> #00 Pull request"));
|
||||
assert!(!text.contains("pinned"));
|
||||
diff.handle(Input::End);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> #19 Pull request"));
|
||||
assert!(!text.contains("#00 Pull request"));
|
||||
diff.handle(Input::Home);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("> #00 Pull request"));
|
||||
diff.handle(Input::Char('\t'));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("No matching pull requests"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() {
|
||||
let before: Vec<_> = (0..80)
|
||||
|
||||
Reference in New Issue
Block a user