diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 528c609b59b9bb..9b6dc012cb051f 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -35,6 +35,7 @@ fuzzy.workspace = true fuzzy_nucleo.workspace = true git.workspace = true gpui.workspace = true +http_client.workspace = true itertools.workspace = true language.workspace = true language_model.workspace = true @@ -55,6 +56,7 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true smallvec.workspace = true +smol.workspace = true strum.workspace = true sysinfo.workspace = true task.workspace = true diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index e33871266ef621..8a0e7e806ad407 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -48,6 +48,7 @@ pub mod git_picker; mod git_runtime_diagnostics; pub mod multi_diff_view; pub mod picker_prompt; +pub mod pr_review; pub mod project_diff; pub(crate) mod remote_output; pub mod repository_selector; @@ -109,6 +110,7 @@ pub fn init(cx: &mut App) { staged_diff::StagedDiff::register(workspace, cx); unstaged_diff::UnstagedDiff::register(workspace, cx); branch_diff::BranchDiff::register(workspace, cx); + pr_review::register(workspace, cx); CommitModal::register(workspace); git_panel::register(workspace); repository_selector::register(workspace); diff --git a/crates/git_ui/src/pr_review.rs b/crates/git_ui/src/pr_review.rs new file mode 100644 index 00000000000000..b3ef65dee43c42 --- /dev/null +++ b/crates/git_ui/src/pr_review.rs @@ -0,0 +1,826 @@ +//! A read-and-reply GitHub pull request comments pane. The command +//! `review: open pr comments` opens the pull request's comment threads in a +//! split beside the current view. It resolves the pull request from the current +//! branch (or a pasted URL), and replies/comments are posted to GitHub using the +//! user's token. + +use anyhow::{Context as _, Result, bail}; +use collections::HashMap; +use futures::AsyncReadExt as _; +use gpui::{ + App, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity, Window, +}; +use http_client::{AsyncBody, HttpClient, HttpRequestExt as _, Request}; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use std::sync::Arc; +use std::time::Duration; +use theme::ActiveTheme as _; +use ui::{Avatar, Button, Color, Icon, IconName, Label, LabelCommon as _, SharedString, prelude::*}; +use util::ResultExt as _; +use workspace::{Item, SplitDirection, Workspace}; + +use editor::{Editor, EditorEvent}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +/// Open the pull request's comment threads beside the current view. +#[derive(Clone, Default, PartialEq, gpui::Action)] +#[action(namespace = review)] +pub struct OpenPrComments; + +pub fn register(workspace: &mut Workspace, _cx: &mut Context) { + workspace.register_action( + |workspace, _: &OpenPrComments, window, cx: &mut Context| { + let branch_target = detect_branch_target(workspace, cx); + let view = cx.new(|cx| PrCommentsView::new(branch_target, window, cx)); + workspace.split_item(SplitDirection::Right, Box::new(view), window, cx); + }, + ); +} + +#[derive(Clone)] +struct BranchTarget { + host: String, + owner: String, + repo: String, + branch: String, +} + +fn detect_branch_target(workspace: &Workspace, cx: &App) -> Option { + let repository = workspace.project().read(cx).active_repository(cx)?; + let repository = repository.read(cx); + let branch = repository.branch.as_ref()?.name().to_string(); + let remote = repository + .remote_origin_url + .clone() + .or_else(|| repository.remote_upstream_url.clone())?; + let registry = git::GitHostingProviderRegistry::global(cx); + let (provider, parsed) = git::parse_git_remote_url(registry, &remote)?; + Some(BranchTarget { + host: provider.base_url().host_str()?.to_string(), + owner: parsed.owner.to_string(), + repo: parsed.repo.to_string(), + branch, + }) +} + +fn api_host(host: &str) -> String { + if host == "github.com" { + "api.github.com".to_string() + } else { + format!("api.{host}") + } +} + +struct PullRequestRef { + host: String, + owner: String, + repo: String, + number: u64, +} + +fn parse_pull_request_url(input: &str) -> Option { + let without_scheme = input + .trim() + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(input.trim()); + let segments: Vec<&str> = without_scheme + .split('/') + .filter(|segment| !segment.is_empty()) + .collect(); + let pull_index = segments + .iter() + .position(|segment| *segment == "pull" || *segment == "pulls")?; + let owner = segments.get(pull_index.checked_sub(2)?)?.to_string(); + let repo = segments.get(pull_index - 1)?.to_string(); + let number = segments + .get(pull_index + 1)? + .split(|c: char| !c.is_ascii_digit()) + .next()? + .parse::() + .ok()?; + let host = segments + .first() + .filter(|segment| segment.contains('.')) + .map(|segment| segment.to_string()) + .unwrap_or_else(|| "github.com".to_string()); + Some(PullRequestRef { + host, + owner, + repo, + number, + }) +} + +#[derive(Debug, Default, Clone, Deserialize)] +struct GithubUser { + #[serde(default)] + login: String, + #[serde(default)] + avatar_url: String, +} + +fn login(user: Option<&GithubUser>) -> String { + user.map(|user| user.login.clone()).unwrap_or_default() +} + +#[derive(Debug, Deserialize)] +struct GitRef { + #[serde(default)] + r#ref: String, +} + +#[derive(Debug, Deserialize)] +struct PullRequestResponse { + #[serde(default)] + number: u64, + title: String, + #[serde(default)] + body: Option, + #[serde(default)] + user: Option, + head: GitRef, +} + +#[derive(Debug, Deserialize)] +struct TimelineEvent { + #[serde(default)] + event: String, + #[serde(default)] + user: Option, + #[serde(default)] + body: Option, + #[serde(default)] + created_at: Option, +} + +#[derive(Debug, Deserialize)] +struct ReviewComment { + #[serde(default)] + id: u64, + #[serde(default)] + in_reply_to_id: Option, + #[serde(default)] + path: Option, + #[serde(default)] + line: Option, + #[serde(default)] + body: String, + #[serde(default)] + user: Option, + #[serde(default)] + created_at: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct RepoResponse { + #[serde(default)] + parent: Option, +} + +#[derive(Debug, Deserialize)] +struct ParentRepo { + #[serde(default)] + full_name: String, +} + +fn split_full_name(full_name: &str) -> Option<(String, String)> { + let (owner, repo) = full_name.split_once('/')?; + if owner.is_empty() || repo.is_empty() { + None + } else { + Some((owner.to_string(), repo.to_string())) + } +} + +struct LoadedPullRequest { + host: String, + owner: String, + repo: String, + number: u64, + pull_request: PullRequestResponse, + timeline: Vec, + review_comments: Vec, +} + +enum ViewState { + Loading, + Loaded(Box), + Error(SharedString), +} + +pub struct PrCommentsView { + focus_handle: FocusHandle, + input: Entity, + comment_editor: Entity, + reply_editors: HashMap>, + branch_target: Option, + state: ViewState, + _load_task: Option>, +} + +impl PrCommentsView { + fn new(branch_target: Option, window: &mut Window, cx: &mut Context) -> Self { + let placeholder = match &branch_target { + Some(target) => format!( + "PR for {}/{} @ {} — or paste a URL", + target.owner, target.repo, target.branch + ), + None => "Paste a GitHub pull request URL".to_string(), + }; + let input = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text(&placeholder, window, cx); + editor + }); + let comment_editor = cx.new(|cx| { + let mut editor = Editor::auto_height(1, 6, window, cx); + editor.set_placeholder_text("Add a comment", window, cx); + editor + }); + + let mut this = Self { + focus_handle: cx.focus_handle(), + input, + comment_editor, + reply_editors: HashMap::default(), + branch_target, + state: ViewState::Loading, + _load_task: None, + }; + this.load(window, cx); + this + } + + fn load(&mut self, window: &mut Window, cx: &mut Context) { + let input = self.input.read(cx).text(cx); + let target = match self.resolve_target(&input) { + Ok(target) => target, + Err(error) => { + self.state = ViewState::Error(error.to_string().into()); + cx.notify(); + return; + } + }; + + self.state = ViewState::Loading; + self.reply_editors.clear(); + cx.notify(); + + let http_client = cx.http_client(); + self._load_task = Some(cx.spawn_in(window, async move |this: WeakEntity, cx| { + let result = load_pull_request(target, http_client).await; + this.update_in(cx, |this, window, cx| { + match result { + Ok(loaded) => { + for root in loaded + .review_comments + .iter() + .filter(|comment| comment.in_reply_to_id.is_none()) + { + let editor = cx.new(|cx| { + let mut editor = Editor::auto_height(1, 6, window, cx); + editor.set_placeholder_text("Reply…", window, cx); + editor + }); + this.reply_editors.insert(root.id, editor); + } + this.state = ViewState::Loaded(Box::new(loaded)); + } + Err(error) => this.state = ViewState::Error(error.to_string().into()), + } + cx.notify(); + }) + .ok(); + })); + } + + fn resolve_target(&self, input: &str) -> Result { + if !input.trim().is_empty() { + return parse_pull_request_url(input) + .map(Target::Url) + .context("expected a URL like github.com///pull/"); + } + let branch = self + .branch_target + .clone() + .context("no active branch detected — paste a pull request URL")?; + Ok(Target::Branch(branch)) + } + + fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + if self.input.focus_handle(cx).is_focused(window) { + self.load(window, cx); + } + } + + fn loaded(&self) -> Option<&LoadedPullRequest> { + match &self.state { + ViewState::Loaded(loaded) => Some(loaded), + _ => None, + } + } + + fn repo_url(&self) -> Option { + let loaded = self.loaded()?; + Some(format!( + "https://{}/repos/{}/{}", + api_host(&loaded.host), + loaded.owner, + loaded.repo + )) + } + + fn post_json( + &mut self, + url: String, + payload: String, + editor: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let client = cx.http_client(); + self._load_task = Some(cx.spawn_in(window, async move |this: WeakEntity, cx| { + let token = github_token().await; + let posted = github_post(&url, token.as_deref(), payload, &client) + .await + .log_err() + .is_some(); + this.update_in(cx, |this, window, cx| { + if posted { + editor.update(cx, |editor, cx| editor.clear(window, cx)); + this.load(window, cx); + } + }) + .ok(); + })); + } + + fn submit_comment(&mut self, url: String, editor: Entity, window: &mut Window, cx: &mut Context) { + let body = editor.read(cx).text(cx); + if body.trim().is_empty() { + return; + } + let payload = serde_json::json!({ "body": body }).to_string(); + self.post_json(url, payload, editor, window, cx); + } + + fn submit_pr_comment(&mut self, window: &mut Window, cx: &mut Context) { + let Some(base) = self.repo_url() else { + return; + }; + let number = self.loaded().map(|loaded| loaded.number).unwrap_or_default(); + self.submit_comment( + format!("{base}/issues/{number}/comments"), + self.comment_editor.clone(), + window, + cx, + ); + } + + fn submit_reply(&mut self, root_id: u64, window: &mut Window, cx: &mut Context) { + let Some(base) = self.repo_url() else { + return; + }; + let Some(editor) = self.reply_editors.get(&root_id).cloned() else { + return; + }; + let number = self.loaded().map(|loaded| loaded.number).unwrap_or_default(); + self.submit_comment( + format!("{base}/pulls/{number}/comments/{root_id}/replies"), + editor, + window, + cx, + ); + } +} + +enum Target { + Url(PullRequestRef), + Branch(BranchTarget), +} + +async fn github_token() -> Option { + if let Ok(token) = std::env::var("GITHUB_TOKEN") { + if !token.trim().is_empty() { + return Some(token.trim().to_string()); + } + } + let output = smol::process::Command::new("gh") + .args(["auth", "token"]) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + let token = String::from_utf8(output.stdout).ok()?; + let token = token.trim(); + if token.is_empty() { + None + } else { + Some(token.to_string()) + } +} + +async fn github_send( + url: &str, + token: Option<&str>, + method: &str, + body: AsyncBody, + client: &Arc, +) -> Result> { + let mut request = Request::builder() + .uri(url) + .method(method) + .header("Content-Type", "application/json") + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "Zed") + .follow_redirects(http_client::RedirectPolicy::FollowAll) + .timeout(REQUEST_TIMEOUT); + if let Some(token) = token { + request = request.header("Authorization", format!("Bearer {token}")); + } + let mut response = client + .send(request.body(body)?) + .await + .with_context(|| format!("error requesting {url}"))?; + let mut buffer = Vec::new(); + response.body_mut().read_to_end(&mut buffer).await?; + if !response.status().is_success() { + let text = String::from_utf8_lossy(&buffer); + bail!("GitHub returned {} for {url}: {text}", response.status()); + } + Ok(buffer) +} + +async fn github_get(url: &str, token: Option<&str>, client: &Arc) -> Result> { + github_send(url, token, "GET", AsyncBody::default(), client).await +} + +async fn github_post( + url: &str, + token: Option<&str>, + payload: String, + client: &Arc, +) -> Result<()> { + let token = token.context( + "a GitHub token is required to comment — set GITHUB_TOKEN or run `gh auth login`", + )?; + github_send(url, Some(token), "POST", payload.into(), client).await?; + Ok(()) +} + +async fn github_get_or_default( + url: String, + token: Option<&str>, + client: &Arc, +) -> T { + let Some(body) = github_get(&url, token, client).await.log_err() else { + return T::default(); + }; + serde_json::from_slice(&body).log_err().unwrap_or_default() +} + +async fn resolve_pull_request_ref( + target: Target, + token: Option<&str>, + client: &Arc, +) -> Result { + let branch = match target { + Target::Url(pull_request) => return Ok(pull_request), + Target::Branch(branch) => branch, + }; + + let host = api_host(&branch.host); + let head = format!("{}:{}", branch.owner, branch.branch); + let make_ref = |owner: String, repo: String, number: u64| PullRequestRef { + host: branch.host.clone(), + owner, + repo, + number, + }; + + let on_origin: Vec = github_get_or_default( + format!("https://{host}/repos/{}/{}/pulls?head={head}&state=open", branch.owner, branch.repo), + token, + client, + ) + .await; + if let Some(pull) = on_origin.first() { + return Ok(make_ref(branch.owner, branch.repo, pull.number)); + } + + let repo_meta: RepoResponse = github_get_or_default( + format!("https://{host}/repos/{}/{}", branch.owner, branch.repo), + token, + client, + ) + .await; + if let Some((parent_owner, parent_repo)) = + repo_meta.parent.and_then(|parent| split_full_name(&parent.full_name)) + { + let on_parent: Vec = github_get_or_default( + format!("https://{host}/repos/{parent_owner}/{parent_repo}/pulls?head={head}&state=open"), + token, + client, + ) + .await; + if let Some(pull) = on_parent.first() { + return Ok(make_ref(parent_owner, parent_repo, pull.number)); + } + } + + let open: Vec = github_get_or_default( + format!("https://{host}/repos/{}/{}/pulls?state=open&per_page=100", branch.owner, branch.repo), + token, + client, + ) + .await; + let number = open + .iter() + .find(|pull| pull.head.r#ref == branch.branch) + .map(|pull| pull.number) + .with_context(|| format!("no open pull request found for branch {}", branch.branch))?; + Ok(make_ref(branch.owner, branch.repo, number)) +} + +async fn load_pull_request(target: Target, client: Arc) -> Result { + let token = github_token().await; + let pull_ref = resolve_pull_request_ref(target, token.as_deref(), &client).await?; + let host = api_host(&pull_ref.host); + let repo_url = format!("https://{host}/repos/{}/{}", pull_ref.owner, pull_ref.repo); + let number = pull_ref.number; + + let pull_request: PullRequestResponse = { + let body = github_get(&format!("{repo_url}/pulls/{number}"), token.as_deref(), &client) + .await?; + serde_json::from_slice(&body).context("failed to parse pull request")? + }; + + let (timeline, review_comments) = futures::join!( + github_get_or_default::>( + format!("{repo_url}/issues/{number}/timeline?per_page=100"), + token.as_deref(), + &client, + ), + github_get_or_default::>( + format!("{repo_url}/pulls/{number}/comments?per_page=100"), + token.as_deref(), + &client, + ), + ); + + Ok(LoadedPullRequest { + host: pull_ref.host, + owner: pull_ref.owner, + repo: pull_ref.repo, + number, + pull_request, + timeline, + review_comments, + }) +} + +fn short_date(date: &Option) -> String { + date.as_ref() + .map(|value| value.chars().take(10).collect()) + .unwrap_or_default() +} + +impl PrCommentsView { + fn boxed(&self, cx: &Context) -> Div { + v_flex() + .w_full() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .bg(cx.theme().colors().elevated_surface_background) + } + + fn comment_card( + &self, + user: Option<&GithubUser>, + body: &str, + created_at: &Option, + cx: &Context, + ) -> Div { + let avatar_url = user.map(|user| user.avatar_url.clone()).unwrap_or_default(); + self.boxed(cx) + .child( + h_flex() + .w_full() + .p_2() + .gap_2() + .bg(cx.theme().colors().element_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .when(!avatar_url.is_empty(), |this| { + this.child(Avatar::new(avatar_url).size(px(20.))) + }) + .child(Label::new(login(user))) + .child( + Label::new(format!("commented {}", short_date(created_at))) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .child( + div() + .w_full() + .p_3() + .text_ui(cx) + .child(SharedString::from(body.to_string())), + ) + } + + fn render_comment_thread( + &self, + root: &ReviewComment, + replies: &[&ReviewComment], + cx: &Context, + ) -> impl IntoElement { + let location = match (&root.path, root.line) { + (Some(path), Some(line)) => format!("{path}:{line}"), + (Some(path), None) => path.clone(), + _ => "Review comment".to_string(), + }; + let reply_editor = self.reply_editors.get(&root.id).cloned(); + let root_id = root.id; + + self.boxed(cx) + .mt_2() + .child( + div() + .w_full() + .px_3() + .py_1() + .bg(cx.theme().colors().element_background) + .child(Label::new(location).buffer_font(cx).size(LabelSize::Small)), + ) + .child(self.comment_card(root.user.as_ref(), &root.body, &root.created_at, cx)) + .children( + replies + .iter() + .map(|reply| self.comment_card(reply.user.as_ref(), &reply.body, &reply.created_at, cx)), + ) + .when_some(reply_editor, |this, editor| { + this.child( + v_flex() + .w_full() + .p_2() + .gap_1() + .child(editor) + .child( + Button::new(("reply", root_id as usize), "Reply").on_click( + cx.listener(move |this, _, window, cx| { + this.submit_reply(root_id, window, cx) + }), + ), + ), + ) + }) + } + + fn render_review_threads(&self, loaded: &LoadedPullRequest, cx: &Context) -> Vec { + let mut replies_by_root: HashMap> = HashMap::default(); + for comment in &loaded.review_comments { + if let Some(root_id) = comment.in_reply_to_id { + replies_by_root.entry(root_id).or_default().push(comment); + } + } + loaded + .review_comments + .iter() + .filter(|comment| comment.in_reply_to_id.is_none()) + .map(|root| { + let replies = replies_by_root.remove(&root.id).unwrap_or_default(); + self.render_comment_thread(root, &replies, cx).into_any_element() + }) + .collect() + } + + fn render_comment_box(&self, cx: &Context) -> impl IntoElement { + self.boxed(cx).mt_4().p_2().gap_1().child(self.comment_editor.clone()).child( + Button::new("add-comment", "Comment") + .on_click(cx.listener(|this, _, window, cx| this.submit_pr_comment(window, cx))), + ) + } + + fn render_body(&self, cx: &mut Context) -> AnyElement { + match &self.state { + ViewState::Loading => v_flex() + .size_full() + .items_center() + .justify_center() + .child(Label::new("Loading pull request…").color(Color::Muted)) + .into_any_element(), + ViewState::Error(error) => v_flex() + .size_full() + .p_4() + .gap_2() + .child(Label::new("Failed to load pull request").color(Color::Error)) + .child(Label::new(error.clone()).color(Color::Muted)) + .child( + Label::new("Set GITHUB_TOKEN or run `gh auth login` for private repos.") + .color(Color::Muted) + .size(LabelSize::Small), + ) + .into_any_element(), + ViewState::Loaded(loaded) => { + let pull_request = &loaded.pull_request; + let description = pull_request + .body + .as_deref() + .filter(|body| !body.trim().is_empty()) + .map(|body| self.comment_card(pull_request.user.as_ref(), body, &None, cx)); + + v_flex() + .id("pr-comments") + .size_full() + .p_4() + .gap_2() + .overflow_y_scroll() + .child( + Label::new(format!("{} #{}", pull_request.title, pull_request.number)) + .size(LabelSize::Large), + ) + .children(description) + .children( + loaded + .timeline + .iter() + .filter(|event| event.event == "commented") + .map(|event| { + self.comment_card( + event.user.as_ref(), + event.body.as_deref().unwrap_or_default(), + &event.created_at, + cx, + ) + }), + ) + .children(self.render_review_threads(loaded, cx)) + .child(self.render_comment_box(cx)) + .into_any_element() + } + } + } +} + +impl Focusable for PrCommentsView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for PrCommentsView {} + +impl Render for PrCommentsView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .key_context("PrComments") + .track_focus(&self.focus_handle) + .on_action(cx.listener(Self::confirm)) + .size_full() + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .w_full() + .p_2() + .gap_2() + .border_b_1() + .border_color(cx.theme().colors().border) + .child(Icon::new(IconName::GitBranch).color(Color::Muted)) + .child( + div() + .flex_1() + .px_2() + .py_1() + .rounded_md() + .bg(cx.theme().colors().editor_background) + .border_1() + .border_color(cx.theme().colors().border) + .child(self.input.clone()), + ), + ) + .child(self.render_body(cx)) + } +} + +impl Item for PrCommentsView { + type Event = EditorEvent; + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + match &self.state { + ViewState::Loaded(loaded) => format!("PR #{} comments", loaded.pull_request.number).into(), + _ => "PR Comments".into(), + } + } + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::GitBranch).color(Color::Muted)) + } +}