From 93738f719c4a42c3c595dae429cfd61cad7092b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Fri, 5 Jun 2026 22:02:50 +0200 Subject: [PATCH 01/13] feat(diff): abstract PR hosting behind a provider trait; add Azure DevOps The "lumen diff --pr" PR review flow was hardcoded to GitHub and the gh CLI throughout. Introduce a PrProvider trait (src/command/diff/pr_provider.rs) so other forges can be supported, and move the existing GitHub logic behind a GitHubProvider impl. Add an AzureProvider backed by the Azure DevOps REST API (src/command/diff/azure.rs): - parses dev.azure.com and *.visualstudio.com PR URLs and remotes (HTTPS + SSH) - fetches PR metadata, the iteration change list, and blob content over REST - diffs against the merge base (iteration changes with $compareTo=0), i.e. the same three-dot view the Azure web UI shows, instead of a tip-to-tip diff - fetches blobs concurrently over pooled HTTP (reusing the existing async reqwest + tokio; no new dependencies) - supports --detect-pr and open-in-browser ('o') Why REST and not the az CLI: az has no first-class command to list a PR's changed files or fetch file content; the only CLI route is the generic `az devops invoke` passthrough, which spawns a Python process per call (2 per file) and is unusably slow for an interactive diff. REST also lets us drop the azure-devops extension requirement entirely. Auth resolves to a PAT (ADO_PAT / AZURE_DEVOPS_EXT_PAT) via HTTP Basic, or a bearer token from `az account get-access-token` (core az, no extension). Per-file viewed-state sync stays a GitHub-only capability via a default no-op on the trait (Azure DevOps has no equivalent API). The provider is selected from the PR URL, falling back to the git origin remote, so bare PR numbers route to the right forge. Adds unit tests for URL/remote parsing, provider detection, and Azure change-entry parsing. Refs #118 --- README.md | 7 +- src/command/diff/app.rs | 37 +-- src/command/diff/azure.rs | 482 +++++++++++++++++++++++++++ src/command/diff/git.rs | 77 ++--- src/command/diff/mod.rs | 92 +++-- src/command/diff/pr_provider.rs | 572 ++++++++++++++++++++++++++++++++ src/config/cli.rs | 6 +- 7 files changed, 1139 insertions(+), 134 deletions(-) create mode 100644 src/command/diff/azure.rs create mode 100644 src/command/diff/pr_provider.rs diff --git a/README.md b/README.md index 6af76de9..180ca694 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A fast terminal diff viewer and code review TUI, written in Rust. Review `git diff`, commits, branches, or GitHub PRs side-by-side without leaving your terminal. Ships as a single static Rust binary and stays snappy on multi-thousand-line diffs. - Side-by-side diff viewer with tree-sitter syntax highlighting -- Review GitHub Pull Requests with `lumen diff --pr 123` +- Review GitHub and Azure DevOps Pull Requests with `lumen diff --pr 123` - Annotate selections, hunks, or whole files - Watch mode and stacked-commit review - Optional AI commit messages and change explanations (10+ providers) @@ -52,6 +52,8 @@ Before you begin, ensure you have: 1. `git` installed on your system 2. [fzf](https://github.com/junegunn/fzf) (optional) - Required for `lumen explain --list` command 3. [mdcat](https://github.com/swsnr/mdcat) (optional) - Required for pretty output formatting +4. [GitHub CLI (`gh`)](https://cli.github.com/) (optional) - Required for reviewing GitHub Pull Requests +5. Azure DevOps Pull Requests (optional) - Authenticate with either a Personal Access Token (`ADO_PAT`, scope: *Code → Read*) or the [Azure CLI (`az`)](https://learn.microsoft.com/cli/azure/) signed in via `az login`. The `azure-devops` extension is **not** required. ### Installation @@ -86,9 +88,10 @@ lumen diff HEAD~1 # View changes between branches lumen diff main..feature/A -# View changes in a GitHub Pull Request +# View changes in a Pull Request (GitHub or Azure DevOps) lumen diff --pr 123 # (--pr is optional) lumen diff https://github.com/owner/repo/pull/123 +lumen diff https://dev.azure.com/org/project/_git/repo/pullrequest/123 # Open the PR associated with the current branch lumen diff --detect-pr diff --git a/src/command/diff/app.rs b/src/command/diff/app.rs index 2af08187..1c7d31ab 100644 --- a/src/command/diff/app.rs +++ b/src/command/diff/app.rs @@ -37,10 +37,9 @@ fn open_tui_writer() -> io::Result> { use super::annotation::{AnnotationEditor, AnnotationEditorResult}; use super::coordinates::{extract_selected_text, PanelLayout}; -use super::git::{ - get_current_branch, load_file_diffs, load_pr_file_diffs, load_single_commit_diffs, -}; +use super::git::{get_current_branch, load_file_diffs, load_single_commit_diffs}; use super::highlight; +use super::pr_provider::{load_pr_file_diffs, pr_file_web_url}; use super::render::{ render_diff, render_empty_state, truncate_path, FilePickerItem, KeyBind, KeyBindSection, Modal, ModalContent, ModalFileStatus, ModalResult, @@ -281,8 +280,9 @@ pub fn run_app_stacked( run_app_internal(options, None, file_diffs, Some(commits), backend) } -/// Sync viewed files from GitHub to local state -fn sync_viewed_files_from_github(pr_info: &PrInfo, state: &mut AppState) { +/// Sync per-file viewed state from the hosting provider into local state. +/// No-op for providers without viewed-file support (e.g. Azure DevOps). +fn sync_viewed_files_from_provider(pr_info: &PrInfo, state: &mut AppState) { if let Ok(viewed_paths) = fetch_viewed_files(pr_info) { state.viewed_files.clear(); for (idx, diff) in state.file_diffs.iter().enumerate() { @@ -329,14 +329,14 @@ fn run_app_internal( state.init_stacked_mode(commits); } - // Load viewed files from GitHub on startup in PR mode (before TUI starts) + // Load viewed files from the provider on startup in PR mode (before TUI starts) if let Some(ref pr) = pr_info { let mut spinner = Spinner::new( spinners::Dots, format!("Syncing viewed status for {} files", state.file_diffs.len()), Color::Cyan, ); - sync_viewed_files_from_github(pr, &mut state); + sync_viewed_files_from_provider(pr, &mut state); let viewed_count = state.viewed_files.len(); spinner.success(&format!("{} files marked as viewed", viewed_count)); } @@ -389,7 +389,7 @@ fn run_app_internal( if state.needs_reload { let file_diffs = if let Some(ref pr) = pr_info { - // In PR mode, reload from GitHub + // In PR mode, reload from the hosting provider match load_pr_file_diffs(pr) { Ok(diffs) => diffs, Err(e) => { @@ -407,7 +407,7 @@ fn run_app_internal( // Re-sync viewed files from GitHub in PR mode if let Some(ref pr) = pr_info { - sync_viewed_files_from_github(pr, &mut state); + sync_viewed_files_from_provider(pr, &mut state); } } @@ -1960,14 +1960,9 @@ fn run_app_internal( if let Some(ref pr) = pr_info { if !state.file_diffs.is_empty() { let filename = &state.file_diffs[state.current_file].filename; - let file_url = format!( - "https://github.com/{}/{}/pull/{}/files#diff-{}", - pr.repo_owner, - pr.repo_name, - pr.number, - generate_file_anchor(filename) - ); - let _ = open_url(&file_url); + if let Some(file_url) = pr_file_web_url(pr, filename) { + let _ = open_url(&file_url); + } } } } @@ -2236,11 +2231,3 @@ fn open_url(url: &str) -> io::Result<()> { } Ok(()) } - -fn generate_file_anchor(filename: &str) -> String { - use sha2::{Digest, Sha256}; - - let mut hasher = Sha256::new(); - hasher.update(filename.as_bytes()); - format!("{:x}", hasher.finalize()) -} diff --git a/src/command/diff/azure.rs b/src/command/diff/azure.rs new file mode 100644 index 00000000..c72322b9 --- /dev/null +++ b/src/command/diff/azure.rs @@ -0,0 +1,482 @@ +//! Azure DevOps REST client for pull-request review. +//! +//! The `az` CLI has no first-class command to list a PR's changed files or +//! fetch file content, and the generic `az devops invoke` passthrough spawns a +//! Python process per call — unusable for an interactive diff. So we talk to the +//! Azure DevOps REST API directly. +//! +//! Diffs use the iteration-changes endpoint with `$compareTo=0`, i.e. the diff +//! against the PR's merge base — the same three-dot view the Azure web UI shows, +//! rather than a raw tip-to-tip comparison. +//! +//! Auth resolves to a PAT (`ADO_PAT` / `AZURE_DEVOPS_EXT_PAT`) via HTTP Basic, +//! or falls back to a bearer token from `az account get-access-token`. Only core +//! `az` (or a PAT) is required — not the `azure-devops` extension. + +use std::env; +use std::process::Command; +use std::sync::Arc; + +use reqwest::header::ACCEPT; +use serde_json::Value; +use tokio::sync::Semaphore; +use tokio::task::JoinSet; + +use super::git::build_file_diff; +use super::types::FileDiff; +use super::PrInfo; + +const API_VERSION: &str = "7.1"; +/// Azure DevOps OAuth resource id, used with `az account get-access-token`. +const ADO_RESOURCE: &str = "499b84ac-1321-427f-aa17-267ca6975798"; +/// Max concurrent blob fetches — keeps us well under Azure's rate limits while +/// still parallelising content retrieval. +const BLOB_CONCURRENCY: usize = 8; +/// Page size for the paginated iteration-changes endpoint. +const CHANGES_PAGE: usize = 1000; + +/// PR metadata needed to drive the diff UI. +pub struct AzurePrMeta { + pub source_ref: String, + pub target_ref: String, + pub repo_name: String, +} + +enum AdoAuth { + /// Personal access token, sent via HTTP Basic with an empty username. + Pat(String), + /// OAuth bearer token (from `az account get-access-token`). + Bearer(String), +} + +#[derive(Clone)] +struct AdoClient { + http: reqwest::Client, + /// Organisation base URL, e.g. `https://dev.azure.com/org`. + base: String, + project: String, + repo: String, + auth: Arc, +} + +impl AdoClient { + fn new(org_url: &str, project: &str, repo: &str) -> Result { + Ok(Self { + http: reqwest::Client::new(), + base: org_url.trim_end_matches('/').to_string(), + project: project.to_string(), + repo: repo.to_string(), + auth: Arc::new(resolve_auth()?), + }) + } + + fn authed(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match self.auth.as_ref() { + AdoAuth::Pat(pat) => rb.basic_auth("", Some(pat)), + AdoAuth::Bearer(token) => rb.bearer_auth(token), + } + } + + /// `{base}/{project}/_apis/git/...` with project URL-encoded. + fn git_url(&self, suffix: &str) -> String { + format!( + "{}/{}/_apis/git/{}", + self.base, + enc(&self.project), + suffix + ) + } + + async fn get_json(&self, url: &str) -> Result { + let resp = self + .authed(self.http.get(url)) + .header(ACCEPT, "application/json") + .send() + .await + .map_err(|e| format!("request failed: {}", e))?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(auth_hint(status, &body)); + } + serde_json::from_str(&body).map_err(|e| format!("invalid JSON from Azure: {}", e)) + } + + /// Fetch a blob's text content. Returns empty string when `blob_id` is None + /// (the absent side of an add/delete) or on any error. + async fn blob_text(&self, blob_id: Option<&str>) -> String { + let Some(blob_id) = blob_id else { + return String::new(); + }; + let url = format!( + "{}?$format=text&api-version={}", + self.git_url(&format!("repositories/{}/blobs/{}", enc(&self.repo), blob_id)), + API_VERSION + ); + let Ok(resp) = self + .authed(self.http.get(&url)) + .header(ACCEPT, "text/plain") + .send() + .await + else { + return String::new(); + }; + if !resp.status().is_success() { + return String::new(); + } + // Read as bytes then lossy-decode so binary blobs degrade gracefully + // (build_file_diff flags them as binary downstream). + match resp.bytes().await { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(_) => String::new(), + } + } + + async fn latest_iteration(&self, pr_id: u64) -> Result { + let url = format!( + "{}?api-version={}", + self.git_url(&format!( + "repositories/{}/pullRequests/{}/iterations", + enc(&self.repo), + pr_id + )), + API_VERSION + ); + let json = self.get_json(&url).await?; + json.get("value") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.iter().filter_map(|i| i.get("id")?.as_u64()).max()) + .ok_or_else(|| "PR has no iterations".to_string()) + } + + /// All change entries for `iteration`, compared against the merge base + /// (`$compareTo=0`), following `$skip`/`$top` pagination. + async fn changes(&self, pr_id: u64, iteration: u64) -> Result, String> { + let mut entries = Vec::new(); + let mut skip = 0usize; + loop { + let url = format!( + "{}?$compareTo=0&$top={}&$skip={}&api-version={}", + self.git_url(&format!( + "repositories/{}/pullRequests/{}/iterations/{}/changes", + enc(&self.repo), + pr_id, + iteration + )), + CHANGES_PAGE, + skip, + API_VERSION + ); + let json = self.get_json(&url).await?; + let page = json + .get("changeEntries") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let page_len = page.len(); + for entry in page { + if let Some(change) = ChangeEntry::from_json(&entry) { + entries.push(change); + } + } + // `nextSkip` is the canonical "more pages" signal; fall back to a + // short page meaning we're done. + let next_skip = json.get("nextSkip").and_then(|v| v.as_u64()).unwrap_or(0); + if next_skip == 0 || page_len < CHANGES_PAGE { + break; + } + skip = next_skip as usize; + } + Ok(entries) + } + + async fn load_file_diffs(&self, pr_id: u64) -> Result, String> { + let iteration = self.latest_iteration(pr_id).await?; + let changes = self.changes(pr_id, iteration).await?; + + let sem = Arc::new(Semaphore::new(BLOB_CONCURRENCY)); + let mut set: JoinSet<(usize, FileDiff)> = JoinSet::new(); + for (idx, change) in changes.into_iter().enumerate() { + let client = self.clone(); + let sem = Arc::clone(&sem); + set.spawn(async move { + let _permit = sem.acquire_owned().await; + let old = client.blob_text(change.old_blob.as_deref()).await; + let new = client.blob_text(change.new_blob.as_deref()).await; + (idx, build_file_diff(change.path, old, new)) + }); + } + + // Reassemble in the original change order. + let mut out: Vec> = Vec::new(); + while let Some(res) = set.join_next().await { + let (idx, diff) = res.map_err(|e| format!("blob fetch task failed: {}", e))?; + if idx >= out.len() { + out.resize_with(idx + 1, || None); + } + out[idx] = Some(diff); + } + Ok(out.into_iter().flatten().collect()) + } +} + +/// One PR file change reduced to what the diff UI needs. +struct ChangeEntry { + /// Repo-relative path without a leading slash. + path: String, + /// Blob id of the new (head) side, if any. + new_blob: Option, + /// Blob id of the old (base) side, if any. + old_blob: Option, +} + +impl ChangeEntry { + fn from_json(entry: &Value) -> Option { + let item = entry.get("item"); + // Skip folders. + if item + .and_then(|i| i.get("isFolder")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return None; + } + let raw_path = item + .and_then(|i| i.get("path")) + .and_then(|v| v.as_str()) + .or_else(|| entry.get("originalPath").and_then(|v| v.as_str()))?; + let new_blob = item + .and_then(|i| i.get("objectId")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let old_blob = item + .and_then(|i| i.get("originalObjectId")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + Some(Self { + path: raw_path.trim_start_matches('/').to_string(), + new_blob, + old_blob, + }) + } +} + +// --------------------------------------------------------------------------- +// Public sync entry points (bridge the async client onto the sync diff path) +// --------------------------------------------------------------------------- + +pub fn fetch_pr_metadata( + org_url: &str, + project: &str, + repo: &str, + pr_id: u64, +) -> Result { + let client = AdoClient::new(org_url, project, repo)?; + block_on(async move { + // PR detail is project-scoped (not repo-scoped) in the REST API. + let url = format!( + "{}?api-version={}", + client.git_url(&format!("pullrequests/{}", pr_id)), + API_VERSION + ); + let json = client.get_json(&url).await?; + let source_ref = json + .get("sourceRefName") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let target_ref = json + .get("targetRefName") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let repo_name = json + .pointer("/repository/name") + .and_then(|v| v.as_str()) + .unwrap_or(repo) + .to_string(); + Ok(AzurePrMeta { + source_ref, + target_ref, + repo_name, + }) + }) +} + +pub fn detect_active_pr( + org_url: &str, + project: &str, + repo: &str, + branch: &str, +) -> Result { + let client = AdoClient::new(org_url, project, repo)?; + block_on(async move { + let url = format!( + "{}?searchCriteria.status=active&searchCriteria.sourceRefName={}&api-version={}", + client.git_url(&format!("repositories/{}/pullrequests", enc(repo))), + enc(&format!("refs/heads/{}", branch)), + API_VERSION + ); + let json = client.get_json(&url).await?; + json.get("value") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|pr| pr.get("pullRequestId")) + .and_then(|v| v.as_u64()) + .ok_or_else(|| format!("No active PR found for branch {}", branch)) + }) +} + +pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, String> { + let org_url = pr + .org_url + .as_deref() + .ok_or("Azure PR missing organisation URL")?; + let project = pr + .project + .as_deref() + .ok_or("Azure PR missing project")?; + let client = AdoClient::new(org_url, project, &pr.repo_name)?; + let pr_id = pr.number; + block_on(async move { client.load_file_diffs(pr_id).await }) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Run an async future to completion from the synchronous diff path. `main` is +/// a multi-threaded `#[tokio::main]`, so we mark the current worker as blocking +/// and drive the future on the existing runtime — no second runtime, no new deps. +fn block_on(fut: F) -> F::Output { + tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut)) +} + +fn resolve_auth() -> Result { + for var in ["ADO_PAT", "AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"] { + if let Ok(pat) = env::var(var) { + if !pat.trim().is_empty() { + return Ok(AdoAuth::Pat(pat)); + } + } + } + // Fall back to an OAuth token from the Azure CLI (`az login`). + let output = Command::new("az") + .args([ + "account", + "get-access-token", + "--resource", + ADO_RESOURCE, + "-o", + "json", + ]) + .output() + .map_err(|e| { + format!( + "No Azure DevOps credentials: set ADO_PAT or install the Azure CLI and run `az login` ({})", + e + ) + })?; + if !output.status.success() { + return Err( + "No Azure DevOps credentials: set ADO_PAT, or run `az login` to use the Azure CLI." + .to_string(), + ); + } + let json: Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Could not parse az token output: {}", e))?; + json.get("accessToken") + .and_then(|v| v.as_str()) + .map(|t| AdoAuth::Bearer(t.to_string())) + .ok_or_else(|| "az returned no access token".to_string()) +} + +fn auth_hint(status: reqwest::StatusCode, body: &str) -> String { + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::NON_AUTHORITATIVE_INFORMATION { + return "Azure DevOps auth failed (401). Check ADO_PAT scopes (Code: Read) or run `az login`.".to_string(); + } + if status == reqwest::StatusCode::FORBIDDEN { + return "Azure DevOps returned 403. The token lacks access to this repository.".to_string(); + } + let snippet: String = body.chars().take(200).collect(); + format!("Azure DevOps request failed ({}): {}", status, snippet.trim()) +} + +/// Percent-encode one URL path/query segment (keeps RFC 3986 unreserved chars). +fn enc(segment: &str) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(segment.len()); + for b in segment.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => { + let _ = write!(out, "%{:02X}", b); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_add_edit_delete_changes() { + let add = ChangeEntry::from_json(&serde_json::json!({ + "changeType": "add", + "item": { "path": "/src/new.rs", "objectId": "newsha" } + })) + .unwrap(); + assert_eq!(add.path, "src/new.rs"); + assert_eq!(add.new_blob.as_deref(), Some("newsha")); + assert_eq!(add.old_blob, None); + + let edit = ChangeEntry::from_json(&serde_json::json!({ + "changeType": "edit", + "item": { "path": "/a.txt", "objectId": "n", "originalObjectId": "o" } + })) + .unwrap(); + assert_eq!(edit.new_blob.as_deref(), Some("n")); + assert_eq!(edit.old_blob.as_deref(), Some("o")); + + let del = ChangeEntry::from_json(&serde_json::json!({ + "changeType": "delete", + "item": { "path": "/gone.rs", "originalObjectId": "o" } + })) + .unwrap(); + assert_eq!(del.new_blob, None); + assert_eq!(del.old_blob.as_deref(), Some("o")); + } + + #[test] + fn skips_folders() { + assert!(ChangeEntry::from_json(&serde_json::json!({ + "changeType": "add", + "item": { "path": "/dir", "isFolder": true } + })) + .is_none()); + } + + #[test] + fn falls_back_to_original_path_on_rename() { + let renamed = ChangeEntry::from_json(&serde_json::json!({ + "changeType": "rename", + "item": { "objectId": "n", "originalObjectId": "o" }, + "originalPath": "/old/name.rs" + })) + .unwrap(); + assert_eq!(renamed.path, "old/name.rs"); + } + + #[test] + fn encodes_segments() { + assert_eq!(enc("My Project"), "My%20Project"); + assert_eq!(enc("refs/heads/feature/x"), "refs%2Fheads%2Ffeature%2Fx"); + assert_eq!(enc("simple-repo.git"), "simple-repo.git"); + } +} diff --git a/src/command/diff/git.rs b/src/command/diff/git.rs index 44ec6286..57d7a968 100644 --- a/src/command/diff/git.rs +++ b/src/command/diff/git.rs @@ -141,6 +141,26 @@ pub fn get_new_content(filename: &str, refs: &DiffRefs, backend: &dyn VcsBackend } } +/// Assemble a `FileDiff` from a filename and its two sides, deriving the file +/// status and binary flag from the contents. +pub fn build_file_diff(filename: String, old_content: String, new_content: String) -> FileDiff { + let status = if old_content.is_empty() && !new_content.is_empty() { + FileStatus::Added + } else if !old_content.is_empty() && new_content.is_empty() { + FileStatus::Deleted + } else { + FileStatus::Modified + }; + let is_binary = is_binary_content(&old_content) || is_binary_content(&new_content); + FileDiff { + filename, + old_content, + new_content, + status, + is_binary, + } +} + pub fn load_file_diffs(options: &DiffOptions, backend: &dyn VcsBackend) -> Vec { let refs = DiffRefs::from_options(options, backend); get_changed_files(options, backend) @@ -148,27 +168,14 @@ pub fn load_file_diffs(options: &DiffOptions, backend: &dyn VcsBackend) -> Vec Result, String> { +/// GitHub implementation of PR diff loading: list changed files via `gh pr +/// diff`, then fetch each file's base/head content via the GitHub contents API. +pub fn github_load_pr_file_diffs(pr_info: &PrInfo) -> Result, String> { let repo_arg = format!("{}/{}", pr_info.repo_owner, pr_info.repo_name); let mut spinner = Spinner::new( @@ -236,23 +243,7 @@ pub fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, String> { .into_iter() .zip(contents.into_iter()) .map(|(filename, (old_content, new_content))| { - let status = if old_content.is_empty() && !new_content.is_empty() { - FileStatus::Added - } else if !old_content.is_empty() && new_content.is_empty() { - FileStatus::Deleted - } else { - FileStatus::Modified - }; - - let is_binary = - is_binary_content(&old_content) || is_binary_content(&new_content); - FileDiff { - filename, - old_content, - new_content, - status, - is_binary, - } + build_file_diff(filename, old_content, new_content) }) .collect(); @@ -473,23 +464,7 @@ pub fn load_single_commit_diffs( .get_file_content_at_ref(commit_id, path) .unwrap_or_default(); - let status = if old_content.is_empty() && !new_content.is_empty() { - FileStatus::Added - } else if !old_content.is_empty() && new_content.is_empty() { - FileStatus::Deleted - } else { - FileStatus::Modified - }; - - let is_binary = - is_binary_content(&old_content) || is_binary_content(&new_content); - FileDiff { - filename, - old_content, - new_content, - status, - is_binary, - } + build_file_diff(filename, old_content, new_content) }) .collect() } diff --git a/src/command/diff/mod.rs b/src/command/diff/mod.rs index c1ce8199..0b09c550 100644 --- a/src/command/diff/mod.rs +++ b/src/command/diff/mod.rs @@ -1,11 +1,13 @@ mod annotation; mod app; +mod azure; mod context; mod coordinates; mod diff_algo; pub mod git; mod global_search; pub mod highlight; +pub mod pr_provider; mod render; mod search; mod state; @@ -18,13 +20,16 @@ mod watcher; use std::collections::HashSet; use std::io; use std::process::{self, Command}; -use std::thread; use spinoff::{spinners, Color, Spinner}; use crate::commit_reference::CommitReference; use crate::vcs::VcsBackend; +pub use pr_provider::{ + fetch_viewed_files, mark_file_as_viewed_async, unmark_file_as_viewed_async, ProviderKind, +}; + pub struct DiffOptions { pub reference: Option, pub pr: Option, @@ -40,6 +45,7 @@ pub struct DiffOptions { #[derive(Clone)] pub struct PrInfo { + pub provider: ProviderKind, pub number: u64, pub node_id: String, pub repo_owner: String, @@ -48,9 +54,13 @@ pub struct PrInfo { pub head_ref: String, pub base_repo_owner: String, pub head_repo_owner: Option, // None if head repo was deleted (fork deleted) + /// Azure DevOps project (None for GitHub). + pub project: Option, + /// Azure DevOps organisation base URL, e.g. `https://dev.azure.com/org`. + pub org_url: Option, } -fn parse_pr_input(input: &str) -> Option<(Option, Option, u64)> { +fn github_parse_pr_input(input: &str) -> Option<(Option, Option, u64)> { // Try to parse as a URL first if input.starts_with("http://") || input.starts_with("https://") { // Extract PR number and repo info from URL @@ -78,7 +88,7 @@ fn parse_pr_input(input: &str) -> Option<(Option, Option, u64)> } } -fn resolve_origin_repo() -> Result { +fn github_resolve_origin_repo() -> Result { let output = Command::new("git") .args(["remote", "get-url", "origin"]) .output() @@ -104,8 +114,11 @@ fn resolve_origin_repo() -> Result { } } -fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result { - let (owner, repo, number) = parse_pr_input(pr_input).ok_or_else(|| { +pub(crate) fn github_fetch_pr_info( + pr_input: &str, + repo_override: Option<&str>, +) -> Result { + let (owner, repo, number) = github_parse_pr_input(pr_input).ok_or_else(|| { format!( "Invalid PR reference: {}. Use a PR number or URL.", pr_input @@ -115,7 +128,7 @@ fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result format!("{}/{}", o, r), (_, _, Some(r)) => r.to_string(), - _ => resolve_origin_repo()?, + _ => github_resolve_origin_repo()?, }; let (repo_owner, repo_name) = { @@ -161,6 +174,7 @@ fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result) -> Result Option { } /// Fetch the list of files that are marked as viewed on GitHub -pub fn fetch_viewed_files(pr_info: &PrInfo) -> Result, String> { +pub(crate) fn github_fetch_viewed_files(pr_info: &PrInfo) -> Result, String> { let query = format!( r#"query {{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ files(first: 100) {{ nodes {{ path viewerViewedState }} }} }} }} }}"#, pr_info.repo_owner, pr_info.repo_name, pr_info.number @@ -258,28 +274,11 @@ pub fn fetch_viewed_files(pr_info: &PrInfo) -> Result, String> { Ok(viewed_files) } -/// Mark a file as viewed on GitHub PR (non-blocking, spawns a thread) -pub fn mark_file_as_viewed_async(pr_info: &PrInfo, file_path: &str) { - let node_id = pr_info.node_id.clone(); - let path = file_path.to_string(); - - thread::spawn(move || { - let _ = mark_file_as_viewed_sync(&node_id, &path); - }); -} - -/// Unmark a file as viewed on GitHub PR (non-blocking, spawns a thread) -pub fn unmark_file_as_viewed_async(pr_info: &PrInfo, file_path: &str) { - let node_id = pr_info.node_id.clone(); - let path = file_path.to_string(); - - thread::spawn(move || { - let _ = unmark_file_as_viewed_sync(&node_id, &path); - }); -} - /// Mark a file as viewed on GitHub PR (blocking) -fn mark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String> { +pub(crate) fn github_mark_file_as_viewed_sync( + node_id: &str, + file_path: &str, +) -> Result<(), String> { let mutation = format!( r#"mutation {{ markFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, node_id, file_path @@ -299,7 +298,10 @@ fn mark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String } /// Unmark a file as viewed on GitHub PR (blocking) -fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String> { +pub(crate) fn github_unmark_file_as_viewed_sync( + node_id: &str, + file_path: &str, +) -> Result<(), String> { let mutation = format!( r#"mutation {{ unmarkFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, node_id, file_path @@ -318,7 +320,7 @@ fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), Stri Ok(()) } -fn detect_current_branch_pr() -> Result { +pub(crate) fn github_detect_current_branch_pr() -> Result { let output = Command::new("gh") .args(["pr", "view", "--json", "number", "-q", ".number"]) .output() @@ -346,7 +348,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re "Detecting PR for current branch", Color::Cyan, ); - match detect_current_branch_pr() { + match pr_provider::detect_current_branch_pr(options.origin.as_deref()) { Ok(number) => { spinner.success(&format!("Detected PR #{}", number)); options.pr = Some(number); @@ -360,17 +362,8 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re // Handle PR mode if let Some(ref pr_input) = options.pr { - let spinner_msg = match parse_pr_input(pr_input) { - Some((Some(owner), Some(repo), number)) => { - format!("Fetching PR {}/{}#{}", owner, repo, number) - } - Some((_, _, number)) => { - format!("Fetching PR #{}", number) - } - None => "Fetching PR".to_string(), - }; - let mut spinner = Spinner::new(spinners::Dots, spinner_msg, Color::Cyan); - match fetch_pr_info(pr_input, options.origin.as_deref()) { + let mut spinner = Spinner::new(spinners::Dots, "Fetching PR metadata", Color::Cyan); + match pr_provider::fetch_pr_info(pr_input, options.origin.as_deref()) { Ok(pr_info) => { spinner.success("Fetched PR metadata"); return app::run_app_with_pr(options, pr_info, backend); @@ -384,18 +377,9 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re // Also check if the reference looks like a PR (number or URL) if let Some(CommitReference::Single(ref input)) = options.reference { - if input.contains("/pull/") || input.parse::().is_ok() { - let spinner_msg = match parse_pr_input(input) { - Some((Some(owner), Some(repo), number)) => { - format!("Fetching PR {}/{}#{}", owner, repo, number) - } - Some((_, _, number)) => { - format!("Fetching PR #{}", number) - } - None => "Fetching PR".to_string(), - }; - let mut spinner = Spinner::new(spinners::Dots, spinner_msg, Color::Cyan); - match fetch_pr_info(input, options.origin.as_deref()) { + if pr_provider::is_pr_reference(input) { + let mut spinner = Spinner::new(spinners::Dots, "Fetching PR metadata", Color::Cyan); + match pr_provider::fetch_pr_info(input, options.origin.as_deref()) { Ok(pr_info) => { spinner.success("Fetched PR metadata"); return app::run_app_with_pr(options, pr_info, backend); diff --git a/src/command/diff/pr_provider.rs b/src/command/diff/pr_provider.rs new file mode 100644 index 00000000..645268dc --- /dev/null +++ b/src/command/diff/pr_provider.rs @@ -0,0 +1,572 @@ +//! Pull-request hosting provider abstraction. +//! +//! `lumen diff --pr` originally only understood GitHub (it shelled out to the +//! `gh` CLI everywhere). This module introduces a [`PrProvider`] trait so other +//! forges can be supported, with `gh` for GitHub and `az` (the Azure DevOps CLI) +//! for Azure DevOps. New providers (e.g. `glab` for GitLab) only need to add an +//! impl and wire it into provider detection. + +use std::collections::HashSet; +use std::process::Command; +use std::thread; + +use super::azure; +use super::git::github_load_pr_file_diffs; +use super::types::FileDiff; +use super::{ + github_detect_current_branch_pr, github_fetch_pr_info, github_fetch_viewed_files, + github_mark_file_as_viewed_sync, github_unmark_file_as_viewed_sync, PrInfo, +}; + +/// Which hosting provider a [`PrInfo`] belongs to. Stored on `PrInfo` so the +/// runtime (viewed-file sync, open-in-browser, reloads) can route back to the +/// right provider without re-parsing the original input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProviderKind { + GitHub, + Azure, +} + +impl ProviderKind { + /// The provider implementation. Both providers are zero-sized, so this hands + /// back a `'static` reference with no allocation. + pub fn handler(self) -> &'static dyn PrProvider { + match self { + ProviderKind::GitHub => &GitHubProvider, + ProviderKind::Azure => &AzureProvider, + } + } +} + +/// A pull-request hosting provider. Each method maps to one capability the diff +/// UI needs; the viewed-file sync methods default to no-ops so providers without +/// that concept (Azure DevOps) don't have to implement them. +pub trait PrProvider { + /// Does this provider recognise `input` as one of its PR URLs? + fn matches_url(&self, input: &str) -> bool; + + /// Does this provider recognise `origin` (a git remote URL) as one of its + /// repositories? Used to pick a provider for bare PR numbers. + fn matches_origin(&self, origin: &str) -> bool; + + /// Resolve a PR number/URL into full metadata. + fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result; + + /// Find the PR associated with the current branch. + fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result; + + /// Load the file diffs for a PR. + fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String>; + + /// Whether this provider supports syncing per-file "viewed" state. + fn supports_viewed_sync(&self) -> bool { + false + } + + /// Fetch the set of paths currently marked as viewed. + fn fetch_viewed_files(&self, _pr: &PrInfo) -> Result, String> { + Ok(HashSet::new()) + } + + /// Mark/unmark a file as viewed (blocking). + fn set_file_viewed(&self, _pr: &PrInfo, _path: &str, _viewed: bool) -> Result<(), String> { + Ok(()) + } + + /// Build a browser URL for `filename` within the PR. + fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option; +} + +// --------------------------------------------------------------------------- +// Provider selection +// --------------------------------------------------------------------------- + +/// True if `input` looks like a PR reference (a known PR URL or a bare number). +pub fn is_pr_reference(input: &str) -> bool { + GitHubProvider.matches_url(input) + || AzureProvider.matches_url(input) + || input.parse::().is_ok() +} + +fn read_origin_url() -> Option { + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if url.is_empty() { + None + } else { + Some(url) + } +} + +/// Pick a provider from the git `origin` remote (and any `--origin` override), +/// defaulting to GitHub when nothing matches. +fn provider_for_origin(repo_override: Option<&str>) -> &'static dyn PrProvider { + let candidates = [repo_override.map(|s| s.to_string()), read_origin_url()]; + for candidate in candidates.into_iter().flatten() { + if AzureProvider.matches_origin(&candidate) { + return ProviderKind::Azure.handler(); + } + } + ProviderKind::GitHub.handler() +} + +/// Pick a provider from a PR URL/number, falling back to origin detection. +fn provider_for_input(input: &str, repo_override: Option<&str>) -> &'static dyn PrProvider { + if AzureProvider.matches_url(input) { + return ProviderKind::Azure.handler(); + } + if GitHubProvider.matches_url(input) { + return ProviderKind::GitHub.handler(); + } + provider_for_origin(repo_override) +} + +// --------------------------------------------------------------------------- +// Dispatchers used by the rest of the diff UI +// --------------------------------------------------------------------------- + +pub fn fetch_pr_info(input: &str, repo_override: Option<&str>) -> Result { + provider_for_input(input, repo_override).fetch_pr_info(input, repo_override) +} + +pub fn detect_current_branch_pr(repo_override: Option<&str>) -> Result { + provider_for_origin(repo_override).detect_current_branch_pr(repo_override) +} + +pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, String> { + pr.provider.handler().load_pr_file_diffs(pr) +} + +pub fn fetch_viewed_files(pr: &PrInfo) -> Result, String> { + pr.provider.handler().fetch_viewed_files(pr) +} + +pub fn pr_file_web_url(pr: &PrInfo, filename: &str) -> Option { + pr.provider.handler().file_web_url(pr, filename) +} + +pub fn mark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { + set_file_viewed_async(pr, file_path, true); +} + +pub fn unmark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { + set_file_viewed_async(pr, file_path, false); +} + +fn set_file_viewed_async(pr: &PrInfo, file_path: &str, viewed: bool) { + if !pr.provider.handler().supports_viewed_sync() { + return; + } + let pr = pr.clone(); + let path = file_path.to_string(); + thread::spawn(move || { + let _ = pr.provider.handler().set_file_viewed(&pr, &path, viewed); + }); +} + +/// SHA-256 file anchor used by GitHub's PR "Files changed" deep links +/// (`#diff-`). +fn github_file_anchor(filename: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(filename.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +// --------------------------------------------------------------------------- +// GitHub +// --------------------------------------------------------------------------- + +pub struct GitHubProvider; + +impl PrProvider for GitHubProvider { + fn matches_url(&self, input: &str) -> bool { + input.starts_with("http") && input.contains("/pull/") + } + + fn matches_origin(&self, origin: &str) -> bool { + origin.contains("github.com") + } + + fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { + github_fetch_pr_info(input, repo_override) + } + + fn detect_current_branch_pr(&self, _repo_override: Option<&str>) -> Result { + github_detect_current_branch_pr() + } + + fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String> { + github_load_pr_file_diffs(pr) + } + + fn supports_viewed_sync(&self) -> bool { + true + } + + fn fetch_viewed_files(&self, pr: &PrInfo) -> Result, String> { + github_fetch_viewed_files(pr) + } + + fn set_file_viewed(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), String> { + if viewed { + github_mark_file_as_viewed_sync(&pr.node_id, path) + } else { + github_unmark_file_as_viewed_sync(&pr.node_id, path) + } + } + + fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { + Some(format!( + "https://github.com/{}/{}/pull/{}/files#diff-{}", + pr.repo_owner, + pr.repo_name, + pr.number, + github_file_anchor(filename) + )) + } +} + +// --------------------------------------------------------------------------- +// Azure DevOps +// --------------------------------------------------------------------------- + +pub struct AzureProvider; + +/// The coordinates of an Azure DevOps repository / PR, parsed from a URL or a +/// git remote. +struct AzureRef { + /// Organisation base URL, e.g. `https://dev.azure.com/myorg`. + org_url: String, + /// Short organisation name, e.g. `myorg`. + org: String, + project: String, + repo: String, + /// PR id when parsed from a PR URL. + id: Option, +} + +impl AzureProvider { + fn resolve_ref(&self, input: &str, repo_override: Option<&str>) -> Result { + if let Some(parsed) = parse_azure_url(input) { + return Ok(parsed); + } + // Bare PR number: take the coordinates from --origin (if it's an Azure + // URL) or from the git `origin` remote. + let id = input + .parse::() + .map_err(|_| format!("Invalid Azure DevOps PR reference: {}", input))?; + let remote = repo_override + .filter(|o| self.matches_origin(o)) + .map(|s| s.to_string()) + .or_else(read_origin_url) + .ok_or_else(|| { + "Could not determine Azure DevOps repository. Run inside the repo or pass a PR URL." + .to_string() + })?; + let mut parsed = parse_azure_remote(&remote) + .ok_or_else(|| format!("Could not parse Azure DevOps remote: {}", remote))?; + parsed.id = Some(id); + Ok(parsed) + } +} + +impl PrProvider for AzureProvider { + fn matches_url(&self, input: &str) -> bool { + let host_ok = input.contains("dev.azure.com") || input.contains(".visualstudio.com"); + host_ok && (input.contains("/pullrequest/") || input.contains("/_git/")) + } + + fn matches_origin(&self, origin: &str) -> bool { + origin.contains("dev.azure.com") || origin.contains(".visualstudio.com") + } + + fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { + let az = self.resolve_ref(input, repo_override)?; + let id = az + .id + .ok_or_else(|| format!("No PR id found in: {}", input))?; + + let meta = azure::fetch_pr_metadata(&az.org_url, &az.project, &az.repo, id)?; + + Ok(PrInfo { + provider: ProviderKind::Azure, + number: id, + node_id: String::new(), + repo_owner: az.org.clone(), + // Prefer the repo name the API reports; fall back to the URL's. + repo_name: if meta.repo_name.is_empty() { + az.repo + } else { + meta.repo_name + }, + base_ref: strip_ref_prefix(&meta.target_ref), + head_ref: strip_ref_prefix(&meta.source_ref), + base_repo_owner: az.org.clone(), + head_repo_owner: Some(az.org), + project: Some(az.project), + org_url: Some(az.org_url), + }) + } + + fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result { + let remote = repo_override + .filter(|o| self.matches_origin(o)) + .map(|s| s.to_string()) + .or_else(read_origin_url) + .ok_or_else(|| "Could not determine Azure DevOps repository.".to_string())?; + let az = parse_azure_remote(&remote) + .ok_or_else(|| format!("Could not parse Azure DevOps remote: {}", remote))?; + + let branch_out = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .map_err(|e| format!("Failed to run git: {}", e))?; + let branch = String::from_utf8_lossy(&branch_out.stdout).trim().to_string(); + if branch.is_empty() { + return Err("Could not determine the current branch".to_string()); + } + + let id = azure::detect_active_pr(&az.org_url, &az.project, &az.repo, &branch)?; + Ok(id.to_string()) + } + + fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String> { + azure::load_pr_file_diffs(pr) + } + + fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { + let org_url = pr.org_url.as_ref()?; + let project = pr.project.as_ref()?; + Some(format!( + "{}/{}/_git/{}/pullrequest/{}?path={}", + org_url, + project, + pr.repo_name, + pr.number, + encode_path(&format!("/{}", filename)) + )) + } +} + +/// Strip a `refs/heads/` (or `refs/`) prefix from an Azure ref name. +fn strip_ref_prefix(ref_name: &str) -> String { + ref_name + .strip_prefix("refs/heads/") + .or_else(|| ref_name.strip_prefix("refs/")) + .unwrap_or(ref_name) + .to_string() +} + +/// Extract `(org_url, org, project, repo)` from the host+path segments of an +/// Azure DevOps HTTPS URL or remote. Shared by URL and remote parsing. +fn azure_coords_from_parts(parts: &[&str]) -> Option<(String, String, String, String)> { + let host = *parts.first()?; + let git_idx = parts.iter().position(|&p| p == "_git")?; + if git_idx == 0 || git_idx + 1 >= parts.len() { + return None; + } + let project = decode_component(parts[git_idx - 1]); + let repo = decode_component(parts[git_idx + 1]); + + let (org_url, org) = if host == "dev.azure.com" { + let org = (*parts.get(1)?).to_string(); + (format!("https://dev.azure.com/{}", org), org) + } else if let Some(org) = host.strip_suffix(".visualstudio.com") { + (format!("https://{}", host), org.to_string()) + } else { + return None; + }; + + Some((org_url, org, project, repo)) +} + +/// Parse an Azure DevOps PR URL into its coordinates. +/// +/// Handles `https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{id}` +/// and `https://{org}.visualstudio.com/{project}/_git/{repo}/pullrequest/{id}`. +fn parse_azure_url(input: &str) -> Option { + if !input.starts_with("http") { + return None; + } + let no_query = input.split('?').next().unwrap_or(input); + let no_scheme = no_query + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/'); + let parts: Vec<&str> = no_scheme.split('/').collect(); + let (org_url, org, project, repo) = azure_coords_from_parts(&parts)?; + + let id = parts + .iter() + .position(|p| p.eq_ignore_ascii_case("pullrequest")) + .and_then(|i| parts.get(i + 1)) + .and_then(|s| s.parse::().ok()); + + Some(AzureRef { + org_url, + org, + project, + repo, + id, + }) +} + +/// Parse an Azure DevOps git remote URL into repository coordinates. +/// +/// Handles HTTPS (`https://[org@]dev.azure.com/{org}/{project}/_git/{repo}`, +/// `https://{org}.visualstudio.com/[collection/]{project}/_git/{repo}`) and SSH +/// (`git@ssh.dev.azure.com:v3/{org}/{project}/{repo}`). +fn parse_azure_remote(remote: &str) -> Option { + let remote = remote.trim().trim_end_matches(".git"); + + // SSH: git@ssh.dev.azure.com:v3/org/project/repo + if let Some(rest) = remote.split("ssh.dev.azure.com:").nth(1) { + let mut segs = rest.trim_start_matches('/').split('/'); + // Drop a leading "v3" path component when present. + let first = segs.next()?; + let org = if first == "v3" { segs.next()? } else { first }; + let project = segs.next()?; + let repo = segs.next()?; + return Some(AzureRef { + org_url: format!("https://dev.azure.com/{}", org), + org: org.to_string(), + project: decode_component(project), + repo: decode_component(repo), + id: None, + }); + } + + // HTTPS variants share the `/_git/` marker. + let no_scheme = remote + .trim_start_matches("https://") + .trim_start_matches("http://"); + // Strip any `user@` userinfo from the host segment. + let no_userinfo = match no_scheme.split_once('@') { + Some((_, after)) if after.contains('/') => after, + _ => no_scheme, + }; + let parts: Vec<&str> = no_userinfo.split('/').collect(); + let (org_url, org, project, repo) = azure_coords_from_parts(&parts)?; + + Some(AzureRef { + org_url, + org, + project, + repo, + id: None, + }) +} + +/// Decode the small set of percent-escapes that show up in Azure path segments +/// (notably `%20` for spaces in project names). +fn decode_component(segment: &str) -> String { + segment.replace("%20", " ") +} + +/// Minimal percent-encoding for an Azure `?path=` query value. +fn encode_path(path: &str) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(path.len()); + for b in path.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => { + let _ = write!(out, "%{:02X}", b); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_matches_pull_urls() { + assert!(GitHubProvider.matches_url("https://github.com/owner/repo/pull/123")); + assert!(!GitHubProvider.matches_url("https://dev.azure.com/o/p/_git/r/pullrequest/1")); + assert!(GitHubProvider.matches_origin("git@github.com:owner/repo.git")); + } + + #[test] + fn azure_matches_pr_urls() { + assert!(AzureProvider.matches_url("https://dev.azure.com/o/p/_git/r/pullrequest/42")); + assert!(AzureProvider.matches_url("https://myorg.visualstudio.com/p/_git/r/pullrequest/7")); + assert!(!AzureProvider.matches_url("https://github.com/owner/repo/pull/123")); + } + + #[test] + fn parse_azure_devazure_url() { + let r = parse_azure_url("https://dev.azure.com/myorg/MyProject/_git/myrepo/pullrequest/55") + .expect("should parse"); + assert_eq!(r.org_url, "https://dev.azure.com/myorg"); + assert_eq!(r.project, "MyProject"); + assert_eq!(r.repo, "myrepo"); + assert_eq!(r.id, Some(55)); + } + + #[test] + fn parse_azure_visualstudio_url() { + let r = parse_azure_url("https://myorg.visualstudio.com/MyProject/_git/myrepo/pullrequest/9") + .expect("should parse"); + assert_eq!(r.org_url, "https://myorg.visualstudio.com"); + assert_eq!(r.project, "MyProject"); + assert_eq!(r.repo, "myrepo"); + assert_eq!(r.id, Some(9)); + } + + #[test] + fn parse_azure_url_with_encoded_project() { + let r = parse_azure_url("https://dev.azure.com/org/My%20Project/_git/repo/pullrequest/1") + .expect("should parse"); + assert_eq!(r.project, "My Project"); + } + + #[test] + fn parse_azure_https_remote() { + let r = parse_azure_remote("https://myorg@dev.azure.com/myorg/MyProject/_git/myrepo") + .expect("should parse"); + assert_eq!(r.org_url, "https://dev.azure.com/myorg"); + assert_eq!(r.project, "MyProject"); + assert_eq!(r.repo, "myrepo"); + } + + #[test] + fn parse_azure_ssh_remote() { + let r = parse_azure_remote("git@ssh.dev.azure.com:v3/myorg/MyProject/myrepo") + .expect("should parse"); + assert_eq!(r.org_url, "https://dev.azure.com/myorg"); + assert_eq!(r.project, "MyProject"); + assert_eq!(r.repo, "myrepo"); + } + + #[test] + fn strip_ref_prefixes() { + assert_eq!(strip_ref_prefix("refs/heads/main"), "main"); + assert_eq!(strip_ref_prefix("refs/tags/v1"), "tags/v1"); + assert_eq!(strip_ref_prefix("feature/x"), "feature/x"); + } + + #[test] + fn encodes_path_query() { + assert_eq!(encode_path("/src/main.rs"), "%2Fsrc%2Fmain.rs"); + } + + #[test] + fn is_pr_reference_detects_forms() { + assert!(is_pr_reference("123")); + assert!(is_pr_reference("https://github.com/o/r/pull/1")); + assert!(is_pr_reference("https://dev.azure.com/o/p/_git/r/pullrequest/1")); + assert!(!is_pr_reference("main..feature")); + } +} diff --git a/src/config/cli.rs b/src/config/cli.rs index b34bd815..a6295d93 100644 --- a/src/config/cli.rs +++ b/src/config/cli.rs @@ -108,11 +108,13 @@ pub enum Commands { /// Launch interactive side-by-side diff viewer Diff { /// Commit reference: SHA, HEAD, HEAD~3..HEAD, main..feature, main...feature - /// Can also be a PR number or URL (e.g., 123 or https://github.com/owner/repo/pull/123) + /// Can also be a PR number or URL (GitHub: 123 or + /// https://github.com/owner/repo/pull/123; Azure DevOps: + /// https://dev.azure.com/org/project/_git/repo/pullrequest/123) #[arg(value_parser = clap::value_parser!(CommitReference))] reference: Option, - /// View a GitHub pull request (number or URL) + /// View a pull request (number or URL; GitHub via `gh`, Azure DevOps via its REST API) #[arg(long)] pr: Option, From eca161fdbba5382e4aeb36a6336bab219b386b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Fri, 5 Jun 2026 22:51:14 +0200 Subject: [PATCH 02/13] fix(diff): propagate Azure blob-fetch failures; dedupe percent-encoder - blob_text returns Result so a failed fetch can't silently empty a side and flip a file's status to Added/Deleted; the load fails instead. - drop the unreachable HTTP 203 branch in auth_hint. - hoist a single percent_encode helper into git.rs, used by both the Azure REST client and the PR web-URL builder. --- src/command/diff/azure.rs | 82 ++++++++++++++------------------- src/command/diff/git.rs | 17 +++++++ src/command/diff/pr_provider.rs | 23 ++------- 3 files changed, 54 insertions(+), 68 deletions(-) diff --git a/src/command/diff/azure.rs b/src/command/diff/azure.rs index c72322b9..dea254da 100644 --- a/src/command/diff/azure.rs +++ b/src/command/diff/azure.rs @@ -22,7 +22,7 @@ use serde_json::Value; use tokio::sync::Semaphore; use tokio::task::JoinSet; -use super::git::build_file_diff; +use super::git::{build_file_diff, percent_encode}; use super::types::FileDiff; use super::PrInfo; @@ -82,7 +82,7 @@ impl AdoClient { format!( "{}/{}/_apis/git/{}", self.base, - enc(&self.project), + percent_encode(&self.project), suffix ) } @@ -102,34 +102,36 @@ impl AdoClient { serde_json::from_str(&body).map_err(|e| format!("invalid JSON from Azure: {}", e)) } - /// Fetch a blob's text content. Returns empty string when `blob_id` is None - /// (the absent side of an add/delete) or on any error. - async fn blob_text(&self, blob_id: Option<&str>) -> String { + /// Fetch a blob's text content. An absent `blob_id` (the missing side of an + /// add/delete) is `Ok("")`; a failed fetch is an `Err` so it can't silently + /// empty a side and flip the file's status to Added/Deleted downstream. + async fn blob_text(&self, blob_id: Option<&str>) -> Result { let Some(blob_id) = blob_id else { - return String::new(); + return Ok(String::new()); }; let url = format!( "{}?$format=text&api-version={}", - self.git_url(&format!("repositories/{}/blobs/{}", enc(&self.repo), blob_id)), + self.git_url(&format!("repositories/{}/blobs/{}", percent_encode(&self.repo), blob_id)), API_VERSION ); - let Ok(resp) = self + let resp = self .authed(self.http.get(&url)) .header(ACCEPT, "text/plain") .send() .await - else { - return String::new(); - }; - if !resp.status().is_success() { - return String::new(); + .map_err(|e| format!("blob {} request failed: {}", blob_id, e))?; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(auth_hint(status, &body)); } // Read as bytes then lossy-decode so binary blobs degrade gracefully // (build_file_diff flags them as binary downstream). - match resp.bytes().await { - Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), - Err(_) => String::new(), - } + let bytes = resp + .bytes() + .await + .map_err(|e| format!("blob {} read failed: {}", blob_id, e))?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) } async fn latest_iteration(&self, pr_id: u64) -> Result { @@ -137,7 +139,7 @@ impl AdoClient { "{}?api-version={}", self.git_url(&format!( "repositories/{}/pullRequests/{}/iterations", - enc(&self.repo), + percent_encode(&self.repo), pr_id )), API_VERSION @@ -159,7 +161,7 @@ impl AdoClient { "{}?$compareTo=0&$top={}&$skip={}&api-version={}", self.git_url(&format!( "repositories/{}/pullRequests/{}/iterations/{}/changes", - enc(&self.repo), + percent_encode(&self.repo), pr_id, iteration )), @@ -195,22 +197,23 @@ impl AdoClient { let changes = self.changes(pr_id, iteration).await?; let sem = Arc::new(Semaphore::new(BLOB_CONCURRENCY)); - let mut set: JoinSet<(usize, FileDiff)> = JoinSet::new(); + let mut set: JoinSet> = JoinSet::new(); for (idx, change) in changes.into_iter().enumerate() { let client = self.clone(); let sem = Arc::clone(&sem); set.spawn(async move { - let _permit = sem.acquire_owned().await; - let old = client.blob_text(change.old_blob.as_deref()).await; - let new = client.blob_text(change.new_blob.as_deref()).await; - (idx, build_file_diff(change.path, old, new)) + let _permit = sem.acquire_owned().await.expect("blob semaphore not closed"); + let old = client.blob_text(change.old_blob.as_deref()).await?; + let new = client.blob_text(change.new_blob.as_deref()).await?; + Ok((idx, build_file_diff(change.path, old, new))) }); } // Reassemble in the original change order. let mut out: Vec> = Vec::new(); while let Some(res) = set.join_next().await { - let (idx, diff) = res.map_err(|e| format!("blob fetch task failed: {}", e))?; + // Outer `?`: the task panicked. Inner `?`: a blob fetch failed. + let (idx, diff) = res.map_err(|e| format!("blob fetch task failed: {}", e))??; if idx >= out.len() { out.resize_with(idx + 1, || None); } @@ -315,8 +318,8 @@ pub fn detect_active_pr( block_on(async move { let url = format!( "{}?searchCriteria.status=active&searchCriteria.sourceRefName={}&api-version={}", - client.git_url(&format!("repositories/{}/pullrequests", enc(repo))), - enc(&format!("refs/heads/{}", branch)), + client.git_url(&format!("repositories/{}/pullrequests", percent_encode(repo))), + percent_encode(&format!("refs/heads/{}", branch)), API_VERSION ); let json = client.get_json(&url).await?; @@ -394,7 +397,7 @@ fn resolve_auth() -> Result { } fn auth_hint(status: reqwest::StatusCode, body: &str) -> String { - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::NON_AUTHORITATIVE_INFORMATION { + if status == reqwest::StatusCode::UNAUTHORIZED { return "Azure DevOps auth failed (401). Check ADO_PAT scopes (Code: Read) or run `az login`.".to_string(); } if status == reqwest::StatusCode::FORBIDDEN { @@ -404,23 +407,6 @@ fn auth_hint(status: reqwest::StatusCode, body: &str) -> String { format!("Azure DevOps request failed ({}): {}", status, snippet.trim()) } -/// Percent-encode one URL path/query segment (keeps RFC 3986 unreserved chars). -fn enc(segment: &str) -> String { - use std::fmt::Write; - let mut out = String::with_capacity(segment.len()); - for b in segment.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) - } - _ => { - let _ = write!(out, "%{:02X}", b); - } - } - } - out -} - #[cfg(test)] mod tests { use super::*; @@ -475,8 +461,8 @@ mod tests { #[test] fn encodes_segments() { - assert_eq!(enc("My Project"), "My%20Project"); - assert_eq!(enc("refs/heads/feature/x"), "refs%2Fheads%2Ffeature%2Fx"); - assert_eq!(enc("simple-repo.git"), "simple-repo.git"); + assert_eq!(percent_encode("My Project"), "My%20Project"); + assert_eq!(percent_encode("refs/heads/feature/x"), "refs%2Fheads%2Ffeature%2Fx"); + assert_eq!(percent_encode("simple-repo.git"), "simple-repo.git"); } } diff --git a/src/command/diff/git.rs b/src/command/diff/git.rs index 57d7a968..6a612205 100644 --- a/src/command/diff/git.rs +++ b/src/command/diff/git.rs @@ -161,6 +161,23 @@ pub fn build_file_diff(filename: String, old_content: String, new_content: Strin } } +/// Percent-encode one URL path/query segment, keeping RFC 3986 unreserved chars. +pub(crate) fn percent_encode(segment: &str) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(segment.len()); + for b in segment.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => { + let _ = write!(out, "%{:02X}", b); + } + } + } + out +} + pub fn load_file_diffs(options: &DiffOptions, backend: &dyn VcsBackend) -> Vec { let refs = DiffRefs::from_options(options, backend); get_changed_files(options, backend) diff --git a/src/command/diff/pr_provider.rs b/src/command/diff/pr_provider.rs index 645268dc..2ec5007a 100644 --- a/src/command/diff/pr_provider.rs +++ b/src/command/diff/pr_provider.rs @@ -11,7 +11,7 @@ use std::process::Command; use std::thread; use super::azure; -use super::git::github_load_pr_file_diffs; +use super::git::{github_load_pr_file_diffs, percent_encode}; use super::types::FileDiff; use super::{ github_detect_current_branch_pr, github_fetch_pr_info, github_fetch_viewed_files, @@ -350,7 +350,7 @@ impl PrProvider for AzureProvider { project, pr.repo_name, pr.number, - encode_path(&format!("/{}", filename)) + percent_encode(&format!("/{}", filename)) )) } } @@ -470,23 +470,6 @@ fn decode_component(segment: &str) -> String { segment.replace("%20", " ") } -/// Minimal percent-encoding for an Azure `?path=` query value. -fn encode_path(path: &str) -> String { - use std::fmt::Write; - let mut out = String::with_capacity(path.len()); - for b in path.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) - } - _ => { - let _ = write!(out, "%{:02X}", b); - } - } - } - out -} - #[cfg(test)] mod tests { use super::*; @@ -559,7 +542,7 @@ mod tests { #[test] fn encodes_path_query() { - assert_eq!(encode_path("/src/main.rs"), "%2Fsrc%2Fmain.rs"); + assert_eq!(percent_encode("/src/main.rs"), "%2Fsrc%2Fmain.rs"); } #[test] From 57b8ab5d84ece26cc4303d99fcbdcf4631b64646 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Fri, 5 Jun 2026 23:16:37 +0200 Subject: [PATCH 03/13] refactor(diff): per-provider modules + registry, drop github_ prefixes Reorganise the PR-provider abstraction so adding a forge is one module plus one registry entry, and provider-specific code stops leaking across files: - pr_provider/ becomes a directory module: github.rs (all the gh-CLI logic, moved out of diff/mod.rs and git.rs and de-prefixed) and azure/ (mod.rs for URL/remote parsing + the PrProvider impl, client.rs for the REST client). - Replace ProviderKind + handler() match + per-method dispatch with a &'static [&dyn PrProvider] registry; selection iterates it. Store the provider as &'static dyn PrProvider directly on PrInfo (trait gains : Sync so it can cross the viewed-sync threads), dropping the enum entirely. - git.rs is now generic VCS only; mod.rs no longer holds github_* functions. Behaviour-preserving; errors are still String and PrInfo still carries the Azure Option fields (addressed in follow-up commits). --- src/command/diff/git.rs | 264 +------- src/command/diff/mod.rs | 288 +------- .../{azure.rs => pr_provider/azure/client.rs} | 6 +- .../azure/mod.rs} | 265 +------- src/command/diff/pr_provider/github.rs | 624 ++++++++++++++++++ src/command/diff/pr_provider/mod.rs | 171 +++++ 6 files changed, 814 insertions(+), 804 deletions(-) rename src/command/diff/{azure.rs => pr_provider/azure/client.rs} (99%) rename src/command/diff/{pr_provider.rs => pr_provider/azure/mod.rs} (53%) create mode 100644 src/command/diff/pr_provider/github.rs create mode 100644 src/command/diff/pr_provider/mod.rs diff --git a/src/command/diff/git.rs b/src/command/diff/git.rs index 6a612205..e3a12c9e 100644 --- a/src/command/diff/git.rs +++ b/src/command/diff/git.rs @@ -1,22 +1,11 @@ use std::fs; use std::path::Path; -use std::process::Command; -use std::sync::mpsc; -use std::sync::{Arc, Mutex}; -use std::thread; - -use spinoff::{spinners, Color, Spinner}; use super::types::{is_binary_content, FileDiff, FileStatus}; -use super::{DiffOptions, PrInfo}; +use super::DiffOptions; use crate::commit_reference::CommitReference; use crate::vcs::VcsBackend; -/// Max concurrent `gh api` requests when fetching PR file contents. -/// GitHub's documented secondary rate limit caps concurrent requests at 100 -/// (shared across REST+GraphQL); 8 keeps us comfortably under that while -/// still giving a large speedup over serial fetching. -const PR_FETCH_CONCURRENCY: usize = 8; pub fn get_current_branch(backend: &dyn VcsBackend) -> String { backend @@ -190,257 +179,6 @@ pub fn load_file_diffs(options: &DiffOptions, backend: &dyn VcsBackend) -> Vec Result, String> { - let repo_arg = format!("{}/{}", pr_info.repo_owner, pr_info.repo_name); - - let mut spinner = Spinner::new( - spinners::Dots, - format!( - "Fetching file list for {}/{}#{}", - pr_info.repo_owner, pr_info.repo_name, pr_info.number - ), - Color::Cyan, - ); - - // Get PR diff to find changed files - let output = Command::new("gh") - .args([ - "pr", - "diff", - &pr_info.number.to_string(), - "--repo", - &repo_arg, - ]) - .output(); - - let output = match output { - Ok(o) => o, - Err(e) => { - let msg = format!("Failed to run gh pr diff: {}", e); - spinner.fail(&msg); - return Err(msg); - } - }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let msg = format!("gh pr diff failed: {}", stderr.trim()); - spinner.fail(&msg); - return Err(msg); - } - - let diff_output = String::from_utf8_lossy(&output.stdout); - let changed_files = parse_changed_files_from_diff(&diff_output); - let n = changed_files.len(); - - if n == 0 { - spinner.success("PR has no changed files"); - return Ok(Vec::new()); - } - - let base_repo = format!("{}/{}", pr_info.base_repo_owner, pr_info.repo_name); - let head_repo = pr_info - .head_repo_owner - .as_ref() - .map(|owner| format!("{}/{}", owner, pr_info.repo_name)) - .unwrap_or_else(|| base_repo.clone()); - - let contents = fetch_pr_file_contents_parallel( - &changed_files, - &base_repo, - &pr_info.base_ref, - &head_repo, - &pr_info.head_ref, - &mut spinner, - ); - - let file_diffs: Vec = changed_files - .into_iter() - .zip(contents.into_iter()) - .map(|(filename, (old_content, new_content))| { - build_file_diff(filename, old_content, new_content) - }) - .collect(); - - spinner.success(&format!("Fetched {} files", n)); - Ok(file_diffs) -} - -#[derive(Clone, Copy)] -enum Side { - Old, - New, -} - -struct FetchTask { - idx: usize, - filename: String, - repo: String, - git_ref: String, - side: Side, -} - -enum FetchEvent { - Started(String), - Finished { - idx: usize, - side: Side, - filename: String, - content: String, - }, -} - -/// Fetch (old, new) contents for every changed file using a bounded worker -/// pool, updating `spinner` with live progress. -fn fetch_pr_file_contents_parallel( - files: &[String], - base_repo: &str, - base_ref: &str, - head_repo: &str, - head_ref: &str, - spinner: &mut Spinner, -) -> Vec<(String, String)> { - let n = files.len(); - let mut tasks: Vec = Vec::with_capacity(2 * n); - for (idx, filename) in files.iter().enumerate() { - tasks.push(FetchTask { - idx, - filename: filename.clone(), - repo: base_repo.to_string(), - git_ref: base_ref.to_string(), - side: Side::Old, - }); - tasks.push(FetchTask { - idx, - filename: filename.clone(), - repo: head_repo.to_string(), - git_ref: head_ref.to_string(), - side: Side::New, - }); - } - // Pop from the back, so process files in listed order. - tasks.reverse(); - - let total = tasks.len(); - let queue = Arc::new(Mutex::new(tasks)); - let (tx, rx) = mpsc::channel::(); - - let worker_count = PR_FETCH_CONCURRENCY.min(total); - let mut handles = Vec::with_capacity(worker_count); - for _ in 0..worker_count { - let queue = Arc::clone(&queue); - let tx = tx.clone(); - handles.push(thread::spawn(move || loop { - let task = { queue.lock().unwrap().pop() }; - let Some(task) = task else { break }; - let _ = tx.send(FetchEvent::Started(task.filename.clone())); - let content = fetch_file_content_from_github(&task.repo, &task.git_ref, &task.filename); - let _ = tx.send(FetchEvent::Finished { - idx: task.idx, - side: task.side, - filename: task.filename, - content, - }); - })); - } - drop(tx); - - let mut contents: Vec<(String, String)> = vec![(String::new(), String::new()); n]; - let mut done = 0usize; - let mut in_flight: Vec = Vec::new(); - let mut last_finished: Option = None; - - while let Ok(ev) = rx.recv() { - match ev { - FetchEvent::Started(name) => { - in_flight.push(name); - } - FetchEvent::Finished { - idx, - side, - filename, - content, - } => { - if let Some(pos) = in_flight.iter().position(|f| f == &filename) { - in_flight.swap_remove(pos); - } - match side { - Side::Old => contents[idx].0 = content, - Side::New => contents[idx].1 = content, - } - done += 1; - last_finished = Some(filename); - } - } - spinner.update_text(format_fetch_progress(done, total, &in_flight, last_finished.as_deref())); - } - - for h in handles { - let _ = h.join(); - } - - contents -} - -fn format_fetch_progress( - done: usize, - total: usize, - in_flight: &[String], - last_finished: Option<&str>, -) -> String { - let current = if let Some(name) = in_flight.last() { - name.as_str() - } else if let Some(name) = last_finished { - name - } else { - "" - }; - if current.is_empty() { - format!("Fetching files [{}/{}]", done, total) - } else { - format!("Fetching files [{}/{}] · {}", done, total, current) - } -} - -fn fetch_file_content_from_github(repo: &str, git_ref: &str, path: &str) -> String { - let api_path = format!("repos/{}/contents/{}?ref={}", repo, path, git_ref); - let output = Command::new("gh") - .args([ - "api", - &api_path, - "-H", - "Accept: application/vnd.github.raw+json", - ]) - .output(); - - match output { - Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), - _ => String::new(), - } -} - -fn parse_changed_files_from_diff(diff: &str) -> Vec { - let mut files = Vec::new(); - - for line in diff.lines() { - if line.starts_with("diff --git") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 4 { - let b_path = parts[3]; - if let Some(filename) = b_path.strip_prefix("b/") { - files.push(filename.to_string()); - } else { - files.push(b_path.to_string()); - } - } - } - } - - files -} - /// Load file diffs for a single commit (comparing commit to its parent). /// Uses VcsBackend for backend-agnostic file content retrieval. pub fn load_single_commit_diffs( diff --git a/src/command/diff/mod.rs b/src/command/diff/mod.rs index 0b09c550..77cd94a4 100644 --- a/src/command/diff/mod.rs +++ b/src/command/diff/mod.rs @@ -1,6 +1,5 @@ mod annotation; mod app; -mod azure; mod context; mod coordinates; mod diff_algo; @@ -17,9 +16,8 @@ pub mod theme; mod types; mod watcher; -use std::collections::HashSet; use std::io; -use std::process::{self, Command}; +use std::process; use spinoff::{spinners, Color, Spinner}; @@ -27,7 +25,7 @@ use crate::commit_reference::CommitReference; use crate::vcs::VcsBackend; pub use pr_provider::{ - fetch_viewed_files, mark_file_as_viewed_async, unmark_file_as_viewed_async, ProviderKind, + fetch_viewed_files, mark_file_as_viewed_async, unmark_file_as_viewed_async, PrProvider, }; pub struct DiffOptions { @@ -45,7 +43,7 @@ pub struct DiffOptions { #[derive(Clone)] pub struct PrInfo { - pub provider: ProviderKind, + pub provider: &'static dyn PrProvider, pub number: u64, pub node_id: String, pub repo_owner: String, @@ -60,286 +58,6 @@ pub struct PrInfo { pub org_url: Option, } -fn github_parse_pr_input(input: &str) -> Option<(Option, Option, u64)> { - // Try to parse as a URL first - if input.starts_with("http://") || input.starts_with("https://") { - // Extract PR number and repo info from URL - // Format: https://github.com/owner/repo/pull/123 - let parts: Vec<&str> = input.trim_end_matches('/').split('/').collect(); - if parts.len() >= 2 { - if let Some(pos) = parts.iter().position(|&p| p == "pull") { - if pos + 1 < parts.len() { - if let Ok(num) = parts[pos + 1].parse::() { - // Extract owner and repo - if pos >= 2 { - let owner = parts[pos - 2].to_string(); - let repo = parts[pos - 1].to_string(); - return Some((Some(owner), Some(repo), num)); - } - return Some((None, None, num)); - } - } - } - } - None - } else { - // Try to parse as a PR number - input.parse::().ok().map(|num| (None, None, num)) - } -} - -fn github_resolve_origin_repo() -> Result { - let output = Command::new("git") - .args(["remote", "get-url", "origin"]) - .output() - .map_err(|e| format!("Failed to run git: {}", e))?; - if !output.status.success() { - return Err( - "Could not determine repository. Set origin remote or use --origin owner/repo" - .to_string(), - ); - } - let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let url = url.strip_suffix(".git").unwrap_or(&url); - let path = url - .split("github.com") - .nth(1) - .ok_or_else(|| format!("Origin URL is not a GitHub URL: {}", url))?; - let path = path.trim_start_matches(':').trim_start_matches('/'); - let parts: Vec<&str> = path.split('/').collect(); - if parts.len() >= 2 { - Ok(format!("{}/{}", parts[0], parts[1])) - } else { - Err(format!("Could not parse owner/repo from origin URL: {}", url)) - } -} - -pub(crate) fn github_fetch_pr_info( - pr_input: &str, - repo_override: Option<&str>, -) -> Result { - let (owner, repo, number) = github_parse_pr_input(pr_input).ok_or_else(|| { - format!( - "Invalid PR reference: {}. Use a PR number or URL.", - pr_input - ) - })?; - - let repo_full = match (&owner, &repo, repo_override) { - (Some(o), Some(r), _) => format!("{}/{}", o, r), - (_, _, Some(r)) => r.to_string(), - _ => github_resolve_origin_repo()?, - }; - - let (repo_owner, repo_name) = { - let parts: Vec<&str> = repo_full.split('/').collect(); - if parts.len() != 2 { - return Err(format!("Invalid repo format: {}", repo_full)); - } - ( - owner.unwrap_or_else(|| parts[0].to_string()), - repo.unwrap_or_else(|| parts[1].to_string()), - ) - }; - - // Use GraphQL to get the PR node ID, branch refs, and repo owners - let query = format!( - r#"query {{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ id url baseRefName headRefName baseRepository {{ owner {{ login }} }} headRepository {{ owner {{ login }} }} }} }} }}"#, - repo_owner, repo_name, number - ); - - let output = Command::new("gh") - .args(["api", "graphql", "-f", &format!("query={}", query)]) - .output() - .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("gh api graphql failed: {}", stderr.trim())); - } - - let json_str = String::from_utf8_lossy(&output.stdout); - - // Parse the GraphQL response - let node_id = extract_json_string(&json_str, "id") - .ok_or_else(|| "Could not parse PR node ID from GraphQL response".to_string())?; - let base_ref = - extract_json_string(&json_str, "baseRefName").unwrap_or_else(|| "base".to_string()); - let head_ref = - extract_json_string(&json_str, "headRefName").unwrap_or_else(|| "head".to_string()); - - // Extract repo owners from nested structure - let base_repo_owner = - extract_nested_login(&json_str, "baseRepository").unwrap_or_else(|| repo_owner.clone()); - let head_repo_owner = extract_nested_login(&json_str, "headRepository"); - - Ok(PrInfo { - provider: ProviderKind::GitHub, - number, - node_id, - repo_owner, - repo_name, - base_ref, - head_ref, - base_repo_owner, - head_repo_owner, - project: None, - org_url: None, - }) -} - -fn extract_json_string(json: &str, key: &str) -> Option { - let pattern = format!("\"{}\":\"", key); - if let Some(start) = json.find(&pattern) { - let value_start = start + pattern.len(); - if let Some(end) = json[value_start..].find('"') { - return Some(json[value_start..value_start + end].to_string()); - } - } - None -} - -fn extract_nested_login(json: &str, parent_key: &str) -> Option { - // Look for pattern like "baseRepository":{"owner":{"login":"username"}} - // or handle null case like "headRepository":null - let pattern = format!("\"{}\":", parent_key); - if let Some(start) = json.find(&pattern) { - let after_key = &json[start + pattern.len()..]; - // Check if it's null - if after_key.trim_start().starts_with("null") { - return None; - } - // Look for login within this section - if let Some(login_start) = after_key.find("\"login\":\"") { - let value_start = login_start + 9; - let after_login = &after_key[value_start..]; - if let Some(end) = after_login.find('"') { - return Some(after_login[..end].to_string()); - } - } - } - None -} - -/// Fetch the list of files that are marked as viewed on GitHub -pub(crate) fn github_fetch_viewed_files(pr_info: &PrInfo) -> Result, String> { - let query = format!( - r#"query {{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ files(first: 100) {{ nodes {{ path viewerViewedState }} }} }} }} }}"#, - pr_info.repo_owner, pr_info.repo_name, pr_info.number - ); - - let output = Command::new("gh") - .args(["api", "graphql", "-f", &format!("query={}", query)]) - .output() - .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("gh api graphql failed: {}", stderr.trim())); - } - - let json_str = String::from_utf8_lossy(&output.stdout); - - // Parse the response to find viewed files - // Look for patterns like: "path":"filename","viewerViewedState":"VIEWED" - let mut viewed_files = HashSet::new(); - - // Simple parsing: find all path/viewerViewedState pairs - let mut remaining = json_str.as_ref(); - while let Some(path_start) = remaining.find("\"path\":\"") { - let path_value_start = path_start + 8; - let after_path = &remaining[path_value_start..]; - if let Some(path_end) = after_path.find('"') { - let path = &after_path[..path_end]; - - // Look for viewerViewedState after this path - let after_path_str = &after_path[path_end..]; - if let Some(state_start) = after_path_str.find("\"viewerViewedState\":\"") { - let state_value_start = state_start + 21; - let after_state = &after_path_str[state_value_start..]; - if let Some(state_end) = after_state.find('"') { - let state = &after_state[..state_end]; - if state == "VIEWED" { - viewed_files.insert(path.to_string()); - } - } - } - - remaining = &remaining[path_value_start + path_end..]; - } else { - break; - } - } - - Ok(viewed_files) -} - -/// Mark a file as viewed on GitHub PR (blocking) -pub(crate) fn github_mark_file_as_viewed_sync( - node_id: &str, - file_path: &str, -) -> Result<(), String> { - let mutation = format!( - r#"mutation {{ markFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, - node_id, file_path - ); - - let output = Command::new("gh") - .args(["api", "graphql", "-f", &format!("query={}", mutation)]) - .output() - .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(stderr.trim().to_string()); - } - - Ok(()) -} - -/// Unmark a file as viewed on GitHub PR (blocking) -pub(crate) fn github_unmark_file_as_viewed_sync( - node_id: &str, - file_path: &str, -) -> Result<(), String> { - let mutation = format!( - r#"mutation {{ unmarkFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, - node_id, file_path - ); - - let output = Command::new("gh") - .args(["api", "graphql", "-f", &format!("query={}", mutation)]) - .output() - .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(stderr.trim().to_string()); - } - - Ok(()) -} - -pub(crate) fn github_detect_current_branch_pr() -> Result { - let output = Command::new("gh") - .args(["pr", "view", "--json", "number", "-q", ".number"]) - .output() - .map_err(|e| format!("Failed to run gh: {}", e))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let msg = stderr.trim(); - if msg.is_empty() { - return Err("No PR found for the current branch".to_string()); - } - return Err(msg.to_string()); - } - let number = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if number.is_empty() { - return Err("No PR found for the current branch".to_string()); - } - Ok(number) -} - pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Result<()> { // Resolve --detect-pr into options.pr if options.detect_pr && options.pr.is_none() { diff --git a/src/command/diff/azure.rs b/src/command/diff/pr_provider/azure/client.rs similarity index 99% rename from src/command/diff/azure.rs rename to src/command/diff/pr_provider/azure/client.rs index dea254da..98d41e89 100644 --- a/src/command/diff/azure.rs +++ b/src/command/diff/pr_provider/azure/client.rs @@ -22,9 +22,9 @@ use serde_json::Value; use tokio::sync::Semaphore; use tokio::task::JoinSet; -use super::git::{build_file_diff, percent_encode}; -use super::types::FileDiff; -use super::PrInfo; +use crate::command::diff::git::{build_file_diff, percent_encode}; +use crate::command::diff::types::FileDiff; +use crate::command::diff::PrInfo; const API_VERSION: &str = "7.1"; /// Azure DevOps OAuth resource id, used with `az account get-access-token`. diff --git a/src/command/diff/pr_provider.rs b/src/command/diff/pr_provider/azure/mod.rs similarity index 53% rename from src/command/diff/pr_provider.rs rename to src/command/diff/pr_provider/azure/mod.rs index 2ec5007a..ef0badf7 100644 --- a/src/command/diff/pr_provider.rs +++ b/src/command/diff/pr_provider/azure/mod.rs @@ -1,241 +1,15 @@ -//! Pull-request hosting provider abstraction. -//! -//! `lumen diff --pr` originally only understood GitHub (it shelled out to the -//! `gh` CLI everywhere). This module introduces a [`PrProvider`] trait so other -//! forges can be supported, with `gh` for GitHub and `az` (the Azure DevOps CLI) -//! for Azure DevOps. New providers (e.g. `glab` for GitLab) only need to add an -//! impl and wire it into provider detection. - -use std::collections::HashSet; -use std::process::Command; -use std::thread; - -use super::azure; -use super::git::{github_load_pr_file_diffs, percent_encode}; -use super::types::FileDiff; -use super::{ - github_detect_current_branch_pr, github_fetch_pr_info, github_fetch_viewed_files, - github_mark_file_as_viewed_sync, github_unmark_file_as_viewed_sync, PrInfo, -}; - -/// Which hosting provider a [`PrInfo`] belongs to. Stored on `PrInfo` so the -/// runtime (viewed-file sync, open-in-browser, reloads) can route back to the -/// right provider without re-parsing the original input. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ProviderKind { - GitHub, - Azure, -} - -impl ProviderKind { - /// The provider implementation. Both providers are zero-sized, so this hands - /// back a `'static` reference with no allocation. - pub fn handler(self) -> &'static dyn PrProvider { - match self { - ProviderKind::GitHub => &GitHubProvider, - ProviderKind::Azure => &AzureProvider, - } - } -} - -/// A pull-request hosting provider. Each method maps to one capability the diff -/// UI needs; the viewed-file sync methods default to no-ops so providers without -/// that concept (Azure DevOps) don't have to implement them. -pub trait PrProvider { - /// Does this provider recognise `input` as one of its PR URLs? - fn matches_url(&self, input: &str) -> bool; - - /// Does this provider recognise `origin` (a git remote URL) as one of its - /// repositories? Used to pick a provider for bare PR numbers. - fn matches_origin(&self, origin: &str) -> bool; - - /// Resolve a PR number/URL into full metadata. - fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result; - - /// Find the PR associated with the current branch. - fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result; - - /// Load the file diffs for a PR. - fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String>; - - /// Whether this provider supports syncing per-file "viewed" state. - fn supports_viewed_sync(&self) -> bool { - false - } - - /// Fetch the set of paths currently marked as viewed. - fn fetch_viewed_files(&self, _pr: &PrInfo) -> Result, String> { - Ok(HashSet::new()) - } - - /// Mark/unmark a file as viewed (blocking). - fn set_file_viewed(&self, _pr: &PrInfo, _path: &str, _viewed: bool) -> Result<(), String> { - Ok(()) - } - - /// Build a browser URL for `filename` within the PR. - fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option; -} - -// --------------------------------------------------------------------------- -// Provider selection -// --------------------------------------------------------------------------- - -/// True if `input` looks like a PR reference (a known PR URL or a bare number). -pub fn is_pr_reference(input: &str) -> bool { - GitHubProvider.matches_url(input) - || AzureProvider.matches_url(input) - || input.parse::().is_ok() -} - -fn read_origin_url() -> Option { - let output = Command::new("git") - .args(["remote", "get-url", "origin"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if url.is_empty() { - None - } else { - Some(url) - } -} - -/// Pick a provider from the git `origin` remote (and any `--origin` override), -/// defaulting to GitHub when nothing matches. -fn provider_for_origin(repo_override: Option<&str>) -> &'static dyn PrProvider { - let candidates = [repo_override.map(|s| s.to_string()), read_origin_url()]; - for candidate in candidates.into_iter().flatten() { - if AzureProvider.matches_origin(&candidate) { - return ProviderKind::Azure.handler(); - } - } - ProviderKind::GitHub.handler() -} +//! Azure DevOps provider: URL/remote parsing and the [`PrProvider`] impl. The +//! REST client lives in [`client`]. -/// Pick a provider from a PR URL/number, falling back to origin detection. -fn provider_for_input(input: &str, repo_override: Option<&str>) -> &'static dyn PrProvider { - if AzureProvider.matches_url(input) { - return ProviderKind::Azure.handler(); - } - if GitHubProvider.matches_url(input) { - return ProviderKind::GitHub.handler(); - } - provider_for_origin(repo_override) -} - -// --------------------------------------------------------------------------- -// Dispatchers used by the rest of the diff UI -// --------------------------------------------------------------------------- - -pub fn fetch_pr_info(input: &str, repo_override: Option<&str>) -> Result { - provider_for_input(input, repo_override).fetch_pr_info(input, repo_override) -} - -pub fn detect_current_branch_pr(repo_override: Option<&str>) -> Result { - provider_for_origin(repo_override).detect_current_branch_pr(repo_override) -} - -pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, String> { - pr.provider.handler().load_pr_file_diffs(pr) -} - -pub fn fetch_viewed_files(pr: &PrInfo) -> Result, String> { - pr.provider.handler().fetch_viewed_files(pr) -} - -pub fn pr_file_web_url(pr: &PrInfo, filename: &str) -> Option { - pr.provider.handler().file_web_url(pr, filename) -} +mod client; -pub fn mark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { - set_file_viewed_async(pr, file_path, true); -} - -pub fn unmark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { - set_file_viewed_async(pr, file_path, false); -} - -fn set_file_viewed_async(pr: &PrInfo, file_path: &str, viewed: bool) { - if !pr.provider.handler().supports_viewed_sync() { - return; - } - let pr = pr.clone(); - let path = file_path.to_string(); - thread::spawn(move || { - let _ = pr.provider.handler().set_file_viewed(&pr, &path, viewed); - }); -} - -/// SHA-256 file anchor used by GitHub's PR "Files changed" deep links -/// (`#diff-`). -fn github_file_anchor(filename: &str) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(filename.as_bytes()); - format!("{:x}", hasher.finalize()) -} - -// --------------------------------------------------------------------------- -// GitHub -// --------------------------------------------------------------------------- - -pub struct GitHubProvider; - -impl PrProvider for GitHubProvider { - fn matches_url(&self, input: &str) -> bool { - input.starts_with("http") && input.contains("/pull/") - } - - fn matches_origin(&self, origin: &str) -> bool { - origin.contains("github.com") - } - - fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { - github_fetch_pr_info(input, repo_override) - } - - fn detect_current_branch_pr(&self, _repo_override: Option<&str>) -> Result { - github_detect_current_branch_pr() - } - - fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String> { - github_load_pr_file_diffs(pr) - } - - fn supports_viewed_sync(&self) -> bool { - true - } - - fn fetch_viewed_files(&self, pr: &PrInfo) -> Result, String> { - github_fetch_viewed_files(pr) - } - - fn set_file_viewed(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), String> { - if viewed { - github_mark_file_as_viewed_sync(&pr.node_id, path) - } else { - github_unmark_file_as_viewed_sync(&pr.node_id, path) - } - } +use std::process::Command; - fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { - Some(format!( - "https://github.com/{}/{}/pull/{}/files#diff-{}", - pr.repo_owner, - pr.repo_name, - pr.number, - github_file_anchor(filename) - )) - } -} +use crate::command::diff::git::percent_encode; +use crate::command::diff::types::FileDiff; +use crate::command::diff::PrInfo; -// --------------------------------------------------------------------------- -// Azure DevOps -// --------------------------------------------------------------------------- +use super::{read_origin_url, PrProvider}; pub struct AzureProvider; @@ -293,10 +67,10 @@ impl PrProvider for AzureProvider { .id .ok_or_else(|| format!("No PR id found in: {}", input))?; - let meta = azure::fetch_pr_metadata(&az.org_url, &az.project, &az.repo, id)?; + let meta = client::fetch_pr_metadata(&az.org_url, &az.project, &az.repo, id)?; Ok(PrInfo { - provider: ProviderKind::Azure, + provider: &AzureProvider, number: id, node_id: String::new(), repo_owner: az.org.clone(), @@ -333,12 +107,12 @@ impl PrProvider for AzureProvider { return Err("Could not determine the current branch".to_string()); } - let id = azure::detect_active_pr(&az.org_url, &az.project, &az.repo, &branch)?; + let id = client::detect_active_pr(&az.org_url, &az.project, &az.repo, &branch)?; Ok(id.to_string()) } fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String> { - azure::load_pr_file_diffs(pr) + client::load_pr_file_diffs(pr) } fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { @@ -474,13 +248,6 @@ fn decode_component(segment: &str) -> String { mod tests { use super::*; - #[test] - fn github_matches_pull_urls() { - assert!(GitHubProvider.matches_url("https://github.com/owner/repo/pull/123")); - assert!(!GitHubProvider.matches_url("https://dev.azure.com/o/p/_git/r/pullrequest/1")); - assert!(GitHubProvider.matches_origin("git@github.com:owner/repo.git")); - } - #[test] fn azure_matches_pr_urls() { assert!(AzureProvider.matches_url("https://dev.azure.com/o/p/_git/r/pullrequest/42")); @@ -544,12 +311,4 @@ mod tests { fn encodes_path_query() { assert_eq!(percent_encode("/src/main.rs"), "%2Fsrc%2Fmain.rs"); } - - #[test] - fn is_pr_reference_detects_forms() { - assert!(is_pr_reference("123")); - assert!(is_pr_reference("https://github.com/o/r/pull/1")); - assert!(is_pr_reference("https://dev.azure.com/o/p/_git/r/pullrequest/1")); - assert!(!is_pr_reference("main..feature")); - } } diff --git a/src/command/diff/pr_provider/github.rs b/src/command/diff/pr_provider/github.rs new file mode 100644 index 00000000..4477941f --- /dev/null +++ b/src/command/diff/pr_provider/github.rs @@ -0,0 +1,624 @@ +//! GitHub provider: everything is driven through the `gh` CLI (GraphQL for PR +//! metadata and viewed-file state, the contents API for file blobs). + +use std::collections::HashSet; +use std::process::Command; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; + +use spinoff::{spinners, Color, Spinner}; + +use crate::command::diff::git::build_file_diff; +use crate::command::diff::types::FileDiff; +use crate::command::diff::PrInfo; + +use super::PrProvider; + +/// Max concurrent `gh api` requests when fetching PR file contents. +/// GitHub's documented secondary rate limit caps concurrent requests at 100 +/// (shared across REST+GraphQL); 8 keeps us comfortably under that while +/// still giving a large speedup over serial fetching. +const PR_FETCH_CONCURRENCY: usize = 8; + +pub struct GitHubProvider; + +impl PrProvider for GitHubProvider { + fn matches_url(&self, input: &str) -> bool { + input.starts_with("http") && input.contains("/pull/") + } + + fn matches_origin(&self, origin: &str) -> bool { + origin.contains("github.com") + } + + fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { + fetch_pr_info(input, repo_override) + } + + fn detect_current_branch_pr(&self, _repo_override: Option<&str>) -> Result { + detect_current_branch_pr() + } + + fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String> { + load_pr_file_diffs(pr) + } + + fn supports_viewed_sync(&self) -> bool { + true + } + + fn fetch_viewed_files(&self, pr: &PrInfo) -> Result, String> { + fetch_viewed_files(pr) + } + + fn set_file_viewed(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), String> { + if viewed { + mark_file_as_viewed_sync(&pr.node_id, path) + } else { + unmark_file_as_viewed_sync(&pr.node_id, path) + } + } + + fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { + Some(format!( + "https://github.com/{}/{}/pull/{}/files#diff-{}", + pr.repo_owner, + pr.repo_name, + pr.number, + file_anchor(filename) + )) + } +} + +// --------------------------------------------------------------------------- +// PR metadata +// --------------------------------------------------------------------------- + +fn parse_pr_input(input: &str) -> Option<(Option, Option, u64)> { + // Try to parse as a URL first + if input.starts_with("http://") || input.starts_with("https://") { + // Extract PR number and repo info from URL + // Format: https://github.com/owner/repo/pull/123 + let parts: Vec<&str> = input.trim_end_matches('/').split('/').collect(); + if parts.len() >= 2 { + if let Some(pos) = parts.iter().position(|&p| p == "pull") { + if pos + 1 < parts.len() { + if let Ok(num) = parts[pos + 1].parse::() { + // Extract owner and repo + if pos >= 2 { + let owner = parts[pos - 2].to_string(); + let repo = parts[pos - 1].to_string(); + return Some((Some(owner), Some(repo), num)); + } + return Some((None, None, num)); + } + } + } + } + None + } else { + // Try to parse as a PR number + input.parse::().ok().map(|num| (None, None, num)) + } +} + +fn resolve_origin_repo() -> Result { + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + .map_err(|e| format!("Failed to run git: {}", e))?; + if !output.status.success() { + return Err( + "Could not determine repository. Set origin remote or use --origin owner/repo" + .to_string(), + ); + } + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let url = url.strip_suffix(".git").unwrap_or(&url); + let path = url + .split("github.com") + .nth(1) + .ok_or_else(|| format!("Origin URL is not a GitHub URL: {}", url))?; + let path = path.trim_start_matches(':').trim_start_matches('/'); + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() >= 2 { + Ok(format!("{}/{}", parts[0], parts[1])) + } else { + Err(format!("Could not parse owner/repo from origin URL: {}", url)) + } +} + +fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result { + let (owner, repo, number) = parse_pr_input(pr_input).ok_or_else(|| { + format!( + "Invalid PR reference: {}. Use a PR number or URL.", + pr_input + ) + })?; + + let repo_full = match (&owner, &repo, repo_override) { + (Some(o), Some(r), _) => format!("{}/{}", o, r), + (_, _, Some(r)) => r.to_string(), + _ => resolve_origin_repo()?, + }; + + let (repo_owner, repo_name) = { + let parts: Vec<&str> = repo_full.split('/').collect(); + if parts.len() != 2 { + return Err(format!("Invalid repo format: {}", repo_full)); + } + ( + owner.unwrap_or_else(|| parts[0].to_string()), + repo.unwrap_or_else(|| parts[1].to_string()), + ) + }; + + // Use GraphQL to get the PR node ID, branch refs, and repo owners + let query = format!( + r#"query {{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ id url baseRefName headRefName baseRepository {{ owner {{ login }} }} headRepository {{ owner {{ login }} }} }} }} }}"#, + repo_owner, repo_name, number + ); + + let output = Command::new("gh") + .args(["api", "graphql", "-f", &format!("query={}", query)]) + .output() + .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("gh api graphql failed: {}", stderr.trim())); + } + + let json_str = String::from_utf8_lossy(&output.stdout); + + // Parse the GraphQL response + let node_id = extract_json_string(&json_str, "id") + .ok_or_else(|| "Could not parse PR node ID from GraphQL response".to_string())?; + let base_ref = + extract_json_string(&json_str, "baseRefName").unwrap_or_else(|| "base".to_string()); + let head_ref = + extract_json_string(&json_str, "headRefName").unwrap_or_else(|| "head".to_string()); + + // Extract repo owners from nested structure + let base_repo_owner = + extract_nested_login(&json_str, "baseRepository").unwrap_or_else(|| repo_owner.clone()); + let head_repo_owner = extract_nested_login(&json_str, "headRepository"); + + Ok(PrInfo { + provider: &GitHubProvider, + number, + node_id, + repo_owner, + repo_name, + base_ref, + head_ref, + base_repo_owner, + head_repo_owner, + project: None, + org_url: None, + }) +} + +fn extract_json_string(json: &str, key: &str) -> Option { + let pattern = format!("\"{}\":\"", key); + if let Some(start) = json.find(&pattern) { + let value_start = start + pattern.len(); + if let Some(end) = json[value_start..].find('"') { + return Some(json[value_start..value_start + end].to_string()); + } + } + None +} + +fn extract_nested_login(json: &str, parent_key: &str) -> Option { + // Look for pattern like "baseRepository":{"owner":{"login":"username"}} + // or handle null case like "headRepository":null + let pattern = format!("\"{}\":", parent_key); + if let Some(start) = json.find(&pattern) { + let after_key = &json[start + pattern.len()..]; + // Check if it's null + if after_key.trim_start().starts_with("null") { + return None; + } + // Look for login within this section + if let Some(login_start) = after_key.find("\"login\":\"") { + let value_start = login_start + 9; + let after_login = &after_key[value_start..]; + if let Some(end) = after_login.find('"') { + return Some(after_login[..end].to_string()); + } + } + } + None +} + +fn detect_current_branch_pr() -> Result { + let output = Command::new("gh") + .args(["pr", "view", "--json", "number", "-q", ".number"]) + .output() + .map_err(|e| format!("Failed to run gh: {}", e))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let msg = stderr.trim(); + if msg.is_empty() { + return Err("No PR found for the current branch".to_string()); + } + return Err(msg.to_string()); + } + let number = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if number.is_empty() { + return Err("No PR found for the current branch".to_string()); + } + Ok(number) +} + +/// SHA-256 file anchor used by GitHub's PR "Files changed" deep links +/// (`#diff-`). +fn file_anchor(filename: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(filename.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +// --------------------------------------------------------------------------- +// Viewed-file state +// --------------------------------------------------------------------------- + +/// Fetch the list of files that are marked as viewed on GitHub +fn fetch_viewed_files(pr_info: &PrInfo) -> Result, String> { + let query = format!( + r#"query {{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ files(first: 100) {{ nodes {{ path viewerViewedState }} }} }} }} }}"#, + pr_info.repo_owner, pr_info.repo_name, pr_info.number + ); + + let output = Command::new("gh") + .args(["api", "graphql", "-f", &format!("query={}", query)]) + .output() + .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("gh api graphql failed: {}", stderr.trim())); + } + + let json_str = String::from_utf8_lossy(&output.stdout); + + // Parse the response to find viewed files + // Look for patterns like: "path":"filename","viewerViewedState":"VIEWED" + let mut viewed_files = HashSet::new(); + + // Simple parsing: find all path/viewerViewedState pairs + let mut remaining = json_str.as_ref(); + while let Some(path_start) = remaining.find("\"path\":\"") { + let path_value_start = path_start + 8; + let after_path = &remaining[path_value_start..]; + if let Some(path_end) = after_path.find('"') { + let path = &after_path[..path_end]; + + // Look for viewerViewedState after this path + let after_path_str = &after_path[path_end..]; + if let Some(state_start) = after_path_str.find("\"viewerViewedState\":\"") { + let state_value_start = state_start + 21; + let after_state = &after_path_str[state_value_start..]; + if let Some(state_end) = after_state.find('"') { + let state = &after_state[..state_end]; + if state == "VIEWED" { + viewed_files.insert(path.to_string()); + } + } + } + + remaining = &remaining[path_value_start + path_end..]; + } else { + break; + } + } + + Ok(viewed_files) +} + +/// Mark a file as viewed on GitHub PR (blocking) +fn mark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String> { + let mutation = format!( + r#"mutation {{ markFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, + node_id, file_path + ); + + let output = Command::new("gh") + .args(["api", "graphql", "-f", &format!("query={}", mutation)]) + .output() + .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.trim().to_string()); + } + + Ok(()) +} + +/// Unmark a file as viewed on GitHub PR (blocking) +fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String> { + let mutation = format!( + r#"mutation {{ unmarkFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, + node_id, file_path + ); + + let output = Command::new("gh") + .args(["api", "graphql", "-f", &format!("query={}", mutation)]) + .output() + .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.trim().to_string()); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// File diffs (gh pr diff + parallel contents fetch) +// --------------------------------------------------------------------------- + +fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, String> { + let repo_arg = format!("{}/{}", pr_info.repo_owner, pr_info.repo_name); + + let mut spinner = Spinner::new( + spinners::Dots, + format!( + "Fetching file list for {}/{}#{}", + pr_info.repo_owner, pr_info.repo_name, pr_info.number + ), + Color::Cyan, + ); + + // Get PR diff to find changed files + let output = Command::new("gh") + .args([ + "pr", + "diff", + &pr_info.number.to_string(), + "--repo", + &repo_arg, + ]) + .output(); + + let output = match output { + Ok(o) => o, + Err(e) => { + let msg = format!("Failed to run gh pr diff: {}", e); + spinner.fail(&msg); + return Err(msg); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let msg = format!("gh pr diff failed: {}", stderr.trim()); + spinner.fail(&msg); + return Err(msg); + } + + let diff_output = String::from_utf8_lossy(&output.stdout); + let changed_files = parse_changed_files_from_diff(&diff_output); + let n = changed_files.len(); + + if n == 0 { + spinner.success("PR has no changed files"); + return Ok(Vec::new()); + } + + let base_repo = format!("{}/{}", pr_info.base_repo_owner, pr_info.repo_name); + let head_repo = pr_info + .head_repo_owner + .as_ref() + .map(|owner| format!("{}/{}", owner, pr_info.repo_name)) + .unwrap_or_else(|| base_repo.clone()); + + let contents = fetch_pr_file_contents_parallel( + &changed_files, + &base_repo, + &pr_info.base_ref, + &head_repo, + &pr_info.head_ref, + &mut spinner, + ); + + let file_diffs: Vec = changed_files + .into_iter() + .zip(contents.into_iter()) + .map(|(filename, (old_content, new_content))| { + build_file_diff(filename, old_content, new_content) + }) + .collect(); + + spinner.success(&format!("Fetched {} files", n)); + Ok(file_diffs) +} + +#[derive(Clone, Copy)] +enum Side { + Old, + New, +} + +struct FetchTask { + idx: usize, + filename: String, + repo: String, + git_ref: String, + side: Side, +} + +enum FetchEvent { + Started(String), + Finished { + idx: usize, + side: Side, + filename: String, + content: String, + }, +} + +/// Fetch (old, new) contents for every changed file using a bounded worker +/// pool, updating `spinner` with live progress. +fn fetch_pr_file_contents_parallel( + files: &[String], + base_repo: &str, + base_ref: &str, + head_repo: &str, + head_ref: &str, + spinner: &mut Spinner, +) -> Vec<(String, String)> { + let n = files.len(); + let mut tasks: Vec = Vec::with_capacity(2 * n); + for (idx, filename) in files.iter().enumerate() { + tasks.push(FetchTask { + idx, + filename: filename.clone(), + repo: base_repo.to_string(), + git_ref: base_ref.to_string(), + side: Side::Old, + }); + tasks.push(FetchTask { + idx, + filename: filename.clone(), + repo: head_repo.to_string(), + git_ref: head_ref.to_string(), + side: Side::New, + }); + } + // Pop from the back, so process files in listed order. + tasks.reverse(); + + let total = tasks.len(); + let queue = Arc::new(Mutex::new(tasks)); + let (tx, rx) = mpsc::channel::(); + + let worker_count = PR_FETCH_CONCURRENCY.min(total); + let mut handles = Vec::with_capacity(worker_count); + for _ in 0..worker_count { + let queue = Arc::clone(&queue); + let tx = tx.clone(); + handles.push(thread::spawn(move || loop { + let task = { queue.lock().unwrap().pop() }; + let Some(task) = task else { break }; + let _ = tx.send(FetchEvent::Started(task.filename.clone())); + let content = fetch_file_content_from_github(&task.repo, &task.git_ref, &task.filename); + let _ = tx.send(FetchEvent::Finished { + idx: task.idx, + side: task.side, + filename: task.filename, + content, + }); + })); + } + drop(tx); + + let mut contents: Vec<(String, String)> = vec![(String::new(), String::new()); n]; + let mut done = 0usize; + let mut in_flight: Vec = Vec::new(); + let mut last_finished: Option = None; + + while let Ok(ev) = rx.recv() { + match ev { + FetchEvent::Started(name) => { + in_flight.push(name); + } + FetchEvent::Finished { + idx, + side, + filename, + content, + } => { + if let Some(pos) = in_flight.iter().position(|f| f == &filename) { + in_flight.swap_remove(pos); + } + match side { + Side::Old => contents[idx].0 = content, + Side::New => contents[idx].1 = content, + } + done += 1; + last_finished = Some(filename); + } + } + spinner.update_text(format_fetch_progress(done, total, &in_flight, last_finished.as_deref())); + } + + for h in handles { + let _ = h.join(); + } + + contents +} + +fn format_fetch_progress( + done: usize, + total: usize, + in_flight: &[String], + last_finished: Option<&str>, +) -> String { + let current = if let Some(name) = in_flight.last() { + name.as_str() + } else if let Some(name) = last_finished { + name + } else { + "" + }; + if current.is_empty() { + format!("Fetching files [{}/{}]", done, total) + } else { + format!("Fetching files [{}/{}] · {}", done, total, current) + } +} + +fn fetch_file_content_from_github(repo: &str, git_ref: &str, path: &str) -> String { + let api_path = format!("repos/{}/contents/{}?ref={}", repo, path, git_ref); + let output = Command::new("gh") + .args([ + "api", + &api_path, + "-H", + "Accept: application/vnd.github.raw+json", + ]) + .output(); + + match output { + Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), + _ => String::new(), + } +} + +fn parse_changed_files_from_diff(diff: &str) -> Vec { + let mut files = Vec::new(); + + for line in diff.lines() { + if line.starts_with("diff --git") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 4 { + let b_path = parts[3]; + if let Some(filename) = b_path.strip_prefix("b/") { + files.push(filename.to_string()); + } else { + files.push(b_path.to_string()); + } + } + } + } + + files +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn github_matches_pull_urls() { + assert!(GitHubProvider.matches_url("https://github.com/owner/repo/pull/123")); + assert!(!GitHubProvider.matches_url("https://dev.azure.com/o/p/_git/r/pullrequest/1")); + assert!(GitHubProvider.matches_origin("git@github.com:owner/repo.git")); + } +} diff --git a/src/command/diff/pr_provider/mod.rs b/src/command/diff/pr_provider/mod.rs new file mode 100644 index 00000000..d9622667 --- /dev/null +++ b/src/command/diff/pr_provider/mod.rs @@ -0,0 +1,171 @@ +//! Pull-request hosting provider abstraction. +//! +//! `lumen diff --pr` originally only understood GitHub (it shelled out to the +//! `gh` CLI everywhere). This module introduces a [`PrProvider`] trait so other +//! forges can be supported, with [`github`] for GitHub and [`azure`] for Azure +//! DevOps. Adding a forge (e.g. `glab` for GitLab) is one new module plus one +//! entry in [`PROVIDERS`]. + +mod azure; +mod github; + +use std::collections::HashSet; +use std::process::Command; +use std::thread; + +use super::types::FileDiff; +use super::PrInfo; + +use azure::AzureProvider; +use github::GitHubProvider; + +/// A pull-request hosting provider. Each method maps to one capability the diff +/// UI needs; the viewed-file sync methods default to no-ops so providers without +/// that concept (Azure DevOps) don't have to implement them. +/// +/// `Sync` is required so a `&'static dyn PrProvider` (stored on [`PrInfo`]) can +/// be moved into the background threads that sync viewed-file state. +pub trait PrProvider: Sync { + /// Does this provider recognise `input` as one of its PR URLs? + fn matches_url(&self, input: &str) -> bool; + + /// Does this provider recognise `origin` (a git remote URL) as one of its + /// repositories? Used to pick a provider for bare PR numbers. + fn matches_origin(&self, origin: &str) -> bool; + + /// Resolve a PR number/URL into full metadata. + fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result; + + /// Find the PR associated with the current branch. + fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result; + + /// Load the file diffs for a PR. + fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String>; + + /// Whether this provider supports syncing per-file "viewed" state. + fn supports_viewed_sync(&self) -> bool { + false + } + + /// Fetch the set of paths currently marked as viewed. + fn fetch_viewed_files(&self, _pr: &PrInfo) -> Result, String> { + Ok(HashSet::new()) + } + + /// Mark/unmark a file as viewed (blocking). + fn set_file_viewed(&self, _pr: &PrInfo, _path: &str, _viewed: bool) -> Result<(), String> { + Ok(()) + } + + /// Build a browser URL for `filename` within the PR. + fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option; +} + +// --------------------------------------------------------------------------- +// Provider registry & selection +// --------------------------------------------------------------------------- + +/// All compiled-in providers. Detection iterates this; adding a forge is one +/// new module plus one entry here. Both providers are zero-sized, so the +/// `&'static` references cost nothing. +static PROVIDERS: &[&dyn PrProvider] = &[&GitHubProvider, &AzureProvider]; + +/// Used when no provider matches a bare PR number's remote. +const DEFAULT_PROVIDER: &dyn PrProvider = &GitHubProvider; + +/// True if `input` looks like a PR reference (a known PR URL or a bare number). +pub fn is_pr_reference(input: &str) -> bool { + PROVIDERS.iter().any(|p| p.matches_url(input)) || input.parse::().is_ok() +} + +fn read_origin_url() -> Option { + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if url.is_empty() { + None + } else { + Some(url) + } +} + +/// Pick a provider from the git `origin` remote (and any `--origin` override), +/// defaulting to GitHub when nothing matches. +fn provider_for_origin(repo_override: Option<&str>) -> &'static dyn PrProvider { + let candidates = [repo_override.map(|s| s.to_string()), read_origin_url()]; + for candidate in candidates.into_iter().flatten() { + if let Some(p) = PROVIDERS.iter().copied().find(|p| p.matches_origin(&candidate)) { + return p; + } + } + DEFAULT_PROVIDER +} + +/// Pick a provider from a PR URL/number, falling back to origin detection. +fn provider_for_input(input: &str, repo_override: Option<&str>) -> &'static dyn PrProvider { + if let Some(p) = PROVIDERS.iter().copied().find(|p| p.matches_url(input)) { + return p; + } + provider_for_origin(repo_override) +} + +// --------------------------------------------------------------------------- +// Dispatchers used by the rest of the diff UI +// --------------------------------------------------------------------------- + +pub fn fetch_pr_info(input: &str, repo_override: Option<&str>) -> Result { + provider_for_input(input, repo_override).fetch_pr_info(input, repo_override) +} + +pub fn detect_current_branch_pr(repo_override: Option<&str>) -> Result { + provider_for_origin(repo_override).detect_current_branch_pr(repo_override) +} + +pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, String> { + pr.provider.load_pr_file_diffs(pr) +} + +pub fn fetch_viewed_files(pr: &PrInfo) -> Result, String> { + pr.provider.fetch_viewed_files(pr) +} + +pub fn pr_file_web_url(pr: &PrInfo, filename: &str) -> Option { + pr.provider.file_web_url(pr, filename) +} + +pub fn mark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { + set_file_viewed_async(pr, file_path, true); +} + +pub fn unmark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { + set_file_viewed_async(pr, file_path, false); +} + +fn set_file_viewed_async(pr: &PrInfo, file_path: &str, viewed: bool) { + if !pr.provider.supports_viewed_sync() { + return; + } + let pr = pr.clone(); + let path = file_path.to_string(); + thread::spawn(move || { + let _ = pr.provider.set_file_viewed(&pr, &path, viewed); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_pr_reference_detects_forms() { + assert!(is_pr_reference("123")); + assert!(is_pr_reference("https://github.com/o/r/pull/1")); + assert!(is_pr_reference("https://dev.azure.com/o/p/_git/r/pullrequest/1")); + assert!(!is_pr_reference("main..feature")); + } +} From 26a2465cc4c18de8ab09e3d37c1004ca5dc28ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Fri, 5 Jun 2026 23:24:33 +0200 Subject: [PATCH 04/13] refactor(diff): typed PrError instead of Result<_, String> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PrProvider trait and its impls now return a thiserror PrError whose variant is the kind — Auth / NotFound / InvalidRef / Other — so the UI can react to the failure (e.g. prompt for credentials on Auth) rather than only display a string. Classification happens where it's reliable: - Azure REST: HTTP 401/403 -> Auth, 404 -> NotFound; missing/!ok az creds -> Auth. - Parse failures -> InvalidRef; "no PR for branch" -> NotFound. A From/From<&str> impl lets the remaining opaque CLI/transport errors fall through to Other via `?`, so internal helpers stay terse. --- src/command/diff/mod.rs | 6 +- src/command/diff/pr_provider/azure/client.rs | 68 ++++++++++++-------- src/command/diff/pr_provider/azure/mod.rs | 20 +++--- src/command/diff/pr_provider/github.rs | 48 +++++++------- src/command/diff/pr_provider/mod.rs | 50 +++++++++++--- 5 files changed, 119 insertions(+), 73 deletions(-) diff --git a/src/command/diff/mod.rs b/src/command/diff/mod.rs index 77cd94a4..d37b77e7 100644 --- a/src/command/diff/mod.rs +++ b/src/command/diff/mod.rs @@ -72,7 +72,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re options.pr = Some(number); } Err(e) => { - spinner.fail(&e); + spinner.fail(&e.to_string()); process::exit(1); } } @@ -87,7 +87,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re return app::run_app_with_pr(options, pr_info, backend); } Err(e) => { - spinner.fail(&e); + spinner.fail(&e.to_string()); process::exit(1); } } @@ -103,7 +103,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re return app::run_app_with_pr(options, pr_info, backend); } Err(e) => { - spinner.fail(&e); + spinner.fail(&e.to_string()); process::exit(1); } } diff --git a/src/command/diff/pr_provider/azure/client.rs b/src/command/diff/pr_provider/azure/client.rs index 98d41e89..9e51648c 100644 --- a/src/command/diff/pr_provider/azure/client.rs +++ b/src/command/diff/pr_provider/azure/client.rs @@ -24,6 +24,7 @@ use tokio::task::JoinSet; use crate::command::diff::git::{build_file_diff, percent_encode}; use crate::command::diff::types::FileDiff; +use crate::command::diff::pr_provider::PrError; use crate::command::diff::PrInfo; const API_VERSION: &str = "7.1"; @@ -60,7 +61,7 @@ struct AdoClient { } impl AdoClient { - fn new(org_url: &str, project: &str, repo: &str) -> Result { + fn new(org_url: &str, project: &str, repo: &str) -> Result { Ok(Self { http: reqwest::Client::new(), base: org_url.trim_end_matches('/').to_string(), @@ -87,7 +88,7 @@ impl AdoClient { ) } - async fn get_json(&self, url: &str) -> Result { + async fn get_json(&self, url: &str) -> Result { let resp = self .authed(self.http.get(url)) .header(ACCEPT, "application/json") @@ -99,13 +100,13 @@ impl AdoClient { if !status.is_success() { return Err(auth_hint(status, &body)); } - serde_json::from_str(&body).map_err(|e| format!("invalid JSON from Azure: {}", e)) + serde_json::from_str(&body).map_err(|e| PrError::Other(format!("invalid JSON from Azure: {}", e))) } /// Fetch a blob's text content. An absent `blob_id` (the missing side of an /// add/delete) is `Ok("")`; a failed fetch is an `Err` so it can't silently /// empty a side and flip the file's status to Added/Deleted downstream. - async fn blob_text(&self, blob_id: Option<&str>) -> Result { + async fn blob_text(&self, blob_id: Option<&str>) -> Result { let Some(blob_id) = blob_id else { return Ok(String::new()); }; @@ -134,7 +135,7 @@ impl AdoClient { Ok(String::from_utf8_lossy(&bytes).into_owned()) } - async fn latest_iteration(&self, pr_id: u64) -> Result { + async fn latest_iteration(&self, pr_id: u64) -> Result { let url = format!( "{}?api-version={}", self.git_url(&format!( @@ -148,12 +149,12 @@ impl AdoClient { json.get("value") .and_then(|v| v.as_array()) .and_then(|arr| arr.iter().filter_map(|i| i.get("id")?.as_u64()).max()) - .ok_or_else(|| "PR has no iterations".to_string()) + .ok_or_else(|| PrError::Other("PR has no iterations".to_string())) } /// All change entries for `iteration`, compared against the merge base /// (`$compareTo=0`), following `$skip`/`$top` pagination. - async fn changes(&self, pr_id: u64, iteration: u64) -> Result, String> { + async fn changes(&self, pr_id: u64, iteration: u64) -> Result, PrError> { let mut entries = Vec::new(); let mut skip = 0usize; loop { @@ -192,12 +193,12 @@ impl AdoClient { Ok(entries) } - async fn load_file_diffs(&self, pr_id: u64) -> Result, String> { + async fn load_file_diffs(&self, pr_id: u64) -> Result, PrError> { let iteration = self.latest_iteration(pr_id).await?; let changes = self.changes(pr_id, iteration).await?; let sem = Arc::new(Semaphore::new(BLOB_CONCURRENCY)); - let mut set: JoinSet> = JoinSet::new(); + let mut set: JoinSet> = JoinSet::new(); for (idx, change) in changes.into_iter().enumerate() { let client = self.clone(); let sem = Arc::clone(&sem); @@ -275,7 +276,7 @@ pub fn fetch_pr_metadata( project: &str, repo: &str, pr_id: u64, -) -> Result { +) -> Result { let client = AdoClient::new(org_url, project, repo)?; block_on(async move { // PR detail is project-scoped (not repo-scoped) in the REST API. @@ -313,7 +314,7 @@ pub fn detect_active_pr( project: &str, repo: &str, branch: &str, -) -> Result { +) -> Result { let client = AdoClient::new(org_url, project, repo)?; block_on(async move { let url = format!( @@ -328,11 +329,11 @@ pub fn detect_active_pr( .and_then(|arr| arr.first()) .and_then(|pr| pr.get("pullRequestId")) .and_then(|v| v.as_u64()) - .ok_or_else(|| format!("No active PR found for branch {}", branch)) + .ok_or_else(|| PrError::NotFound(format!("No active PR found for branch {}", branch))) }) } -pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, String> { +pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, PrError> { let org_url = pr .org_url .as_deref() @@ -357,7 +358,7 @@ fn block_on(fut: F) -> F::Output { tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut)) } -fn resolve_auth() -> Result { +fn resolve_auth() -> Result { for var in ["ADO_PAT", "AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"] { if let Ok(pat) = env::var(var) { if !pat.trim().is_empty() { @@ -377,34 +378,47 @@ fn resolve_auth() -> Result { ]) .output() .map_err(|e| { - format!( + PrError::Auth(format!( "No Azure DevOps credentials: set ADO_PAT or install the Azure CLI and run `az login` ({})", e - ) + )) })?; if !output.status.success() { - return Err( + return Err(PrError::Auth( "No Azure DevOps credentials: set ADO_PAT, or run `az login` to use the Azure CLI." .to_string(), - ); + )); } let json: Value = serde_json::from_slice(&output.stdout) .map_err(|e| format!("Could not parse az token output: {}", e))?; json.get("accessToken") .and_then(|v| v.as_str()) .map(|t| AdoAuth::Bearer(t.to_string())) - .ok_or_else(|| "az returned no access token".to_string()) + .ok_or_else(|| PrError::Auth("az returned no access token".to_string())) } -fn auth_hint(status: reqwest::StatusCode, body: &str) -> String { - if status == reqwest::StatusCode::UNAUTHORIZED { - return "Azure DevOps auth failed (401). Check ADO_PAT scopes (Code: Read) or run `az login`.".to_string(); - } - if status == reqwest::StatusCode::FORBIDDEN { - return "Azure DevOps returned 403. The token lacks access to this repository.".to_string(); +fn auth_hint(status: reqwest::StatusCode, body: &str) -> PrError { + use reqwest::StatusCode; + match status { + StatusCode::UNAUTHORIZED => PrError::Auth( + "Azure DevOps auth failed (401). Check ADO_PAT scopes (Code: Read) or run `az login`." + .to_string(), + ), + StatusCode::FORBIDDEN => PrError::Auth( + "Azure DevOps returned 403. The token lacks access to this repository.".to_string(), + ), + StatusCode::NOT_FOUND => PrError::NotFound( + "Azure DevOps returned 404 (PR or repository not found).".to_string(), + ), + _ => { + let snippet: String = body.chars().take(200).collect(); + PrError::Other(format!( + "Azure DevOps request failed ({}): {}", + status, + snippet.trim() + )) + } } - let snippet: String = body.chars().take(200).collect(); - format!("Azure DevOps request failed ({}): {}", status, snippet.trim()) } #[cfg(test)] diff --git a/src/command/diff/pr_provider/azure/mod.rs b/src/command/diff/pr_provider/azure/mod.rs index ef0badf7..18115186 100644 --- a/src/command/diff/pr_provider/azure/mod.rs +++ b/src/command/diff/pr_provider/azure/mod.rs @@ -9,7 +9,7 @@ use crate::command::diff::git::percent_encode; use crate::command::diff::types::FileDiff; use crate::command::diff::PrInfo; -use super::{read_origin_url, PrProvider}; +use super::{read_origin_url, PrError, PrProvider}; pub struct AzureProvider; @@ -27,7 +27,7 @@ struct AzureRef { } impl AzureProvider { - fn resolve_ref(&self, input: &str, repo_override: Option<&str>) -> Result { + fn resolve_ref(&self, input: &str, repo_override: Option<&str>) -> Result { if let Some(parsed) = parse_azure_url(input) { return Ok(parsed); } @@ -35,7 +35,7 @@ impl AzureProvider { // URL) or from the git `origin` remote. let id = input .parse::() - .map_err(|_| format!("Invalid Azure DevOps PR reference: {}", input))?; + .map_err(|_| PrError::InvalidRef(format!("Invalid Azure DevOps PR reference: {}", input)))?; let remote = repo_override .filter(|o| self.matches_origin(o)) .map(|s| s.to_string()) @@ -45,7 +45,7 @@ impl AzureProvider { .to_string() })?; let mut parsed = parse_azure_remote(&remote) - .ok_or_else(|| format!("Could not parse Azure DevOps remote: {}", remote))?; + .ok_or_else(|| PrError::InvalidRef(format!("Could not parse Azure DevOps remote: {}", remote)))?; parsed.id = Some(id); Ok(parsed) } @@ -61,11 +61,11 @@ impl PrProvider for AzureProvider { origin.contains("dev.azure.com") || origin.contains(".visualstudio.com") } - fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { + fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { let az = self.resolve_ref(input, repo_override)?; let id = az .id - .ok_or_else(|| format!("No PR id found in: {}", input))?; + .ok_or_else(|| PrError::InvalidRef(format!("No PR id found in: {}", input)))?; let meta = client::fetch_pr_metadata(&az.org_url, &az.project, &az.repo, id)?; @@ -89,14 +89,14 @@ impl PrProvider for AzureProvider { }) } - fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result { + fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result { let remote = repo_override .filter(|o| self.matches_origin(o)) .map(|s| s.to_string()) .or_else(read_origin_url) .ok_or_else(|| "Could not determine Azure DevOps repository.".to_string())?; let az = parse_azure_remote(&remote) - .ok_or_else(|| format!("Could not parse Azure DevOps remote: {}", remote))?; + .ok_or_else(|| PrError::InvalidRef(format!("Could not parse Azure DevOps remote: {}", remote)))?; let branch_out = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) @@ -104,14 +104,14 @@ impl PrProvider for AzureProvider { .map_err(|e| format!("Failed to run git: {}", e))?; let branch = String::from_utf8_lossy(&branch_out.stdout).trim().to_string(); if branch.is_empty() { - return Err("Could not determine the current branch".to_string()); + return Err(PrError::Other("Could not determine the current branch".to_string())); } let id = client::detect_active_pr(&az.org_url, &az.project, &az.repo, &branch)?; Ok(id.to_string()) } - fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String> { + fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, PrError> { client::load_pr_file_diffs(pr) } diff --git a/src/command/diff/pr_provider/github.rs b/src/command/diff/pr_provider/github.rs index 4477941f..7bf5347a 100644 --- a/src/command/diff/pr_provider/github.rs +++ b/src/command/diff/pr_provider/github.rs @@ -12,7 +12,7 @@ use crate::command::diff::git::build_file_diff; use crate::command::diff::types::FileDiff; use crate::command::diff::PrInfo; -use super::PrProvider; +use super::{PrError, PrProvider}; /// Max concurrent `gh api` requests when fetching PR file contents. /// GitHub's documented secondary rate limit caps concurrent requests at 100 @@ -31,15 +31,15 @@ impl PrProvider for GitHubProvider { origin.contains("github.com") } - fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { + fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { fetch_pr_info(input, repo_override) } - fn detect_current_branch_pr(&self, _repo_override: Option<&str>) -> Result { + fn detect_current_branch_pr(&self, _repo_override: Option<&str>) -> Result { detect_current_branch_pr() } - fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String> { + fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, PrError> { load_pr_file_diffs(pr) } @@ -47,11 +47,11 @@ impl PrProvider for GitHubProvider { true } - fn fetch_viewed_files(&self, pr: &PrInfo) -> Result, String> { + fn fetch_viewed_files(&self, pr: &PrInfo) -> Result, PrError> { fetch_viewed_files(pr) } - fn set_file_viewed(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), String> { + fn set_file_viewed(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), PrError> { if viewed { mark_file_as_viewed_sync(&pr.node_id, path) } else { @@ -128,12 +128,12 @@ fn resolve_origin_repo() -> Result { } } -fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result { +fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result { let (owner, repo, number) = parse_pr_input(pr_input).ok_or_else(|| { - format!( + PrError::InvalidRef(format!( "Invalid PR reference: {}. Use a PR number or URL.", pr_input - ) + )) })?; let repo_full = match (&owner, &repo, repo_override) { @@ -145,7 +145,7 @@ fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result = repo_full.split('/').collect(); if parts.len() != 2 { - return Err(format!("Invalid repo format: {}", repo_full)); + return Err(PrError::InvalidRef(format!("Invalid repo format: {}", repo_full))); } ( owner.unwrap_or_else(|| parts[0].to_string()), @@ -166,7 +166,7 @@ fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result Option { None } -fn detect_current_branch_pr() -> Result { +fn detect_current_branch_pr() -> Result { let output = Command::new("gh") .args(["pr", "view", "--json", "number", "-q", ".number"]) .output() @@ -241,13 +241,13 @@ fn detect_current_branch_pr() -> Result { let stderr = String::from_utf8_lossy(&output.stderr); let msg = stderr.trim(); if msg.is_empty() { - return Err("No PR found for the current branch".to_string()); + return Err(PrError::NotFound("No PR found for the current branch".to_string())); } - return Err(msg.to_string()); + return Err(PrError::Other(msg.to_string())); } let number = String::from_utf8_lossy(&output.stdout).trim().to_string(); if number.is_empty() { - return Err("No PR found for the current branch".to_string()); + return Err(PrError::NotFound("No PR found for the current branch".to_string())); } Ok(number) } @@ -266,7 +266,7 @@ fn file_anchor(filename: &str) -> String { // --------------------------------------------------------------------------- /// Fetch the list of files that are marked as viewed on GitHub -fn fetch_viewed_files(pr_info: &PrInfo) -> Result, String> { +fn fetch_viewed_files(pr_info: &PrInfo) -> Result, PrError> { let query = format!( r#"query {{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ files(first: 100) {{ nodes {{ path viewerViewedState }} }} }} }} }}"#, pr_info.repo_owner, pr_info.repo_name, pr_info.number @@ -279,7 +279,7 @@ fn fetch_viewed_files(pr_info: &PrInfo) -> Result, String> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("gh api graphql failed: {}", stderr.trim())); + return Err(PrError::Other(format!("gh api graphql failed: {}", stderr.trim()))); } let json_str = String::from_utf8_lossy(&output.stdout); @@ -319,7 +319,7 @@ fn fetch_viewed_files(pr_info: &PrInfo) -> Result, String> { } /// Mark a file as viewed on GitHub PR (blocking) -fn mark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String> { +fn mark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), PrError> { let mutation = format!( r#"mutation {{ markFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, node_id, file_path @@ -332,14 +332,14 @@ fn mark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(stderr.trim().to_string()); + return Err(PrError::Other(stderr.trim().to_string())); } Ok(()) } /// Unmark a file as viewed on GitHub PR (blocking) -fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String> { +fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), PrError> { let mutation = format!( r#"mutation {{ unmarkFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, node_id, file_path @@ -352,7 +352,7 @@ fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), Stri if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(stderr.trim().to_string()); + return Err(PrError::Other(stderr.trim().to_string())); } Ok(()) @@ -362,7 +362,7 @@ fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), Stri // File diffs (gh pr diff + parallel contents fetch) // --------------------------------------------------------------------------- -fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, String> { +fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, PrError> { let repo_arg = format!("{}/{}", pr_info.repo_owner, pr_info.repo_name); let mut spinner = Spinner::new( @@ -390,7 +390,7 @@ fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, String> { Err(e) => { let msg = format!("Failed to run gh pr diff: {}", e); spinner.fail(&msg); - return Err(msg); + return Err(PrError::Other(msg)); } }; @@ -398,7 +398,7 @@ fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, String> { let stderr = String::from_utf8_lossy(&output.stderr); let msg = format!("gh pr diff failed: {}", stderr.trim()); spinner.fail(&msg); - return Err(msg); + return Err(PrError::Other(msg)); } let diff_output = String::from_utf8_lossy(&output.stdout); diff --git a/src/command/diff/pr_provider/mod.rs b/src/command/diff/pr_provider/mod.rs index d9622667..837e4e82 100644 --- a/src/command/diff/pr_provider/mod.rs +++ b/src/command/diff/pr_provider/mod.rs @@ -19,6 +19,38 @@ use super::PrInfo; use azure::AzureProvider; use github::GitHubProvider; +/// An error from a PR-provider operation. The variant is the kind, so the diff +/// UI can react to it (e.g. prompt for credentials on [`PrError::Auth`]). +/// [`PrError::Other`] is the catch-all for CLI/transport failures, and the +/// `From` impl lets internal string errors fall through to it. +#[derive(Debug, thiserror::Error)] +pub enum PrError { + /// Authentication or authorization failed (missing/insufficient token). + #[error("authentication failed: {0}")] + Auth(String), + /// The PR (or the current branch's PR) could not be found. + #[error("not found: {0}")] + NotFound(String), + /// The input couldn't be parsed as a PR reference for this provider. + #[error("invalid PR reference: {0}")] + InvalidRef(String), + /// Anything else: CLI invocation failure, transport error, bad output. + #[error("{0}")] + Other(String), +} + +impl From for PrError { + fn from(s: String) -> Self { + PrError::Other(s) + } +} + +impl From<&str> for PrError { + fn from(s: &str) -> Self { + PrError::Other(s.to_string()) + } +} + /// A pull-request hosting provider. Each method maps to one capability the diff /// UI needs; the viewed-file sync methods default to no-ops so providers without /// that concept (Azure DevOps) don't have to implement them. @@ -34,13 +66,13 @@ pub trait PrProvider: Sync { fn matches_origin(&self, origin: &str) -> bool; /// Resolve a PR number/URL into full metadata. - fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result; + fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result; /// Find the PR associated with the current branch. - fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result; + fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result; /// Load the file diffs for a PR. - fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, String>; + fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, PrError>; /// Whether this provider supports syncing per-file "viewed" state. fn supports_viewed_sync(&self) -> bool { @@ -48,12 +80,12 @@ pub trait PrProvider: Sync { } /// Fetch the set of paths currently marked as viewed. - fn fetch_viewed_files(&self, _pr: &PrInfo) -> Result, String> { + fn fetch_viewed_files(&self, _pr: &PrInfo) -> Result, PrError> { Ok(HashSet::new()) } /// Mark/unmark a file as viewed (blocking). - fn set_file_viewed(&self, _pr: &PrInfo, _path: &str, _viewed: bool) -> Result<(), String> { + fn set_file_viewed(&self, _pr: &PrInfo, _path: &str, _viewed: bool) -> Result<(), PrError> { Ok(()) } @@ -118,19 +150,19 @@ fn provider_for_input(input: &str, repo_override: Option<&str>) -> &'static dyn // Dispatchers used by the rest of the diff UI // --------------------------------------------------------------------------- -pub fn fetch_pr_info(input: &str, repo_override: Option<&str>) -> Result { +pub fn fetch_pr_info(input: &str, repo_override: Option<&str>) -> Result { provider_for_input(input, repo_override).fetch_pr_info(input, repo_override) } -pub fn detect_current_branch_pr(repo_override: Option<&str>) -> Result { +pub fn detect_current_branch_pr(repo_override: Option<&str>) -> Result { provider_for_origin(repo_override).detect_current_branch_pr(repo_override) } -pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, String> { +pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, PrError> { pr.provider.load_pr_file_diffs(pr) } -pub fn fetch_viewed_files(pr: &PrInfo) -> Result, String> { +pub fn fetch_viewed_files(pr: &PrInfo) -> Result, PrError> { pr.provider.fetch_viewed_files(pr) } From e7867278cd189afa69124a5ea188cb46d9e94f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Fri, 5 Jun 2026 23:29:00 +0200 Subject: [PATCH 05/13] refactor(diff): ProviderData enum instead of optional-field bag PrInfo carried node_id (GitHub-only) plus project/org_url (Azure-only) as Option fields that every provider saw as None for the others. Replace them with a single ProviderData enum whose variant holds exactly the fields its forge needs, so each provider pattern-matches its own data (no spurious None, and adding a forge is a new variant rather than two more Options). Common fields (refs, repo owners) stay on PrInfo. --- src/command/diff/mod.rs | 8 +++----- src/command/diff/pr_provider/azure/client.rs | 15 ++++++--------- src/command/diff/pr_provider/azure/mod.rs | 14 ++++++++------ src/command/diff/pr_provider/github.rs | 13 +++++++------ src/command/diff/pr_provider/mod.rs | 16 ++++++++++++++++ 5 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/command/diff/mod.rs b/src/command/diff/mod.rs index d37b77e7..c6f42397 100644 --- a/src/command/diff/mod.rs +++ b/src/command/diff/mod.rs @@ -26,6 +26,7 @@ use crate::vcs::VcsBackend; pub use pr_provider::{ fetch_viewed_files, mark_file_as_viewed_async, unmark_file_as_viewed_async, PrProvider, + ProviderData, }; pub struct DiffOptions { @@ -45,17 +46,14 @@ pub struct DiffOptions { pub struct PrInfo { pub provider: &'static dyn PrProvider, pub number: u64, - pub node_id: String, pub repo_owner: String, pub repo_name: String, pub base_ref: String, pub head_ref: String, pub base_repo_owner: String, pub head_repo_owner: Option, // None if head repo was deleted (fork deleted) - /// Azure DevOps project (None for GitHub). - pub project: Option, - /// Azure DevOps organisation base URL, e.g. `https://dev.azure.com/org`. - pub org_url: Option, + /// Provider-specific data (GitHub node id, or Azure org URL + project). + pub data: ProviderData, } pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Result<()> { diff --git a/src/command/diff/pr_provider/azure/client.rs b/src/command/diff/pr_provider/azure/client.rs index 9e51648c..4b28306e 100644 --- a/src/command/diff/pr_provider/azure/client.rs +++ b/src/command/diff/pr_provider/azure/client.rs @@ -24,7 +24,7 @@ use tokio::task::JoinSet; use crate::command::diff::git::{build_file_diff, percent_encode}; use crate::command::diff::types::FileDiff; -use crate::command::diff::pr_provider::PrError; +use crate::command::diff::pr_provider::{PrError, ProviderData}; use crate::command::diff::PrInfo; const API_VERSION: &str = "7.1"; @@ -334,14 +334,11 @@ pub fn detect_active_pr( } pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, PrError> { - let org_url = pr - .org_url - .as_deref() - .ok_or("Azure PR missing organisation URL")?; - let project = pr - .project - .as_deref() - .ok_or("Azure PR missing project")?; + let ProviderData::Azure { org_url, project } = &pr.data else { + return Err(PrError::Other( + "Azure PR missing organisation/project data".to_string(), + )); + }; let client = AdoClient::new(org_url, project, &pr.repo_name)?; let pr_id = pr.number; block_on(async move { client.load_file_diffs(pr_id).await }) diff --git a/src/command/diff/pr_provider/azure/mod.rs b/src/command/diff/pr_provider/azure/mod.rs index 18115186..f4695dd6 100644 --- a/src/command/diff/pr_provider/azure/mod.rs +++ b/src/command/diff/pr_provider/azure/mod.rs @@ -9,7 +9,7 @@ use crate::command::diff::git::percent_encode; use crate::command::diff::types::FileDiff; use crate::command::diff::PrInfo; -use super::{read_origin_url, PrError, PrProvider}; +use super::{read_origin_url, PrError, PrProvider, ProviderData}; pub struct AzureProvider; @@ -72,7 +72,6 @@ impl PrProvider for AzureProvider { Ok(PrInfo { provider: &AzureProvider, number: id, - node_id: String::new(), repo_owner: az.org.clone(), // Prefer the repo name the API reports; fall back to the URL's. repo_name: if meta.repo_name.is_empty() { @@ -84,8 +83,10 @@ impl PrProvider for AzureProvider { head_ref: strip_ref_prefix(&meta.source_ref), base_repo_owner: az.org.clone(), head_repo_owner: Some(az.org), - project: Some(az.project), - org_url: Some(az.org_url), + data: ProviderData::Azure { + org_url: az.org_url, + project: az.project, + }, }) } @@ -116,8 +117,9 @@ impl PrProvider for AzureProvider { } fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { - let org_url = pr.org_url.as_ref()?; - let project = pr.project.as_ref()?; + let ProviderData::Azure { org_url, project } = &pr.data else { + return None; + }; Some(format!( "{}/{}/_git/{}/pullrequest/{}?path={}", org_url, diff --git a/src/command/diff/pr_provider/github.rs b/src/command/diff/pr_provider/github.rs index 7bf5347a..794d664d 100644 --- a/src/command/diff/pr_provider/github.rs +++ b/src/command/diff/pr_provider/github.rs @@ -12,7 +12,7 @@ use crate::command::diff::git::build_file_diff; use crate::command::diff::types::FileDiff; use crate::command::diff::PrInfo; -use super::{PrError, PrProvider}; +use super::{PrError, PrProvider, ProviderData}; /// Max concurrent `gh api` requests when fetching PR file contents. /// GitHub's documented secondary rate limit caps concurrent requests at 100 @@ -52,10 +52,13 @@ impl PrProvider for GitHubProvider { } fn set_file_viewed(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), PrError> { + let ProviderData::GitHub { node_id } = &pr.data else { + return Ok(()); // not a GitHub PR; nothing to sync + }; if viewed { - mark_file_as_viewed_sync(&pr.node_id, path) + mark_file_as_viewed_sync(node_id, path) } else { - unmark_file_as_viewed_sync(&pr.node_id, path) + unmark_file_as_viewed_sync(node_id, path) } } @@ -187,15 +190,13 @@ fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result for PrError { } } +/// Provider-specific data carried on [`PrInfo`]. Each variant holds exactly the +/// fields its forge needs, so adding a forge is a new variant rather than more +/// `Option`s smeared across a shared struct. +#[derive(Clone, Debug)] +pub enum ProviderData { + GitHub { + /// PR node id, used by the viewed-file GraphQL mutations. + node_id: String, + }, + Azure { + /// Organisation base URL, e.g. `https://dev.azure.com/org`. + org_url: String, + project: String, + }, +} + /// A pull-request hosting provider. Each method maps to one capability the diff /// UI needs; the viewed-file sync methods default to no-ops so providers without /// that concept (Azure DevOps) don't have to implement them. From b04292be88ffbb7ae927c3022ff9b393e530be5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Fri, 5 Jun 2026 23:30:33 +0200 Subject: [PATCH 06/13] refactor(diff): ViewedSync sub-trait replaces supports_viewed_sync bool A supports_viewed_sync() -> bool can silently drift from the fetch/set impls (override one, forget the other). Replace the three methods with a single viewed_sync() -> Option<&dyn ViewedSync>: having the impl *is* the capability, so it can't disagree with itself. GitHub returns Some(self) and implements ViewedSync; Azure inherits the None default. The fetch/set facades route through the Option, no-op when absent. --- src/command/diff/pr_provider/github.rs | 32 ++++++++++++---------- src/command/diff/pr_provider/mod.rs | 38 ++++++++++++++++---------- 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/command/diff/pr_provider/github.rs b/src/command/diff/pr_provider/github.rs index 794d664d..388dec31 100644 --- a/src/command/diff/pr_provider/github.rs +++ b/src/command/diff/pr_provider/github.rs @@ -12,7 +12,7 @@ use crate::command::diff::git::build_file_diff; use crate::command::diff::types::FileDiff; use crate::command::diff::PrInfo; -use super::{PrError, PrProvider, ProviderData}; +use super::{PrError, PrProvider, ProviderData, ViewedSync}; /// Max concurrent `gh api` requests when fetching PR file contents. /// GitHub's documented secondary rate limit caps concurrent requests at 100 @@ -43,15 +43,27 @@ impl PrProvider for GitHubProvider { load_pr_file_diffs(pr) } - fn supports_viewed_sync(&self) -> bool { - true + fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { + Some(format!( + "https://github.com/{}/{}/pull/{}/files#diff-{}", + pr.repo_owner, + pr.repo_name, + pr.number, + file_anchor(filename) + )) } - fn fetch_viewed_files(&self, pr: &PrInfo) -> Result, PrError> { + fn viewed_sync(&self) -> Option<&dyn ViewedSync> { + Some(self) + } +} + +impl ViewedSync for GitHubProvider { + fn fetch(&self, pr: &PrInfo) -> Result, PrError> { fetch_viewed_files(pr) } - fn set_file_viewed(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), PrError> { + fn set(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), PrError> { let ProviderData::GitHub { node_id } = &pr.data else { return Ok(()); // not a GitHub PR; nothing to sync }; @@ -61,16 +73,6 @@ impl PrProvider for GitHubProvider { unmark_file_as_viewed_sync(node_id, path) } } - - fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { - Some(format!( - "https://github.com/{}/{}/pull/{}/files#diff-{}", - pr.repo_owner, - pr.repo_name, - pr.number, - file_anchor(filename) - )) - } } // --------------------------------------------------------------------------- diff --git a/src/command/diff/pr_provider/mod.rs b/src/command/diff/pr_provider/mod.rs index 427baa9a..7323eacb 100644 --- a/src/command/diff/pr_provider/mod.rs +++ b/src/command/diff/pr_provider/mod.rs @@ -90,23 +90,26 @@ pub trait PrProvider: Sync { /// Load the file diffs for a PR. fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, PrError>; - /// Whether this provider supports syncing per-file "viewed" state. - fn supports_viewed_sync(&self) -> bool { - false + /// Build a browser URL for `filename` within the PR. + fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option; + + /// Per-file "viewed" state sync, if this provider supports it. Returning + /// `Some` *is* the capability — there's no separate boolean flag that can + /// drift out of step with the implementation. + fn viewed_sync(&self) -> Option<&dyn ViewedSync> { + None } +} +/// Syncing per-file "viewed" state with the forge (e.g. GitHub's PR file +/// checkboxes). Providers without the concept simply don't return one from +/// [`PrProvider::viewed_sync`]. +pub trait ViewedSync { /// Fetch the set of paths currently marked as viewed. - fn fetch_viewed_files(&self, _pr: &PrInfo) -> Result, PrError> { - Ok(HashSet::new()) - } + fn fetch(&self, pr: &PrInfo) -> Result, PrError>; /// Mark/unmark a file as viewed (blocking). - fn set_file_viewed(&self, _pr: &PrInfo, _path: &str, _viewed: bool) -> Result<(), PrError> { - Ok(()) - } - - /// Build a browser URL for `filename` within the PR. - fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option; + fn set(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), PrError>; } // --------------------------------------------------------------------------- @@ -179,7 +182,10 @@ pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, PrError> { } pub fn fetch_viewed_files(pr: &PrInfo) -> Result, PrError> { - pr.provider.fetch_viewed_files(pr) + match pr.provider.viewed_sync() { + Some(vs) => vs.fetch(pr), + None => Ok(HashSet::new()), + } } pub fn pr_file_web_url(pr: &PrInfo, filename: &str) -> Option { @@ -195,13 +201,15 @@ pub fn unmark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { } fn set_file_viewed_async(pr: &PrInfo, file_path: &str, viewed: bool) { - if !pr.provider.supports_viewed_sync() { + if pr.provider.viewed_sync().is_none() { return; } let pr = pr.clone(); let path = file_path.to_string(); thread::spawn(move || { - let _ = pr.provider.set_file_viewed(&pr, &path, viewed); + if let Some(vs) = pr.provider.viewed_sync() { + let _ = vs.set(&pr, &path, viewed); + } }); } From 3003c7ae1f04fda9b67491a91f83526563051a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Fri, 5 Jun 2026 23:32:05 +0200 Subject: [PATCH 07/13] style(diff): rustfmt the pr_provider modules --- src/command/diff/pr_provider/azure/client.rs | 32 +++++++++++++----- src/command/diff/pr_provider/azure/mod.rs | 29 ++++++++++------ src/command/diff/pr_provider/github.rs | 35 ++++++++++++++++---- src/command/diff/pr_provider/mod.rs | 10 ++++-- 4 files changed, 77 insertions(+), 29 deletions(-) diff --git a/src/command/diff/pr_provider/azure/client.rs b/src/command/diff/pr_provider/azure/client.rs index 4b28306e..870f3f36 100644 --- a/src/command/diff/pr_provider/azure/client.rs +++ b/src/command/diff/pr_provider/azure/client.rs @@ -23,8 +23,8 @@ use tokio::sync::Semaphore; use tokio::task::JoinSet; use crate::command::diff::git::{build_file_diff, percent_encode}; -use crate::command::diff::types::FileDiff; use crate::command::diff::pr_provider::{PrError, ProviderData}; +use crate::command::diff::types::FileDiff; use crate::command::diff::PrInfo; const API_VERSION: &str = "7.1"; @@ -100,7 +100,8 @@ impl AdoClient { if !status.is_success() { return Err(auth_hint(status, &body)); } - serde_json::from_str(&body).map_err(|e| PrError::Other(format!("invalid JSON from Azure: {}", e))) + serde_json::from_str(&body) + .map_err(|e| PrError::Other(format!("invalid JSON from Azure: {}", e))) } /// Fetch a blob's text content. An absent `blob_id` (the missing side of an @@ -112,7 +113,11 @@ impl AdoClient { }; let url = format!( "{}?$format=text&api-version={}", - self.git_url(&format!("repositories/{}/blobs/{}", percent_encode(&self.repo), blob_id)), + self.git_url(&format!( + "repositories/{}/blobs/{}", + percent_encode(&self.repo), + blob_id + )), API_VERSION ); let resp = self @@ -203,7 +208,10 @@ impl AdoClient { let client = self.clone(); let sem = Arc::clone(&sem); set.spawn(async move { - let _permit = sem.acquire_owned().await.expect("blob semaphore not closed"); + let _permit = sem + .acquire_owned() + .await + .expect("blob semaphore not closed"); let old = client.blob_text(change.old_blob.as_deref()).await?; let new = client.blob_text(change.new_blob.as_deref()).await?; Ok((idx, build_file_diff(change.path, old, new))) @@ -319,7 +327,10 @@ pub fn detect_active_pr( block_on(async move { let url = format!( "{}?searchCriteria.status=active&searchCriteria.sourceRefName={}&api-version={}", - client.git_url(&format!("repositories/{}/pullrequests", percent_encode(repo))), + client.git_url(&format!( + "repositories/{}/pullrequests", + percent_encode(repo) + )), percent_encode(&format!("refs/heads/{}", branch)), API_VERSION ); @@ -404,9 +415,9 @@ fn auth_hint(status: reqwest::StatusCode, body: &str) -> PrError { StatusCode::FORBIDDEN => PrError::Auth( "Azure DevOps returned 403. The token lacks access to this repository.".to_string(), ), - StatusCode::NOT_FOUND => PrError::NotFound( - "Azure DevOps returned 404 (PR or repository not found).".to_string(), - ), + StatusCode::NOT_FOUND => { + PrError::NotFound("Azure DevOps returned 404 (PR or repository not found).".to_string()) + } _ => { let snippet: String = body.chars().take(200).collect(); PrError::Other(format!( @@ -473,7 +484,10 @@ mod tests { #[test] fn encodes_segments() { assert_eq!(percent_encode("My Project"), "My%20Project"); - assert_eq!(percent_encode("refs/heads/feature/x"), "refs%2Fheads%2Ffeature%2Fx"); + assert_eq!( + percent_encode("refs/heads/feature/x"), + "refs%2Fheads%2Ffeature%2Fx" + ); assert_eq!(percent_encode("simple-repo.git"), "simple-repo.git"); } } diff --git a/src/command/diff/pr_provider/azure/mod.rs b/src/command/diff/pr_provider/azure/mod.rs index f4695dd6..1139f26f 100644 --- a/src/command/diff/pr_provider/azure/mod.rs +++ b/src/command/diff/pr_provider/azure/mod.rs @@ -33,9 +33,9 @@ impl AzureProvider { } // Bare PR number: take the coordinates from --origin (if it's an Azure // URL) or from the git `origin` remote. - let id = input - .parse::() - .map_err(|_| PrError::InvalidRef(format!("Invalid Azure DevOps PR reference: {}", input)))?; + let id = input.parse::().map_err(|_| { + PrError::InvalidRef(format!("Invalid Azure DevOps PR reference: {}", input)) + })?; let remote = repo_override .filter(|o| self.matches_origin(o)) .map(|s| s.to_string()) @@ -44,8 +44,9 @@ impl AzureProvider { "Could not determine Azure DevOps repository. Run inside the repo or pass a PR URL." .to_string() })?; - let mut parsed = parse_azure_remote(&remote) - .ok_or_else(|| PrError::InvalidRef(format!("Could not parse Azure DevOps remote: {}", remote)))?; + let mut parsed = parse_azure_remote(&remote).ok_or_else(|| { + PrError::InvalidRef(format!("Could not parse Azure DevOps remote: {}", remote)) + })?; parsed.id = Some(id); Ok(parsed) } @@ -96,16 +97,21 @@ impl PrProvider for AzureProvider { .map(|s| s.to_string()) .or_else(read_origin_url) .ok_or_else(|| "Could not determine Azure DevOps repository.".to_string())?; - let az = parse_azure_remote(&remote) - .ok_or_else(|| PrError::InvalidRef(format!("Could not parse Azure DevOps remote: {}", remote)))?; + let az = parse_azure_remote(&remote).ok_or_else(|| { + PrError::InvalidRef(format!("Could not parse Azure DevOps remote: {}", remote)) + })?; let branch_out = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .output() .map_err(|e| format!("Failed to run git: {}", e))?; - let branch = String::from_utf8_lossy(&branch_out.stdout).trim().to_string(); + let branch = String::from_utf8_lossy(&branch_out.stdout) + .trim() + .to_string(); if branch.is_empty() { - return Err(PrError::Other("Could not determine the current branch".to_string())); + return Err(PrError::Other( + "Could not determine the current branch".to_string(), + )); } let id = client::detect_active_pr(&az.org_url, &az.project, &az.repo, &branch)?; @@ -269,8 +275,9 @@ mod tests { #[test] fn parse_azure_visualstudio_url() { - let r = parse_azure_url("https://myorg.visualstudio.com/MyProject/_git/myrepo/pullrequest/9") - .expect("should parse"); + let r = + parse_azure_url("https://myorg.visualstudio.com/MyProject/_git/myrepo/pullrequest/9") + .expect("should parse"); assert_eq!(r.org_url, "https://myorg.visualstudio.com"); assert_eq!(r.project, "MyProject"); assert_eq!(r.repo, "myrepo"); diff --git a/src/command/diff/pr_provider/github.rs b/src/command/diff/pr_provider/github.rs index 388dec31..9e0e0f8d 100644 --- a/src/command/diff/pr_provider/github.rs +++ b/src/command/diff/pr_provider/github.rs @@ -129,7 +129,10 @@ fn resolve_origin_repo() -> Result { if parts.len() >= 2 { Ok(format!("{}/{}", parts[0], parts[1])) } else { - Err(format!("Could not parse owner/repo from origin URL: {}", url)) + Err(format!( + "Could not parse owner/repo from origin URL: {}", + url + )) } } @@ -150,7 +153,10 @@ fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result = repo_full.split('/').collect(); if parts.len() != 2 { - return Err(PrError::InvalidRef(format!("Invalid repo format: {}", repo_full))); + return Err(PrError::InvalidRef(format!( + "Invalid repo format: {}", + repo_full + ))); } ( owner.unwrap_or_else(|| parts[0].to_string()), @@ -171,7 +177,10 @@ fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result Result { let stderr = String::from_utf8_lossy(&output.stderr); let msg = stderr.trim(); if msg.is_empty() { - return Err(PrError::NotFound("No PR found for the current branch".to_string())); + return Err(PrError::NotFound( + "No PR found for the current branch".to_string(), + )); } return Err(PrError::Other(msg.to_string())); } let number = String::from_utf8_lossy(&output.stdout).trim().to_string(); if number.is_empty() { - return Err(PrError::NotFound("No PR found for the current branch".to_string())); + return Err(PrError::NotFound( + "No PR found for the current branch".to_string(), + )); } Ok(number) } @@ -282,7 +295,10 @@ fn fetch_viewed_files(pr_info: &PrInfo) -> Result, PrError> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(PrError::Other(format!("gh api graphql failed: {}", stderr.trim()))); + return Err(PrError::Other(format!( + "gh api graphql failed: {}", + stderr.trim() + ))); } let json_str = String::from_utf8_lossy(&output.stdout); @@ -547,7 +563,12 @@ fn fetch_pr_file_contents_parallel( last_finished = Some(filename); } } - spinner.update_text(format_fetch_progress(done, total, &in_flight, last_finished.as_deref())); + spinner.update_text(format_fetch_progress( + done, + total, + &in_flight, + last_finished.as_deref(), + )); } for h in handles { diff --git a/src/command/diff/pr_provider/mod.rs b/src/command/diff/pr_provider/mod.rs index 7323eacb..1c175d8c 100644 --- a/src/command/diff/pr_provider/mod.rs +++ b/src/command/diff/pr_provider/mod.rs @@ -150,7 +150,11 @@ fn read_origin_url() -> Option { fn provider_for_origin(repo_override: Option<&str>) -> &'static dyn PrProvider { let candidates = [repo_override.map(|s| s.to_string()), read_origin_url()]; for candidate in candidates.into_iter().flatten() { - if let Some(p) = PROVIDERS.iter().copied().find(|p| p.matches_origin(&candidate)) { + if let Some(p) = PROVIDERS + .iter() + .copied() + .find(|p| p.matches_origin(&candidate)) + { return p; } } @@ -221,7 +225,9 @@ mod tests { fn is_pr_reference_detects_forms() { assert!(is_pr_reference("123")); assert!(is_pr_reference("https://github.com/o/r/pull/1")); - assert!(is_pr_reference("https://dev.azure.com/o/p/_git/r/pullrequest/1")); + assert!(is_pr_reference( + "https://dev.azure.com/o/p/_git/r/pullrequest/1" + )); assert!(!is_pr_reference("main..feature")); } } From 2450d49a122ad48ee7f8c0f22f34195dd31cae87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Fri, 5 Jun 2026 23:44:10 +0200 Subject: [PATCH 08/13] refactor(diff): typed serde deserialization for forge responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop hand-navigating serde_json::Value (and, on the GitHub side, scanning raw JSON with str::find) and deserialize into small typed structs instead — the compiler now checks the wire shape and the structs document it. - azure/client.rs: a generic get:: replaces get_json; IterationList, ChangesPage/RawChange/RawItem, PrDetail, PrList, and TokenResponse model each endpoint. RawChange::into_change keeps the folder-skip / rename-fallback logic (fallible, so a method returning Option rather than a From impl). - github.rs: GraphQl + RepoNode envelopes with #[serde(rename_all = camelCase)] replace extract_json_string / extract_nested_login and the viewerViewedState string scan, which were brittle to whitespace/key order. - Add parse tests locking the GraphQL wire contract (incl. deleted head fork). Unknown fields are ignored by default, preserving the prior "read only what we need" tolerance. Behaviour-preserving; 148 tests pass. --- src/command/diff/pr_provider/azure/client.rs | 213 ++++++++++++------- src/command/diff/pr_provider/github.rs | 209 ++++++++++-------- 2 files changed, 254 insertions(+), 168 deletions(-) diff --git a/src/command/diff/pr_provider/azure/client.rs b/src/command/diff/pr_provider/azure/client.rs index 870f3f36..b5635e5f 100644 --- a/src/command/diff/pr_provider/azure/client.rs +++ b/src/command/diff/pr_provider/azure/client.rs @@ -18,7 +18,7 @@ use std::process::Command; use std::sync::Arc; use reqwest::header::ACCEPT; -use serde_json::Value; +use serde::Deserialize; use tokio::sync::Semaphore; use tokio::task::JoinSet; @@ -88,7 +88,9 @@ impl AdoClient { ) } - async fn get_json(&self, url: &str) -> Result { + /// GET `url` and deserialize the JSON body into `T`. Unknown fields are + /// ignored, so each caller's struct declares only the fields it needs. + async fn get(&self, url: &str) -> Result { let resp = self .authed(self.http.get(url)) .header(ACCEPT, "application/json") @@ -150,10 +152,11 @@ impl AdoClient { )), API_VERSION ); - let json = self.get_json(&url).await?; - json.get("value") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.iter().filter_map(|i| i.get("id")?.as_u64()).max()) + let list: IterationList = self.get(&url).await?; + list.value + .iter() + .map(|i| i.id) + .max() .ok_or_else(|| PrError::Other("PR has no iterations".to_string())) } @@ -175,25 +178,19 @@ impl AdoClient { skip, API_VERSION ); - let json = self.get_json(&url).await?; - let page = json - .get("changeEntries") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - let page_len = page.len(); - for entry in page { - if let Some(change) = ChangeEntry::from_json(&entry) { - entries.push(change); - } - } + let page: ChangesPage = self.get(&url).await?; + let page_len = page.change_entries.len(); + entries.extend( + page.change_entries + .into_iter() + .filter_map(RawChange::into_change), + ); // `nextSkip` is the canonical "more pages" signal; fall back to a // short page meaning we're done. - let next_skip = json.get("nextSkip").and_then(|v| v.as_u64()).unwrap_or(0); - if next_skip == 0 || page_len < CHANGES_PAGE { + if page.next_skip == 0 || page_len < CHANGES_PAGE { break; } - skip = next_skip as usize; + skip = page.next_skip as usize; } Ok(entries) } @@ -232,6 +229,46 @@ impl AdoClient { } } +/// The PR iterations list; we only need each iteration's numeric id. +#[derive(Deserialize)] +struct IterationList { + value: Vec, +} + +#[derive(Deserialize)] +struct Iteration { + id: u64, +} + +/// A page of the iteration-changes endpoint. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChangesPage { + #[serde(default)] + change_entries: Vec, + /// Canonical "more pages" cursor; `0`/absent means we're done. + #[serde(default)] + next_skip: u64, +} + +/// A `changeEntries[]` entry exactly as Azure returns it. +#[derive(Deserialize)] +struct RawChange { + item: Option, + #[serde(rename = "originalPath")] + original_path: Option, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct RawItem { + path: Option, + object_id: Option, + original_object_id: Option, + #[serde(default)] + is_folder: bool, +} + /// One PR file change reduced to what the diff UI needs. struct ChangeEntry { /// Repo-relative path without a leading slash. @@ -242,35 +279,19 @@ struct ChangeEntry { old_blob: Option, } -impl ChangeEntry { - fn from_json(entry: &Value) -> Option { - let item = entry.get("item"); - // Skip folders. - if item - .and_then(|i| i.get("isFolder")) - .and_then(|v| v.as_bool()) - .unwrap_or(false) - { +impl RawChange { + /// Reduce a wire entry to a [`ChangeEntry`], skipping folders and entries + /// with no path. The rename case falls back to `originalPath`. + fn into_change(self) -> Option { + let item = self.item.unwrap_or_default(); + if item.is_folder { return None; } - let raw_path = item - .and_then(|i| i.get("path")) - .and_then(|v| v.as_str()) - .or_else(|| entry.get("originalPath").and_then(|v| v.as_str()))?; - let new_blob = item - .and_then(|i| i.get("objectId")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - let old_blob = item - .and_then(|i| i.get("originalObjectId")) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - Some(Self { + let raw_path = item.path.or(self.original_path)?; + Some(ChangeEntry { path: raw_path.trim_start_matches('/').to_string(), - new_blob, - old_blob, + new_blob: item.object_id.filter(|s| !s.is_empty()), + old_blob: item.original_object_id.filter(|s| !s.is_empty()), }) } } @@ -279,6 +300,22 @@ impl ChangeEntry { // Public sync entry points (bridge the async client onto the sync diff path) // --------------------------------------------------------------------------- +/// The PR detail endpoint, reduced to the refs and repo name we display. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PrDetail { + #[serde(default)] + source_ref_name: String, + #[serde(default)] + target_ref_name: String, + repository: Option, +} + +#[derive(Deserialize)] +struct RepoRef { + name: Option, +} + pub fn fetch_pr_metadata( org_url: &str, project: &str, @@ -293,30 +330,30 @@ pub fn fetch_pr_metadata( client.git_url(&format!("pullrequests/{}", pr_id)), API_VERSION ); - let json = client.get_json(&url).await?; - let source_ref = json - .get("sourceRefName") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let target_ref = json - .get("targetRefName") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - let repo_name = json - .pointer("/repository/name") - .and_then(|v| v.as_str()) - .unwrap_or(repo) - .to_string(); + let detail: PrDetail = client.get(&url).await?; Ok(AzurePrMeta { - source_ref, - target_ref, - repo_name, + source_ref: detail.source_ref_name, + target_ref: detail.target_ref_name, + repo_name: detail + .repository + .and_then(|r| r.name) + .unwrap_or_else(|| repo.to_string()), }) }) } +/// The active-PR search result; we take the first match's id. +#[derive(Deserialize)] +struct PrList { + value: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PrId { + pull_request_id: u64, +} + pub fn detect_active_pr( org_url: &str, project: &str, @@ -334,12 +371,10 @@ pub fn detect_active_pr( percent_encode(&format!("refs/heads/{}", branch)), API_VERSION ); - let json = client.get_json(&url).await?; - json.get("value") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.first()) - .and_then(|pr| pr.get("pullRequestId")) - .and_then(|v| v.as_u64()) + let list: PrList = client.get(&url).await?; + list.value + .first() + .map(|pr| pr.pull_request_id) .ok_or_else(|| PrError::NotFound(format!("No active PR found for branch {}", branch))) }) } @@ -397,14 +432,21 @@ fn resolve_auth() -> Result { .to_string(), )); } - let json: Value = serde_json::from_slice(&output.stdout) - .map_err(|e| format!("Could not parse az token output: {}", e))?; - json.get("accessToken") - .and_then(|v| v.as_str()) - .map(|t| AdoAuth::Bearer(t.to_string())) + let token: TokenResponse = serde_json::from_slice(&output.stdout) + .map_err(|e| PrError::Other(format!("Could not parse az token output: {}", e)))?; + token + .access_token + .map(AdoAuth::Bearer) .ok_or_else(|| PrError::Auth("az returned no access token".to_string())) } +/// The `az account get-access-token --output json` response. +#[derive(Deserialize)] +struct TokenResponse { + #[serde(rename = "accessToken")] + access_token: Option, +} + fn auth_hint(status: reqwest::StatusCode, body: &str) -> PrError { use reqwest::StatusCode; match status { @@ -433,9 +475,16 @@ fn auth_hint(status: reqwest::StatusCode, body: &str) -> PrError { mod tests { use super::*; + /// Deserialize a wire change entry and reduce it the way `changes()` does. + fn change(v: serde_json::Value) -> Option { + serde_json::from_value::(v) + .unwrap() + .into_change() + } + #[test] fn parses_add_edit_delete_changes() { - let add = ChangeEntry::from_json(&serde_json::json!({ + let add = change(serde_json::json!({ "changeType": "add", "item": { "path": "/src/new.rs", "objectId": "newsha" } })) @@ -444,7 +493,7 @@ mod tests { assert_eq!(add.new_blob.as_deref(), Some("newsha")); assert_eq!(add.old_blob, None); - let edit = ChangeEntry::from_json(&serde_json::json!({ + let edit = change(serde_json::json!({ "changeType": "edit", "item": { "path": "/a.txt", "objectId": "n", "originalObjectId": "o" } })) @@ -452,7 +501,7 @@ mod tests { assert_eq!(edit.new_blob.as_deref(), Some("n")); assert_eq!(edit.old_blob.as_deref(), Some("o")); - let del = ChangeEntry::from_json(&serde_json::json!({ + let del = change(serde_json::json!({ "changeType": "delete", "item": { "path": "/gone.rs", "originalObjectId": "o" } })) @@ -463,7 +512,7 @@ mod tests { #[test] fn skips_folders() { - assert!(ChangeEntry::from_json(&serde_json::json!({ + assert!(change(serde_json::json!({ "changeType": "add", "item": { "path": "/dir", "isFolder": true } })) @@ -472,7 +521,7 @@ mod tests { #[test] fn falls_back_to_original_path_on_rename() { - let renamed = ChangeEntry::from_json(&serde_json::json!({ + let renamed = change(serde_json::json!({ "changeType": "rename", "item": { "objectId": "n", "originalObjectId": "o" }, "originalPath": "/old/name.rs" diff --git a/src/command/diff/pr_provider/github.rs b/src/command/diff/pr_provider/github.rs index 9e0e0f8d..1e3c6025 100644 --- a/src/command/diff/pr_provider/github.rs +++ b/src/command/diff/pr_provider/github.rs @@ -6,6 +6,7 @@ use std::process::Command; use std::sync::{mpsc, Arc, Mutex}; use std::thread; +use serde::Deserialize; use spinoff::{spinners, Color, Spinner}; use crate::command::diff::git::build_file_diff; @@ -136,6 +137,43 @@ fn resolve_origin_repo() -> Result { } } +/// The `gh api graphql` response envelope: `{ "data": { ... } }`. +#[derive(Deserialize)] +struct GraphQl { + data: Option, +} + +#[derive(Deserialize)] +struct RepoNode { + repository: Option>, +} + +#[derive(Deserialize)] +struct PullRequestNode { + #[serde(rename = "pullRequest")] + pull_request: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PrNode { + id: String, + base_ref_name: Option, + head_ref_name: Option, + base_repository: Option, + head_repository: Option, +} + +#[derive(Deserialize)] +struct RepoOwner { + owner: Owner, +} + +#[derive(Deserialize)] +struct Owner { + login: String, +} + fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result { let (owner, repo, number) = parse_pr_input(pr_input).ok_or_else(|| { PrError::InvalidRef(format!( @@ -183,67 +221,30 @@ fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result> = serde_json::from_slice(&output.stdout) + .map_err(|e| PrError::Other(format!("could not parse gh graphql response: {}", e)))?; + let pr = resp + .data + .and_then(|d| d.repository) + .and_then(|r| r.pull_request) + .ok_or_else(|| PrError::NotFound(format!("PR #{} not found", number)))?; Ok(PrInfo { provider: &GitHubProvider, number, - repo_owner, + repo_owner: repo_owner.clone(), repo_name, - base_ref, - head_ref, - base_repo_owner, - head_repo_owner, - data: ProviderData::GitHub { node_id }, + base_ref: pr.base_ref_name.unwrap_or_else(|| "base".to_string()), + head_ref: pr.head_ref_name.unwrap_or_else(|| "head".to_string()), + base_repo_owner: pr + .base_repository + .map(|r| r.owner.login) + .unwrap_or(repo_owner), + head_repo_owner: pr.head_repository.map(|r| r.owner.login), + data: ProviderData::GitHub { node_id: pr.id }, }) } -fn extract_json_string(json: &str, key: &str) -> Option { - let pattern = format!("\"{}\":\"", key); - if let Some(start) = json.find(&pattern) { - let value_start = start + pattern.len(); - if let Some(end) = json[value_start..].find('"') { - return Some(json[value_start..value_start + end].to_string()); - } - } - None -} - -fn extract_nested_login(json: &str, parent_key: &str) -> Option { - // Look for pattern like "baseRepository":{"owner":{"login":"username"}} - // or handle null case like "headRepository":null - let pattern = format!("\"{}\":", parent_key); - if let Some(start) = json.find(&pattern) { - let after_key = &json[start + pattern.len()..]; - // Check if it's null - if after_key.trim_start().starts_with("null") { - return None; - } - // Look for login within this section - if let Some(login_start) = after_key.find("\"login\":\"") { - let value_start = login_start + 9; - let after_login = &after_key[value_start..]; - if let Some(end) = after_login.find('"') { - return Some(after_login[..end].to_string()); - } - } - } - None -} - fn detect_current_branch_pr() -> Result { let output = Command::new("gh") .args(["pr", "view", "--json", "number", "-q", ".number"]) @@ -281,6 +282,23 @@ fn file_anchor(filename: &str) -> String { // Viewed-file state // --------------------------------------------------------------------------- +#[derive(Deserialize)] +struct PrFiles { + files: FileConnection, +} + +#[derive(Deserialize)] +struct FileConnection { + nodes: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct FileNode { + path: String, + viewer_viewed_state: String, +} + /// Fetch the list of files that are marked as viewed on GitHub fn fetch_viewed_files(pr_info: &PrInfo) -> Result, PrError> { let query = format!( @@ -301,40 +319,20 @@ fn fetch_viewed_files(pr_info: &PrInfo) -> Result, PrError> { ))); } - let json_str = String::from_utf8_lossy(&output.stdout); - - // Parse the response to find viewed files - // Look for patterns like: "path":"filename","viewerViewedState":"VIEWED" - let mut viewed_files = HashSet::new(); - - // Simple parsing: find all path/viewerViewedState pairs - let mut remaining = json_str.as_ref(); - while let Some(path_start) = remaining.find("\"path\":\"") { - let path_value_start = path_start + 8; - let after_path = &remaining[path_value_start..]; - if let Some(path_end) = after_path.find('"') { - let path = &after_path[..path_end]; - - // Look for viewerViewedState after this path - let after_path_str = &after_path[path_end..]; - if let Some(state_start) = after_path_str.find("\"viewerViewedState\":\"") { - let state_value_start = state_start + 21; - let after_state = &after_path_str[state_value_start..]; - if let Some(state_end) = after_state.find('"') { - let state = &after_state[..state_end]; - if state == "VIEWED" { - viewed_files.insert(path.to_string()); - } - } - } + let resp: GraphQl> = serde_json::from_slice(&output.stdout) + .map_err(|e| PrError::Other(format!("could not parse gh graphql response: {}", e)))?; + let nodes = resp + .data + .and_then(|d| d.repository) + .and_then(|r| r.pull_request) + .map(|p| p.files.nodes) + .unwrap_or_default(); - remaining = &remaining[path_value_start + path_end..]; - } else { - break; - } - } - - Ok(viewed_files) + Ok(nodes + .into_iter() + .filter(|n| n.viewer_viewed_state == "VIEWED") + .map(|n| n.path) + .collect()) } /// Mark a file as viewed on GitHub PR (blocking) @@ -645,4 +643,43 @@ mod tests { assert!(!GitHubProvider.matches_url("https://dev.azure.com/o/p/_git/r/pullrequest/1")); assert!(GitHubProvider.matches_origin("git@github.com:owner/repo.git")); } + + #[test] + fn parses_pr_info_graphql_with_deleted_head_fork() { + let body = serde_json::json!({ + "data": { "repository": { "pullRequest": { + "id": "PR_node1", "url": "https://github.com/o/r/pull/1", + "baseRefName": "main", "headRefName": "feature", + "baseRepository": { "owner": { "login": "base-owner" } }, + "headRepository": null + }}} + }); + let resp: GraphQl> = serde_json::from_value(body).unwrap(); + let pr = resp.data.unwrap().repository.unwrap().pull_request.unwrap(); + assert_eq!(pr.id, "PR_node1"); + assert_eq!(pr.base_ref_name.as_deref(), Some("main")); + assert_eq!(pr.base_repository.unwrap().owner.login, "base-owner"); + assert!(pr.head_repository.is_none()); + } + + #[test] + fn parses_viewed_state_graphql() { + let body = serde_json::json!({ + "data": { "repository": { "pullRequest": { "files": { "nodes": [ + { "path": "a.rs", "viewerViewedState": "VIEWED" }, + { "path": "b.rs", "viewerViewedState": "UNVIEWED" } + ]}}}} + }); + let resp: GraphQl> = serde_json::from_value(body).unwrap(); + let nodes = resp + .data + .unwrap() + .repository + .unwrap() + .pull_request + .unwrap() + .files + .nodes; + assert_eq!(nodes[0].viewer_viewed_state, "VIEWED"); + } } From 45e7d20d669e2433329c301405240d4ddb6a1158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Fri, 5 Jun 2026 23:54:36 +0200 Subject: [PATCH 09/13] fix(diff): lead Azure auth with az login / AZURE_DEVOPS_EXT_PAT ADO_PAT is a made-up alias nothing sets; AZURE_DEVOPS_EXT_PAT is the conventional Azure DevOps PAT var (read by the az devops extension), and a plain az login is enough on its own via the bearer-token fallback. Reorder the PAT precedence to check the conventional var first, and point the docs, README, and error messages at az login / AZURE_DEVOPS_EXT_PAT instead of the alias. --- README.md | 2 +- src/command/diff/pr_provider/azure/client.rs | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 180ca694..75d4d122 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Before you begin, ensure you have: 2. [fzf](https://github.com/junegunn/fzf) (optional) - Required for `lumen explain --list` command 3. [mdcat](https://github.com/swsnr/mdcat) (optional) - Required for pretty output formatting 4. [GitHub CLI (`gh`)](https://cli.github.com/) (optional) - Required for reviewing GitHub Pull Requests -5. Azure DevOps Pull Requests (optional) - Authenticate with either a Personal Access Token (`ADO_PAT`, scope: *Code → Read*) or the [Azure CLI (`az`)](https://learn.microsoft.com/cli/azure/) signed in via `az login`. The `azure-devops` extension is **not** required. +5. Azure DevOps Pull Requests (optional) - Sign in with the [Azure CLI (`az`)](https://learn.microsoft.com/cli/azure/) via `az login` (simplest), or set a Personal Access Token in `AZURE_DEVOPS_EXT_PAT` (scope: *Code → Read*). The `azure-devops` extension is **not** required. ### Installation diff --git a/src/command/diff/pr_provider/azure/client.rs b/src/command/diff/pr_provider/azure/client.rs index b5635e5f..bc7186a1 100644 --- a/src/command/diff/pr_provider/azure/client.rs +++ b/src/command/diff/pr_provider/azure/client.rs @@ -9,9 +9,11 @@ //! against the PR's merge base — the same three-dot view the Azure web UI shows, //! rather than a raw tip-to-tip comparison. //! -//! Auth resolves to a PAT (`ADO_PAT` / `AZURE_DEVOPS_EXT_PAT`) via HTTP Basic, -//! or falls back to a bearer token from `az account get-access-token`. Only core -//! `az` (or a PAT) is required — not the `azure-devops` extension. +//! Auth: `az login` alone is enough — we mint a bearer token via `az account +//! get-access-token`. Alternatively set a PAT in `AZURE_DEVOPS_EXT_PAT` (the +//! conventional var; `AZURE_DEVOPS_PAT` / `ADO_PAT` also work), sent via HTTP +//! Basic. Only core `az` (or a PAT) is required — not the `azure-devops` +//! extension. use std::env; use std::process::Command; @@ -402,7 +404,9 @@ fn block_on(fut: F) -> F::Output { } fn resolve_auth() -> Result { - for var in ["ADO_PAT", "AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"] { + // `AZURE_DEVOPS_EXT_PAT` is the conventional Azure DevOps PAT var (read by + // the `az devops` CLI extension); the others are accepted as aliases. + for var in ["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT", "ADO_PAT"] { if let Ok(pat) = env::var(var) { if !pat.trim().is_empty() { return Ok(AdoAuth::Pat(pat)); @@ -422,14 +426,13 @@ fn resolve_auth() -> Result { .output() .map_err(|e| { PrError::Auth(format!( - "No Azure DevOps credentials: set ADO_PAT or install the Azure CLI and run `az login` ({})", + "No Azure DevOps credentials: run `az login`, or set AZURE_DEVOPS_EXT_PAT ({})", e )) })?; if !output.status.success() { return Err(PrError::Auth( - "No Azure DevOps credentials: set ADO_PAT, or run `az login` to use the Azure CLI." - .to_string(), + "No Azure DevOps credentials: run `az login`, or set AZURE_DEVOPS_EXT_PAT.".to_string(), )); } let token: TokenResponse = serde_json::from_slice(&output.stdout) @@ -451,7 +454,7 @@ fn auth_hint(status: reqwest::StatusCode, body: &str) -> PrError { use reqwest::StatusCode; match status { StatusCode::UNAUTHORIZED => PrError::Auth( - "Azure DevOps auth failed (401). Check ADO_PAT scopes (Code: Read) or run `az login`." + "Azure DevOps auth failed (401). Check your PAT scopes (Code: Read) or run `az login`." .to_string(), ), StatusCode::FORBIDDEN => PrError::Auth( From bf388da7951f6ba726c7b3a8a41409501f64e707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Sat, 6 Jun 2026 11:28:05 +0200 Subject: [PATCH 10/13] style(diff): drop AI-style section banners and narration comments The // ---- divider banners and step-narrating comments (// Try to parse as a URL first, etc.) don't match the rest of the codebase, which uses neither. Keep only the why-comments. --- src/command/diff/pr_provider/azure/client.rs | 8 -------- src/command/diff/pr_provider/github.rs | 17 ----------------- src/command/diff/pr_provider/mod.rs | 8 -------- 3 files changed, 33 deletions(-) diff --git a/src/command/diff/pr_provider/azure/client.rs b/src/command/diff/pr_provider/azure/client.rs index bc7186a1..fd556583 100644 --- a/src/command/diff/pr_provider/azure/client.rs +++ b/src/command/diff/pr_provider/azure/client.rs @@ -298,10 +298,6 @@ impl RawChange { } } -// --------------------------------------------------------------------------- -// Public sync entry points (bridge the async client onto the sync diff path) -// --------------------------------------------------------------------------- - /// The PR detail endpoint, reduced to the refs and repo name we display. #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -392,10 +388,6 @@ pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, PrError> { block_on(async move { client.load_file_diffs(pr_id).await }) } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - /// Run an async future to completion from the synchronous diff path. `main` is /// a multi-threaded `#[tokio::main]`, so we mark the current worker as blocking /// and drive the future on the existing runtime — no second runtime, no new deps. diff --git a/src/command/diff/pr_provider/github.rs b/src/command/diff/pr_provider/github.rs index 1e3c6025..be8c72ba 100644 --- a/src/command/diff/pr_provider/github.rs +++ b/src/command/diff/pr_provider/github.rs @@ -76,21 +76,14 @@ impl ViewedSync for GitHubProvider { } } -// --------------------------------------------------------------------------- -// PR metadata -// --------------------------------------------------------------------------- - fn parse_pr_input(input: &str) -> Option<(Option, Option, u64)> { - // Try to parse as a URL first if input.starts_with("http://") || input.starts_with("https://") { - // Extract PR number and repo info from URL // Format: https://github.com/owner/repo/pull/123 let parts: Vec<&str> = input.trim_end_matches('/').split('/').collect(); if parts.len() >= 2 { if let Some(pos) = parts.iter().position(|&p| p == "pull") { if pos + 1 < parts.len() { if let Ok(num) = parts[pos + 1].parse::() { - // Extract owner and repo if pos >= 2 { let owner = parts[pos - 2].to_string(); let repo = parts[pos - 1].to_string(); @@ -103,7 +96,6 @@ fn parse_pr_input(input: &str) -> Option<(Option, Option, u64)> } None } else { - // Try to parse as a PR number input.parse::().ok().map(|num| (None, None, num)) } } @@ -278,10 +270,6 @@ fn file_anchor(filename: &str) -> String { format!("{:x}", hasher.finalize()) } -// --------------------------------------------------------------------------- -// Viewed-file state -// --------------------------------------------------------------------------- - #[derive(Deserialize)] struct PrFiles { files: FileConnection, @@ -375,10 +363,6 @@ fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), PrEr Ok(()) } -// --------------------------------------------------------------------------- -// File diffs (gh pr diff + parallel contents fetch) -// --------------------------------------------------------------------------- - fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, PrError> { let repo_arg = format!("{}/{}", pr_info.repo_owner, pr_info.repo_name); @@ -391,7 +375,6 @@ fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, PrError> { Color::Cyan, ); - // Get PR diff to find changed files let output = Command::new("gh") .args([ "pr", diff --git a/src/command/diff/pr_provider/mod.rs b/src/command/diff/pr_provider/mod.rs index 1c175d8c..d66ef007 100644 --- a/src/command/diff/pr_provider/mod.rs +++ b/src/command/diff/pr_provider/mod.rs @@ -112,10 +112,6 @@ pub trait ViewedSync { fn set(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), PrError>; } -// --------------------------------------------------------------------------- -// Provider registry & selection -// --------------------------------------------------------------------------- - /// All compiled-in providers. Detection iterates this; adding a forge is one /// new module plus one entry here. Both providers are zero-sized, so the /// `&'static` references cost nothing. @@ -169,10 +165,6 @@ fn provider_for_input(input: &str, repo_override: Option<&str>) -> &'static dyn provider_for_origin(repo_override) } -// --------------------------------------------------------------------------- -// Dispatchers used by the rest of the diff UI -// --------------------------------------------------------------------------- - pub fn fetch_pr_info(input: &str, repo_override: Option<&str>) -> Result { provider_for_input(input, repo_override).fetch_pr_info(input, repo_override) } From 33d5afa941e33068ec9b080d3f5e1f6da639bd13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Sat, 6 Jun 2026 12:04:16 +0200 Subject: [PATCH 11/13] docs: match Azure prereq line to the existing tool-first list style --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 75d4d122..9ad0d04a 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Before you begin, ensure you have: 2. [fzf](https://github.com/junegunn/fzf) (optional) - Required for `lumen explain --list` command 3. [mdcat](https://github.com/swsnr/mdcat) (optional) - Required for pretty output formatting 4. [GitHub CLI (`gh`)](https://cli.github.com/) (optional) - Required for reviewing GitHub Pull Requests -5. Azure DevOps Pull Requests (optional) - Sign in with the [Azure CLI (`az`)](https://learn.microsoft.com/cli/azure/) via `az login` (simplest), or set a Personal Access Token in `AZURE_DEVOPS_EXT_PAT` (scope: *Code → Read*). The `azure-devops` extension is **not** required. +5. [Azure CLI (`az`)](https://learn.microsoft.com/cli/azure/) (optional) - Required for reviewing Azure DevOps Pull Requests (sign in with `az login`, or set `AZURE_DEVOPS_EXT_PAT`) ### Installation From 2e07d12468616ccc81f79b58822c5d8071ebf136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Wed, 15 Jul 2026 23:10:31 +0200 Subject: [PATCH 12/13] refactor(diff): harden PR provider boundaries --- Cargo.lock | 1 + Cargo.toml | 2 +- src/command/diff/app.rs | 262 +++++++--- src/command/diff/git.rs | 16 +- src/command/diff/mod.rs | 26 +- src/command/diff/pr_provider/azure/client.rs | 495 ++++++++++++++----- src/command/diff/pr_provider/azure/mod.rs | 470 +++++++++--------- src/command/diff/pr_provider/github.rs | 432 ++++++++++------ src/command/diff/pr_provider/mod.rs | 423 +++++++++++----- src/config/cli.rs | 2 +- src/main.rs | 12 +- src/vcs/backend.rs | 4 + src/vcs/git.rs | 35 +- src/vcs/jj.rs | 202 +++++++- 14 files changed, 1651 insertions(+), 731 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ba31dc3..b0e5f307 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3343,6 +3343,7 @@ checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", diff --git a/Cargo.toml b/Cargo.toml index c488d02d..68877669 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ repository = "https://github.com/jnsahaj/lumen" [dependencies] clap = { version = "4.4", features = ["derive", "env"] } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.0", features = ["full"] } diff --git a/src/command/diff/app.rs b/src/command/diff/app.rs index 1c7d31ab..cb909cbc 100644 --- a/src/command/diff/app.rs +++ b/src/command/diff/app.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use std::io::{self, IsTerminal, Write}; use std::sync::mpsc::TryRecvError; use std::time::Duration; @@ -39,7 +39,7 @@ use super::annotation::{AnnotationEditor, AnnotationEditorResult}; use super::coordinates::{extract_selected_text, PanelLayout}; use super::git::{get_current_branch, load_file_diffs, load_single_commit_diffs}; use super::highlight; -use super::pr_provider::{load_pr_file_diffs, pr_file_web_url}; +use super::pr_provider::{load_pr_file_diffs, pr_file_web_url, PrError}; use super::render::{ render_diff, render_empty_state, truncate_path, FilePickerItem, KeyBind, KeyBindSection, Modal, ModalContent, ModalFileStatus, ModalResult, @@ -52,7 +52,8 @@ use super::types::{ }; use super::watcher::{setup_watcher, WatchEvent}; use super::{ - fetch_viewed_files, mark_file_as_viewed_async, unmark_file_as_viewed_async, DiffOptions, PrInfo, + fetch_viewed_files, mark_file_as_viewed_async, supports_viewed_files, + unmark_file_as_viewed_async, DiffOptions, PrInfo, }; use spinoff::{spinners, Color, Spinner}; @@ -252,12 +253,11 @@ fn format_annotation_preview(annotation: &super::state::Annotation) -> String { pub fn run_app_with_pr( options: DiffOptions, pr_info: PrInfo, - backend: &dyn VcsBackend, + backend: Option<&dyn VcsBackend>, ) -> io::Result<()> { - match load_pr_file_diffs(&pr_info) { - Ok(file_diffs) => run_app_internal(options, Some(pr_info), file_diffs, None, backend), - Err(_) => std::process::exit(1), - } + let file_diffs = load_pr_file_diffs(&pr_info) + .map_err(|error| io::Error::other(format!("failed to load PR diffs: {error}")))?; + run_app_internal(options, Some(pr_info), file_diffs, None, backend) } pub fn run_app( @@ -266,7 +266,7 @@ pub fn run_app( backend: &dyn VcsBackend, ) -> io::Result<()> { let file_diffs = load_file_diffs(&options, backend); - run_app_internal(options, pr_info, file_diffs, None, backend) + run_app_internal(options, pr_info, file_diffs, None, Some(backend)) } pub fn run_app_stacked( @@ -277,28 +277,76 @@ pub fn run_app_stacked( // Load the first commit's diff let first_commit = &commits[0]; let file_diffs = load_single_commit_diffs(&first_commit.commit_id, &options.file, backend); - run_app_internal(options, None, file_diffs, Some(commits), backend) + run_app_internal(options, None, file_diffs, Some(commits), Some(backend)) +} + +fn apply_viewed_paths( + state: &mut AppState, + viewed_paths: Option<&HashSet>, +) -> Option { + let viewed_paths = viewed_paths?; + state.viewed_files = state + .file_diffs + .iter() + .enumerate() + .filter_map(|(idx, diff)| viewed_paths.contains(&diff.filename).then_some(idx)) + .collect(); + Some(state.viewed_files.len()) } /// Sync per-file viewed state from the hosting provider into local state. -/// No-op for providers without viewed-file support (e.g. Azure DevOps). -fn sync_viewed_files_from_provider(pr_info: &PrInfo, state: &mut AppState) { - if let Ok(viewed_paths) = fetch_viewed_files(pr_info) { - state.viewed_files.clear(); - for (idx, diff) in state.file_diffs.iter().enumerate() { - if viewed_paths.contains(&diff.filename) { - state.viewed_files.insert(idx); - } +/// `None` means the provider does not support viewed-file synchronization. +fn sync_viewed_files_from_provider( + pr_info: &PrInfo, + state: &mut AppState, +) -> Result, String> { + if !supports_viewed_files(pr_info) { + return Ok(None); + } + + let viewed_paths = fetch_viewed_files(pr_info).map_err(|error| error.to_string())?; + Ok(apply_viewed_paths(state, viewed_paths.as_ref())) +} + +fn sync_viewed_files_on_startup(pr_info: &PrInfo, state: &mut AppState) { + if !supports_viewed_files(pr_info) { + return; + } + + let mut spinner = Spinner::new( + spinners::Dots, + format!("Syncing viewed status for {} files", state.file_diffs.len()), + Color::Cyan, + ); + match sync_viewed_files_from_provider(pr_info, state) { + Ok(Some(viewed_count)) => { + spinner.success(&format!("{} files marked as viewed", viewed_count)); + } + Ok(None) => { + spinner.fail("Viewed status sync is unavailable"); + } + Err(error) => { + spinner.fail(&format!("Failed to sync viewed status: {error}")); } } } +fn apply_pr_reload( + state: &mut AppState, + result: Result, PrError>, + changed_files: Option<&HashSet>, +) -> Result<(), PrError> { + let file_diffs = result?; + state.reload(file_diffs, changed_files); + Ok(()) +} + fn run_app_internal( options: DiffOptions, pr_info: Option, file_diffs: Vec, stacked_commits: Option>, - backend: &dyn VcsBackend, + backend: Option<&dyn VcsBackend>, ) -> io::Result<()> { theme::init(options.theme.as_deref()); highlight::init(); @@ -306,7 +354,7 @@ fn run_app_internal( // Initialize state before TUI so we can sync viewed files let mut state = AppState::new(file_diffs, options.focus.as_deref()); state.settings.wrap = options.wrap; - state.set_vcs_name(backend.name()); + state.set_vcs_name(backend.map_or("pr", |backend| backend.name())); // Set diff reference for annotation export context let diff_ref_str = if let Some(pr) = &pr_info { @@ -329,16 +377,9 @@ fn run_app_internal( state.init_stacked_mode(commits); } - // Load viewed files from the provider on startup in PR mode (before TUI starts) + // Load viewed files from the provider on startup in PR mode (before TUI starts). if let Some(ref pr) = pr_info { - let mut spinner = Spinner::new( - spinners::Dots, - format!("Syncing viewed status for {} files", state.file_diffs.len()), - Color::Cyan, - ); - sync_viewed_files_from_provider(pr, &mut state); - let viewed_count = state.viewed_files.len(); - spinner.success(&format!("{} files marked as viewed", viewed_count)); + sync_viewed_files_on_startup(pr, &mut state); } // Now enter TUI mode. Use /dev/tty when stdout is captured so the @@ -388,26 +429,44 @@ fn run_app_internal( } if state.needs_reload { - let file_diffs = if let Some(ref pr) = pr_info { + // Clear this before loading so a provider failure does not retry every frame. + state.needs_reload = false; + if let Some(ref pr) = pr_info { // In PR mode, reload from the hosting provider - match load_pr_file_diffs(pr) { - Ok(diffs) => diffs, + let changed_files = pending_watch_event + .as_ref() + .map(|event| &event.changed_files); + match apply_pr_reload(&mut state, load_pr_file_diffs(pr), changed_files) { + Ok(()) => { + pending_watch_event.take(); + // Re-sync viewed files from the hosting provider when supported. + if supports_viewed_files(pr) { + if let Err(error) = sync_viewed_files_from_provider(pr, &mut state) { + active_modal = Some(Modal::info( + "Viewed status sync failed", + format!( + "The diff was reloaded, but viewed status could not be synced.\n\n{error}" + ), + )); + } + } + } Err(e) => { - eprintln!("Warning: failed to reload PR diffs: {}", e); - Vec::new() + active_modal = Some(Modal::info( + "Reload failed", + format!( + "Could not reload PR diffs. The current diff and review state were preserved.\n\n{e}" + ), + )); } } } else { - load_file_diffs(&options, backend) - }; - - // Pass changed files to reload so it can unmark them from viewed - let changed_files = pending_watch_event.take().map(|e| e.changed_files); - state.reload(file_diffs, changed_files.as_ref()); - - // Re-sync viewed files from GitHub in PR mode - if let Some(ref pr) = pr_info { - sync_viewed_files_from_provider(pr, &mut state); + let backend = backend + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "not a repository"))?; + let file_diffs = load_file_diffs(&options, backend); + // Pass changed files to reload so it can unmark them from viewed. + let changed_files = pending_watch_event.take().map(|e| e.changed_files); + state.reload(file_diffs, changed_files.as_ref()); } } @@ -434,12 +493,15 @@ fn run_app_internal( .viewed_hunks .get(&diff.filename) .unwrap_or(&empty_viewed_hunks); - let branch_fallback = get_current_branch(backend); + let branch_fallback = backend + .map(get_current_branch) + .unwrap_or_else(|| "unknown".to_string()); let commit_ref = state.diff_reference.as_deref().unwrap_or(&branch_fallback); let row_offset = std::cell::Cell::new(0usize); let gaps_cell = std::cell::RefCell::new(Vec::new()); let rects_cell = std::cell::RefCell::new(Vec::new()); - let editor_rect_cell: std::cell::Cell> = std::cell::Cell::new(None); + let editor_rect_cell: std::cell::Cell> = + std::cell::Cell::new(None); terminal.draw(|frame| { let (offset, gaps, rects, er) = render_diff( frame, @@ -469,12 +531,12 @@ fn run_app_internal( commit_ref, pr_info.as_ref(), state.focused_hunk, - &hunks, + hunks, state.stacked_mode, state.current_commit(), state.current_commit_index, state.stacked_commits.len(), - &side_by_side, + side_by_side, state.vcs_name, &state.annotations, &state.selection, @@ -948,9 +1010,11 @@ fn run_app_internal( // Left arrow click (first 4 columns to cover " < ") if mouse.column < 4 && state.current_commit_index > 0 { let new_index = state.current_commit_index - 1; - navigate_stacked_commit( - &mut state, new_index, &options, backend, - ); + if let Some(backend) = backend { + navigate_stacked_commit( + &mut state, new_index, &options, backend, + ); + } } // Right arrow click (last 4 columns to cover " > ") else if mouse.column >= term_size.width.saturating_sub(4) @@ -958,9 +1022,11 @@ fn run_app_internal( < state.stacked_commits.len().saturating_sub(1) { let new_index = state.current_commit_index + 1; - navigate_stacked_commit( - &mut state, new_index, &options, backend, - ); + if let Some(backend) = backend { + navigate_stacked_commit( + &mut state, new_index, &options, backend, + ); + } } } else if state.show_sidebar && mouse.column < sidebar_width @@ -1329,14 +1395,22 @@ fn run_app_internal( && state.current_commit_index < state.stacked_commits.len() - 1 { let new_index = state.current_commit_index + 1; - navigate_stacked_commit(&mut state, new_index, &options, backend); + if let Some(backend) = backend { + navigate_stacked_commit( + &mut state, new_index, &options, backend, + ); + } } } // Stacked mode: navigate to previous commit KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { if state.stacked_mode && state.current_commit_index > 0 { let new_index = state.current_commit_index - 1; - navigate_stacked_commit(&mut state, new_index, &options, backend); + if let Some(backend) = backend { + navigate_stacked_commit( + &mut state, new_index, &options, backend, + ); + } } } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -1899,10 +1973,8 @@ fn run_app_internal( } KeyCode::Char('e') => { if !state.file_diffs.is_empty() { - let _ = execute!( - terminal.backend_mut(), - PopKeyboardEnhancementFlags - ); + let _ = + execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags); execute!( terminal.backend_mut(), DisableMouseCapture, @@ -1951,7 +2023,9 @@ fn run_app_internal( )?; let _ = execute!( terminal.backend_mut(), - PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) + PushKeyboardEnhancementFlags( + KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES + ) ); terminal.clear()?; } @@ -2100,10 +2174,10 @@ fn run_app_internal( key: "h/l or left/right", description: "Scroll horizontally", }, - KeyBind { - key: "w", - description: "Toggle watch mode", - }, + KeyBind { + key: "w", + description: "Toggle watch mode", + }, KeyBind { key: "gg / G", description: "Scroll to top / bottom", @@ -2147,7 +2221,8 @@ fn run_app_internal( }, KeyBind { key: "ctrl+f", - description: "Global fuzzy search (all files, with preview)", + description: + "Global fuzzy search (all files, with preview)", }, KeyBind { key: "n or down", @@ -2231,3 +2306,58 @@ fn open_url(url: &str) -> io::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::command::diff::types::FileDiff; + + fn state_with_one_viewed_file() -> AppState { + let mut state = AppState::new( + vec![FileDiff { + filename: "src/lib.rs".to_string(), + old_content: String::new(), + new_content: String::new(), + status: FileStatus::Modified, + is_binary: false, + }], + None, + ); + state.viewed_files.insert(0); + state + } + + #[test] + fn apply_viewed_paths_preserves_local_state_when_unsupported() { + let mut state = state_with_one_viewed_file(); + + let result = apply_viewed_paths(&mut state, None); + + assert_eq!((result, state.viewed_files), (None, HashSet::from([0]))); + } + + #[test] + fn apply_viewed_paths_treats_empty_set_as_authoritative() { + let mut state = state_with_one_viewed_file(); + let viewed_paths = HashSet::new(); + + let result = apply_viewed_paths(&mut state, Some(&viewed_paths)); + + assert_eq!((result, state.viewed_files), (Some(0), HashSet::new())); + } + + #[test] + fn failed_pr_reload_preserves_current_state() { + let mut state = state_with_one_viewed_file(); + + let result = apply_pr_reload( + &mut state, + Err(PrError::Other("temporary failure".to_string())), + None, + ); + + assert!(result.is_err()); + assert_eq!(state.file_diffs[0].filename, "src/lib.rs"); + assert_eq!(state.viewed_files, HashSet::from([0])); + } +} diff --git a/src/command/diff/git.rs b/src/command/diff/git.rs index e3a12c9e..65a0e8f7 100644 --- a/src/command/diff/git.rs +++ b/src/command/diff/git.rs @@ -6,7 +6,6 @@ use super::DiffOptions; use crate::commit_reference::CommitReference; use crate::vcs::VcsBackend; - pub fn get_current_branch(backend: &dyn VcsBackend) -> String { backend .get_current_branch() @@ -76,7 +75,7 @@ pub fn get_changed_files(options: &DiffOptions, backend: &dyn VcsBackend) -> Vec let wt_files = backend.get_working_tree_changed_files().unwrap_or_default(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut combined: Vec = Vec::new(); - for f in range_files.into_iter().chain(wt_files.into_iter()) { + for f in range_files.into_iter().chain(wt_files) { if seen.insert(f.clone()) { combined.push(f); } @@ -327,9 +326,11 @@ mod tests { let backend = crate::vcs::GitBackend::from_cwd().expect("should open repo"); let options = super::super::DiffOptions { - reference: Some(crate::commit_reference::CommitReference::RangeToWorkingTree { - from: "HEAD~1".to_string(), - }), + reference: Some( + crate::commit_reference::CommitReference::RangeToWorkingTree { + from: "HEAD~1".to_string(), + }, + ), pr: None, detect_pr: false, file: None, @@ -367,7 +368,10 @@ mod tests { assert_eq!(base.new_content, "base modified\n"); // committed.txt: old=empty (not in HEAD~1), new=fs content - let committed = diffs.iter().find(|d| d.filename == "committed.txt").unwrap(); + let committed = diffs + .iter() + .find(|d| d.filename == "committed.txt") + .unwrap(); assert_eq!(committed.old_content, ""); assert_eq!(committed.new_content, "committed\n"); diff --git a/src/command/diff/mod.rs b/src/command/diff/mod.rs index c6f42397..e995f8fc 100644 --- a/src/command/diff/mod.rs +++ b/src/command/diff/mod.rs @@ -25,8 +25,8 @@ use crate::commit_reference::CommitReference; use crate::vcs::VcsBackend; pub use pr_provider::{ - fetch_viewed_files, mark_file_as_viewed_async, unmark_file_as_viewed_async, PrProvider, - ProviderData, + fetch_viewed_files, mark_file_as_viewed_async, supports_viewed_files, + unmark_file_as_viewed_async, PrProvider, }; pub struct DiffOptions { @@ -44,7 +44,7 @@ pub struct DiffOptions { #[derive(Clone)] pub struct PrInfo { - pub provider: &'static dyn PrProvider, + pub provider: PrProvider, pub number: u64, pub repo_owner: String, pub repo_name: String, @@ -52,11 +52,12 @@ pub struct PrInfo { pub head_ref: String, pub base_repo_owner: String, pub head_repo_owner: Option, // None if head repo was deleted (fork deleted) - /// Provider-specific data (GitHub node id, or Azure org URL + project). - pub data: ProviderData, } -pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Result<()> { +pub fn run_diff_ui(mut options: DiffOptions, backend: Option<&dyn VcsBackend>) -> io::Result<()> { + let repository_context = + pr_provider::RepositoryContext::resolve(backend, options.origin.as_deref()); + // Resolve --detect-pr into options.pr if options.detect_pr && options.pr.is_none() { let mut spinner = Spinner::new( @@ -64,7 +65,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re "Detecting PR for current branch", Color::Cyan, ); - match pr_provider::detect_current_branch_pr(options.origin.as_deref()) { + match pr_provider::detect_current_branch_pr(&repository_context) { Ok(number) => { spinner.success(&format!("Detected PR #{}", number)); options.pr = Some(number); @@ -79,7 +80,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re // Handle PR mode if let Some(ref pr_input) = options.pr { let mut spinner = Spinner::new(spinners::Dots, "Fetching PR metadata", Color::Cyan); - match pr_provider::fetch_pr_info(pr_input, options.origin.as_deref()) { + match pr_provider::fetch_pr_info(pr_input, &repository_context) { Ok(pr_info) => { spinner.success("Fetched PR metadata"); return app::run_app_with_pr(options, pr_info, backend); @@ -95,7 +96,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re if let Some(CommitReference::Single(ref input)) = options.reference { if pr_provider::is_pr_reference(input) { let mut spinner = Spinner::new(spinners::Dots, "Fetching PR metadata", Color::Cyan); - match pr_provider::fetch_pr_info(input, options.origin.as_deref()) { + match pr_provider::fetch_pr_info(input, &repository_context) { Ok(pr_info) => { spinner.success("Fetched PR metadata"); return app::run_app_with_pr(options, pr_info, backend); @@ -110,6 +111,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re // Handle stacked mode for range references if options.stacked { + let backend = require_backend(backend)?; if let Some(ref reference) = options.reference { let (from, to) = match reference { CommitReference::Range { from, to } => (from.clone(), to.clone()), @@ -150,5 +152,9 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: &dyn VcsBackend) -> io::Re } } - app::run_app(options, None, backend) + app::run_app(options, None, require_backend(backend)?) +} + +fn require_backend(backend: Option<&dyn VcsBackend>) -> io::Result<&dyn VcsBackend> { + backend.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "not a repository")) } diff --git a/src/command/diff/pr_provider/azure/client.rs b/src/command/diff/pr_provider/azure/client.rs index fd556583..14c03287 100644 --- a/src/command/diff/pr_provider/azure/client.rs +++ b/src/command/diff/pr_provider/azure/client.rs @@ -18,16 +18,14 @@ use std::env; use std::process::Command; use std::sync::Arc; +use std::thread; use reqwest::header::ACCEPT; use serde::Deserialize; -use tokio::sync::Semaphore; -use tokio::task::JoinSet; -use crate::command::diff::git::{build_file_diff, percent_encode}; -use crate::command::diff::pr_provider::{PrError, ProviderData}; -use crate::command::diff::types::FileDiff; -use crate::command::diff::PrInfo; +use crate::command::diff::git::percent_encode; +use crate::command::diff::pr_provider::PrError; +use crate::command::diff::types::{is_binary_content, FileDiff, FileStatus}; const API_VERSION: &str = "7.1"; /// Azure DevOps OAuth resource id, used with `az account get-access-token`. @@ -54,7 +52,7 @@ enum AdoAuth { #[derive(Clone)] struct AdoClient { - http: reqwest::Client, + http: reqwest::blocking::Client, /// Organisation base URL, e.g. `https://dev.azure.com/org`. base: String, project: String, @@ -65,7 +63,9 @@ struct AdoClient { impl AdoClient { fn new(org_url: &str, project: &str, repo: &str) -> Result { Ok(Self { - http: reqwest::Client::new(), + http: reqwest::blocking::Client::builder() + .build() + .map_err(|e| format!("could not create Azure HTTP client: {}", e))?, base: org_url.trim_end_matches('/').to_string(), project: project.to_string(), repo: repo.to_string(), @@ -73,7 +73,7 @@ impl AdoClient { }) } - fn authed(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + fn authed(&self, rb: reqwest::blocking::RequestBuilder) -> reqwest::blocking::RequestBuilder { match self.auth.as_ref() { AdoAuth::Pat(pat) => rb.basic_auth("", Some(pat)), AdoAuth::Bearer(token) => rb.bearer_auth(token), @@ -92,15 +92,16 @@ impl AdoClient { /// GET `url` and deserialize the JSON body into `T`. Unknown fields are /// ignored, so each caller's struct declares only the fields it needs. - async fn get(&self, url: &str) -> Result { + fn get(&self, url: &str) -> Result { let resp = self .authed(self.http.get(url)) .header(ACCEPT, "application/json") .send() - .await .map_err(|e| format!("request failed: {}", e))?; let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); + let body = resp + .text() + .map_err(|e| format!("response body read failed: {}", e))?; if !status.is_success() { return Err(auth_hint(status, &body)); } @@ -108,12 +109,10 @@ impl AdoClient { .map_err(|e| PrError::Other(format!("invalid JSON from Azure: {}", e))) } - /// Fetch a blob's text content. An absent `blob_id` (the missing side of an - /// add/delete) is `Ok("")`; a failed fetch is an `Err` so it can't silently - /// empty a side and flip the file's status to Added/Deleted downstream. - async fn blob_text(&self, blob_id: Option<&str>) -> Result { + /// Fetch a blob's text content while retaining whether the side exists. + fn blob_text(&self, blob_id: Option<&str>) -> Result, PrError> { let Some(blob_id) = blob_id else { - return Ok(String::new()); + return Ok(None); }; let url = format!( "{}?$format=text&api-version={}", @@ -128,23 +127,23 @@ impl AdoClient { .authed(self.http.get(&url)) .header(ACCEPT, "text/plain") .send() - .await .map_err(|e| format!("blob {} request failed: {}", blob_id, e))?; let status = resp.status(); if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); + let body = resp + .text() + .map_err(|e| format!("blob {} error body read failed: {}", blob_id, e))?; return Err(auth_hint(status, &body)); } // Read as bytes then lossy-decode so binary blobs degrade gracefully // (build_file_diff flags them as binary downstream). let bytes = resp .bytes() - .await .map_err(|e| format!("blob {} read failed: {}", blob_id, e))?; - Ok(String::from_utf8_lossy(&bytes).into_owned()) + Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) } - async fn latest_iteration(&self, pr_id: u64) -> Result { + fn latest_iteration(&self, pr_id: u64) -> Result { let url = format!( "{}?api-version={}", self.git_url(&format!( @@ -154,7 +153,7 @@ impl AdoClient { )), API_VERSION ); - let list: IterationList = self.get(&url).await?; + let list: IterationList = self.get(&url)?; list.value .iter() .map(|i| i.id) @@ -164,9 +163,9 @@ impl AdoClient { /// All change entries for `iteration`, compared against the merge base /// (`$compareTo=0`), following `$skip`/`$top` pagination. - async fn changes(&self, pr_id: u64, iteration: u64) -> Result, PrError> { + fn changes(&self, pr_id: u64, iteration: u64) -> Result, PrError> { let mut entries = Vec::new(); - let mut skip = 0usize; + let mut skip = 0_u64; loop { let url = format!( "{}?$compareTo=0&$top={}&$skip={}&api-version={}", @@ -180,54 +179,120 @@ impl AdoClient { skip, API_VERSION ); - let page: ChangesPage = self.get(&url).await?; - let page_len = page.change_entries.len(); - entries.extend( - page.change_entries - .into_iter() - .filter_map(RawChange::into_change), - ); - // `nextSkip` is the canonical "more pages" signal; fall back to a - // short page meaning we're done. - if page.next_skip == 0 || page_len < CHANGES_PAGE { - break; + let page: ChangesPage = self.get(&url)?; + for raw_change in page.change_entries { + if let Some(change) = raw_change.into_change()? { + entries.push(change); + } + } + match page.next_skip { + None | Some(0) => break, + Some(next_skip) if next_skip > skip => skip = next_skip, + Some(next_skip) => { + return Err(PrError::Other(format!( + "Azure returned non-advancing nextSkip cursor {} after {}", + next_skip, skip + ))) + } } - skip = page.next_skip as usize; } Ok(entries) } - async fn load_file_diffs(&self, pr_id: u64) -> Result, PrError> { - let iteration = self.latest_iteration(pr_id).await?; - let changes = self.changes(pr_id, iteration).await?; - - let sem = Arc::new(Semaphore::new(BLOB_CONCURRENCY)); - let mut set: JoinSet> = JoinSet::new(); - for (idx, change) in changes.into_iter().enumerate() { - let client = self.clone(); - let sem = Arc::clone(&sem); - set.spawn(async move { - let _permit = sem - .acquire_owned() - .await - .expect("blob semaphore not closed"); - let old = client.blob_text(change.old_blob.as_deref()).await?; - let new = client.blob_text(change.new_blob.as_deref()).await?; - Ok((idx, build_file_diff(change.path, old, new))) - }); + fn fetch_file_diff(&self, change: &ChangeEntry) -> Result { + let old = self.blob_text(change.old_blob.as_deref()); + let new = self.blob_text(change.new_blob.as_deref()); + match (old, new) { + (Ok(old), Ok(new)) => Ok(build_file_diff(change, old, new)), + (Err(old), Err(new)) => Err(PrError::Other(format!( + "both blob fetches failed for {}: {}; {}", + change.path, old, new + ))), + (Err(error), _) | (_, Err(error)) => Err(error), } + } - // Reassemble in the original change order. - let mut out: Vec> = Vec::new(); - while let Some(res) = set.join_next().await { - // Outer `?`: the task panicked. Inner `?`: a blob fetch failed. - let (idx, diff) = res.map_err(|e| format!("blob fetch task failed: {}", e))??; - if idx >= out.len() { - out.resize_with(idx + 1, || None); + fn load_file_diffs(&self, pr_id: u64) -> Result, PrError> { + let iteration = self.latest_iteration(pr_id)?; + let changes = self.changes(pr_id, iteration)?; + if changes.is_empty() { + return Ok(Vec::new()); + } + + let worker_count = changes.len().min(BLOB_CONCURRENCY); + let base_chunk_size = changes.len() / worker_count; + let larger_chunks = changes.len() % worker_count; + let batches = thread::scope(|scope| { + let mut workers = Vec::with_capacity(worker_count); + let mut chunk_start = 0; + for worker_index in 0..worker_count { + let chunk_len = base_chunk_size + usize::from(worker_index < larger_chunks); + let chunk = &changes[chunk_start..chunk_start + chunk_len]; + let result_start = chunk_start; + chunk_start += chunk_len; + let client = self.clone(); + workers.push(scope.spawn(move || { + chunk + .iter() + .enumerate() + .map(|(offset, change)| { + (result_start + offset, client.fetch_file_diff(change)) + }) + .collect::>() + })); } - out[idx] = Some(diff); + + workers + .into_iter() + .map(|worker| worker.join()) + .collect::>() + }); + + let mut out = std::iter::repeat_with(|| None) + .take(changes.len()) + .collect::>>(); + let mut failures = Vec::new(); + for batch in batches { + match batch { + Ok(results) => { + for (index, result) in results { + match result { + Ok(diff) => out[index] = Some(diff), + Err(error) => failures.push(error), + } + } + } + Err(_) => failures.push(PrError::Other( + "Azure blob fetch worker panicked".to_string(), + )), + } + } + + if failures.len() == 1 { + return Err(failures.remove(0)); + } + if !failures.is_empty() { + return Err(PrError::Other(format!( + "multiple Azure blob fetch failures: {}", + failures + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("; ") + ))); } - Ok(out.into_iter().flatten().collect()) + + out.into_iter() + .enumerate() + .map(|(index, diff)| { + diff.ok_or_else(|| { + PrError::Other(format!( + "Azure blob worker returned no result for change {}", + index + )) + }) + }) + .collect() } } @@ -246,22 +311,62 @@ struct Iteration { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ChangesPage { - #[serde(default)] change_entries: Vec, - /// Canonical "more pages" cursor; `0`/absent means we're done. - #[serde(default)] - next_skip: u64, + /// Canonical "more pages" cursor; `0` or absent means we're done. + next_skip: Option, } /// A `changeEntries[]` entry exactly as Azure returns it. #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] struct RawChange { + change_type: AzureChangeType, item: Option, - #[serde(rename = "originalPath")] original_path: Option, } -#[derive(Deserialize, Default)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum AzureChangeType { + None, + Add, + Edit, + Encoding, + Rename, + Delete, + Undelete, + Branch, + Merge, + Lock, + Rollback, + SourceRename, + TargetRename, + Property, + All, +} + +impl AzureChangeType { + fn file_status(self) -> FileStatus { + match self { + Self::Add | Self::Undelete => FileStatus::Added, + Self::Delete => FileStatus::Deleted, + Self::None + | Self::Edit + | Self::Encoding + | Self::Rename + | Self::Branch + | Self::Merge + | Self::Lock + | Self::Rollback + | Self::SourceRename + | Self::TargetRename + | Self::Property + | Self::All => FileStatus::Modified, + } + } +} + +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct RawItem { path: Option, @@ -272,6 +377,7 @@ struct RawItem { } /// One PR file change reduced to what the diff UI needs. +#[derive(Debug)] struct ChangeEntry { /// Repo-relative path without a leading slash. path: String, @@ -279,22 +385,74 @@ struct ChangeEntry { new_blob: Option, /// Blob id of the old (base) side, if any. old_blob: Option, + status: FileStatus, } impl RawChange { - /// Reduce a wire entry to a [`ChangeEntry`], skipping folders and entries - /// with no path. The rename case falls back to `originalPath`. - fn into_change(self) -> Option { - let item = self.item.unwrap_or_default(); + /// Reduce a wire entry to a [`ChangeEntry`], skipping folders explicitly. + fn into_change(self) -> Result, PrError> { + let item = self.item.ok_or_else(|| { + PrError::Other("Azure returned a file change without an item".to_string()) + })?; if item.is_folder { - return None; + return Ok(None); } - let raw_path = item.path.or(self.original_path)?; - Some(ChangeEntry { - path: raw_path.trim_start_matches('/').to_string(), - new_blob: item.object_id.filter(|s| !s.is_empty()), - old_blob: item.original_object_id.filter(|s| !s.is_empty()), - }) + let raw_path = item + .path + .or(self.original_path) + .filter(|path| !path.trim_matches('/').is_empty()) + .ok_or_else(|| { + PrError::Other("Azure returned a file change without a path".to_string()) + })?; + let path = raw_path.trim_start_matches('/').to_string(); + let new_blob = item.object_id.filter(|id| !id.is_empty()); + let old_blob = item.original_object_id.filter(|id| !id.is_empty()); + let status = self.change_type.file_status(); + + match status { + FileStatus::Added if new_blob.is_none() => { + return Err(missing_blob_error(&path, "new")); + } + FileStatus::Deleted if old_blob.is_none() => { + return Err(missing_blob_error(&path, "old")); + } + FileStatus::Modified if old_blob.is_none() || new_blob.is_none() => { + let side = if old_blob.is_none() { "old" } else { "new" }; + return Err(missing_blob_error(&path, side)); + } + _ => {} + } + + Ok(Some(ChangeEntry { + path, + new_blob, + old_blob, + status, + })) + } +} + +fn missing_blob_error(path: &str, side: &str) -> PrError { + PrError::Other(format!( + "Azure returned file change {} without {} blob id", + path, side + )) +} + +fn build_file_diff( + change: &ChangeEntry, + old_content: Option, + new_content: Option, +) -> FileDiff { + let old_content = old_content.unwrap_or_default(); + let new_content = new_content.unwrap_or_default(); + let is_binary = is_binary_content(&old_content) || is_binary_content(&new_content); + FileDiff { + filename: change.path.clone(), + old_content, + new_content, + status: change.status, + is_binary, } } @@ -320,15 +478,15 @@ pub fn fetch_pr_metadata( repo: &str, pr_id: u64, ) -> Result { - let client = AdoClient::new(org_url, project, repo)?; - block_on(async move { + on_http_thread(|| { + let client = AdoClient::new(org_url, project, repo)?; // PR detail is project-scoped (not repo-scoped) in the REST API. let url = format!( "{}?api-version={}", client.git_url(&format!("pullrequests/{}", pr_id)), API_VERSION ); - let detail: PrDetail = client.get(&url).await?; + let detail: PrDetail = client.get(&url)?; Ok(AzurePrMeta { source_ref: detail.source_ref_name, target_ref: detail.target_ref_name, @@ -358,8 +516,8 @@ pub fn detect_active_pr( repo: &str, branch: &str, ) -> Result { - let client = AdoClient::new(org_url, project, repo)?; - block_on(async move { + on_http_thread(|| { + let client = AdoClient::new(org_url, project, repo)?; let url = format!( "{}?searchCriteria.status=active&searchCriteria.sourceRefName={}&api-version={}", client.git_url(&format!( @@ -369,7 +527,7 @@ pub fn detect_active_pr( percent_encode(&format!("refs/heads/{}", branch)), API_VERSION ); - let list: PrList = client.get(&url).await?; + let list: PrList = client.get(&url)?; list.value .first() .map(|pr| pr.pull_request_id) @@ -377,22 +535,27 @@ pub fn detect_active_pr( }) } -pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, PrError> { - let ProviderData::Azure { org_url, project } = &pr.data else { - return Err(PrError::Other( - "Azure PR missing organisation/project data".to_string(), - )); - }; - let client = AdoClient::new(org_url, project, &pr.repo_name)?; - let pr_id = pr.number; - block_on(async move { client.load_file_diffs(pr_id).await }) +pub fn load_pr_file_diffs( + org_url: &str, + project: &str, + repo: &str, + pr_id: u64, +) -> Result, PrError> { + on_http_thread(|| AdoClient::new(org_url, project, repo)?.load_file_diffs(pr_id)) } -/// Run an async future to completion from the synchronous diff path. `main` is -/// a multi-threaded `#[tokio::main]`, so we mark the current worker as blocking -/// and drive the future on the existing runtime — no second runtime, no new deps. -fn block_on(fut: F) -> F::Output { - tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut)) +/// Keep reqwest's blocking client outside any transitive async runtime. +fn on_http_thread(operation: F) -> Result +where + T: Send, + F: FnOnce() -> Result + Send, +{ + thread::scope(|scope| { + scope + .spawn(operation) + .join() + .map_err(|_| PrError::Other("Azure HTTP worker panicked".to_string()))? + }) } fn resolve_auth() -> Result { @@ -471,38 +634,73 @@ mod tests { use super::*; /// Deserialize a wire change entry and reduce it the way `changes()` does. - fn change(v: serde_json::Value) -> Option { + fn change(v: serde_json::Value) -> Result, PrError> { serde_json::from_value::(v) .unwrap() .into_change() } #[test] - fn parses_add_edit_delete_changes() { + fn change_type_parses_add() { let add = change(serde_json::json!({ "changeType": "add", "item": { "path": "/src/new.rs", "objectId": "newsha" } })) + .unwrap() .unwrap(); - assert_eq!(add.path, "src/new.rs"); - assert_eq!(add.new_blob.as_deref(), Some("newsha")); - assert_eq!(add.old_blob, None); + assert_eq!(add.status, FileStatus::Added); + } + + #[test] + fn change_type_parses_edit() { let edit = change(serde_json::json!({ "changeType": "edit", "item": { "path": "/a.txt", "objectId": "n", "originalObjectId": "o" } })) + .unwrap() .unwrap(); - assert_eq!(edit.new_blob.as_deref(), Some("n")); - assert_eq!(edit.old_blob.as_deref(), Some("o")); + assert_eq!(edit.status, FileStatus::Modified); + } + + #[test] + fn change_type_parses_delete() { let del = change(serde_json::json!({ "changeType": "delete", "item": { "path": "/gone.rs", "originalObjectId": "o" } })) + .unwrap() .unwrap(); - assert_eq!(del.new_blob, None); - assert_eq!(del.old_blob.as_deref(), Some("o")); + + assert_eq!(del.status, FileStatus::Deleted); + } + + #[test] + fn change_type_parses_rename_as_modified() { + let rename = change(serde_json::json!({ + "changeType": "rename", + "item": { + "path": "/new.rs", + "objectId": "n", + "originalObjectId": "o" + }, + "originalPath": "/old.rs" + })) + .unwrap() + .unwrap(); + + assert_eq!(rename.status, FileStatus::Modified); + } + + #[test] + fn unknown_change_type_is_rejected() { + let result = serde_json::from_value::(serde_json::json!({ + "changeType": "unexpected", + "item": { "path": "/a.txt", "objectId": "n" } + })); + + assert!(result.is_err()); } #[test] @@ -511,6 +709,7 @@ mod tests { "changeType": "add", "item": { "path": "/dir", "isFolder": true } })) + .unwrap() .is_none()); } @@ -521,10 +720,86 @@ mod tests { "item": { "objectId": "n", "originalObjectId": "o" }, "originalPath": "/old/name.rs" })) + .unwrap() .unwrap(); assert_eq!(renamed.path, "old/name.rs"); } + #[test] + fn malformed_change_without_item_is_rejected() { + let error = change(serde_json::json!({ + "changeType": "add" + })) + .unwrap_err(); + + assert!(error.to_string().contains("without an item")); + } + + #[test] + fn malformed_change_without_path_is_rejected() { + let error = change(serde_json::json!({ + "changeType": "add", + "item": { "objectId": "n" } + })) + .unwrap_err(); + + assert!(error.to_string().contains("without a path")); + } + + #[test] + fn malformed_change_without_required_blob_is_rejected() { + let error = change(serde_json::json!({ + "changeType": "delete", + "item": { "path": "/empty.txt" } + })) + .unwrap_err(); + + assert!(error.to_string().contains("without old blob id")); + } + + #[test] + fn empty_added_file_keeps_added_status() { + let change = change(serde_json::json!({ + "changeType": "add", + "item": { "path": "/empty.txt", "objectId": "n" } + })) + .unwrap() + .unwrap(); + let diff = build_file_diff(&change, None, Some(String::new())); + + assert_eq!(diff.status, FileStatus::Added); + } + + #[test] + fn edit_to_empty_content_keeps_modified_status() { + let change = change(serde_json::json!({ + "changeType": "edit", + "item": { + "path": "/empty.txt", + "objectId": "n", + "originalObjectId": "o" + } + })) + .unwrap() + .unwrap(); + let diff = build_file_diff(&change, Some("content".to_string()), Some(String::new())); + + assert_eq!(diff.status, FileStatus::Modified); + } + + #[test] + fn empty_deleted_file_keeps_deleted_status() { + let change = change(serde_json::json!({ + "changeType": "delete", + "item": { "path": "/empty.txt", "originalObjectId": "o" } + })) + .unwrap() + .unwrap(); + let diff = build_file_diff(&change, Some(String::new()), None); + + assert_eq!(diff.status, FileStatus::Deleted); + } + #[test] fn encodes_segments() { assert_eq!(percent_encode("My Project"), "My%20Project"); diff --git a/src/command/diff/pr_provider/azure/mod.rs b/src/command/diff/pr_provider/azure/mod.rs index 1139f26f..6ccf1f32 100644 --- a/src/command/diff/pr_provider/azure/mod.rs +++ b/src/command/diff/pr_provider/azure/mod.rs @@ -1,255 +1,230 @@ -//! Azure DevOps provider: URL/remote parsing and the [`PrProvider`] impl. The -//! REST client lives in [`client`]. +//! Azure DevOps provider routing and URL parsing. mod client; -use std::process::Command; - use crate::command::diff::git::percent_encode; use crate::command::diff::types::FileDiff; use crate::command::diff::PrInfo; -use super::{read_origin_url, PrError, PrProvider, ProviderData}; - -pub struct AzureProvider; +use super::{decoded_path_segments, parse_http_url, HttpUrl, PrError, PrProvider}; -/// The coordinates of an Azure DevOps repository / PR, parsed from a URL or a -/// git remote. -struct AzureRef { - /// Organisation base URL, e.g. `https://dev.azure.com/myorg`. +#[derive(Clone, Debug)] +pub(super) struct AzureRepository { org_url: String, - /// Short organisation name, e.g. `myorg`. org: String, project: String, repo: String, - /// PR id when parsed from a PR URL. - id: Option, } -impl AzureProvider { - fn resolve_ref(&self, input: &str, repo_override: Option<&str>) -> Result { - if let Some(parsed) = parse_azure_url(input) { - return Ok(parsed); +impl AzureRepository { + pub(super) fn with_number(&self, id: u64) -> AzurePrReference { + AzurePrReference { + repository: self.clone(), + id, } - // Bare PR number: take the coordinates from --origin (if it's an Azure - // URL) or from the git `origin` remote. - let id = input.parse::().map_err(|_| { - PrError::InvalidRef(format!("Invalid Azure DevOps PR reference: {}", input)) - })?; - let remote = repo_override - .filter(|o| self.matches_origin(o)) - .map(|s| s.to_string()) - .or_else(read_origin_url) - .ok_or_else(|| { - "Could not determine Azure DevOps repository. Run inside the repo or pass a PR URL." - .to_string() - })?; - let mut parsed = parse_azure_remote(&remote).ok_or_else(|| { - PrError::InvalidRef(format!("Could not parse Azure DevOps remote: {}", remote)) - })?; - parsed.id = Some(id); - Ok(parsed) } } -impl PrProvider for AzureProvider { - fn matches_url(&self, input: &str) -> bool { - let host_ok = input.contains("dev.azure.com") || input.contains(".visualstudio.com"); - host_ok && (input.contains("/pullrequest/") || input.contains("/_git/")) - } - - fn matches_origin(&self, origin: &str) -> bool { - origin.contains("dev.azure.com") || origin.contains(".visualstudio.com") - } - - fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { - let az = self.resolve_ref(input, repo_override)?; - let id = az - .id - .ok_or_else(|| PrError::InvalidRef(format!("No PR id found in: {}", input)))?; - - let meta = client::fetch_pr_metadata(&az.org_url, &az.project, &az.repo, id)?; +#[derive(Clone, Debug)] +pub(super) struct AzurePrReference { + repository: AzureRepository, + id: u64, +} - Ok(PrInfo { - provider: &AzureProvider, - number: id, - repo_owner: az.org.clone(), - // Prefer the repo name the API reports; fall back to the URL's. - repo_name: if meta.repo_name.is_empty() { - az.repo - } else { - meta.repo_name +pub(super) fn parse_pr_url(url: HttpUrl<'_>) -> Option { + let parts = decoded_path_segments(url.path)?; + let (repository, pullrequest_index) = if url.host.eq_ignore_ascii_case("dev.azure.com") { + if parts.len() != 6 || parts[2] != "_git" { + return None; + } + ( + AzureRepository { + org_url: format!("https://dev.azure.com/{}", parts[0]), + org: parts[0].clone(), + project: parts[1].clone(), + repo: parts[3].clone(), }, - base_ref: strip_ref_prefix(&meta.target_ref), - head_ref: strip_ref_prefix(&meta.source_ref), - base_repo_owner: az.org.clone(), - head_repo_owner: Some(az.org), - data: ProviderData::Azure { - org_url: az.org_url, - project: az.project, + 4, + ) + } else { + let org = visualstudio_org(url.host)?; + let (project_index, git_index, pr_index) = match parts.len() { + 5 => (0, 1, 3), + 6 => (1, 2, 4), + _ => return None, + }; + if parts[git_index] != "_git" { + return None; + } + ( + AzureRepository { + org_url: format!("https://{}", url.host), + org, + project: parts[project_index].clone(), + repo: parts[git_index + 1].clone(), }, - }) - } - - fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result { - let remote = repo_override - .filter(|o| self.matches_origin(o)) - .map(|s| s.to_string()) - .or_else(read_origin_url) - .ok_or_else(|| "Could not determine Azure DevOps repository.".to_string())?; - let az = parse_azure_remote(&remote).ok_or_else(|| { - PrError::InvalidRef(format!("Could not parse Azure DevOps remote: {}", remote)) - })?; + pr_index, + ) + }; - let branch_out = Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .output() - .map_err(|e| format!("Failed to run git: {}", e))?; - let branch = String::from_utf8_lossy(&branch_out.stdout) - .trim() - .to_string(); - if branch.is_empty() { - return Err(PrError::Other( - "Could not determine the current branch".to_string(), - )); - } + if !parts[pullrequest_index].eq_ignore_ascii_case("pullrequest") { + return None; + } + let id = parts.get(pullrequest_index + 1)?.parse().ok()?; + Some(AzurePrReference { repository, id }) +} - let id = client::detect_active_pr(&az.org_url, &az.project, &az.repo, &branch)?; - Ok(id.to_string()) +pub(super) fn parse_repository(input: &str) -> Option { + let input = input.trim().trim_end_matches('/'); + if let Some(repository) = parse_ssh_repository(input) { + return Some(repository); } - fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, PrError> { - client::load_pr_file_diffs(pr) + let normalized = strip_http_userinfo(input); + let url = parse_http_url(normalized.as_ref())?; + let mut parts = decoded_path_segments(url.path)?; + let repo = parts.last_mut()?; + *repo = repo.trim_end_matches(".git").to_string(); + if repo.is_empty() { + return None; } - fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { - let ProviderData::Azure { org_url, project } = &pr.data else { + if url.host.eq_ignore_ascii_case("dev.azure.com") { + if parts.len() != 4 || parts[2] != "_git" { return None; + } + Some(AzureRepository { + org_url: format!("https://dev.azure.com/{}", parts[0]), + org: parts[0].clone(), + project: parts[1].clone(), + repo: parts[3].clone(), + }) + } else { + let org = visualstudio_org(url.host)?; + let (project_index, git_index) = match parts.len() { + 3 => (0, 1), + 4 => (1, 2), + _ => return None, }; - Some(format!( - "{}/{}/_git/{}/pullrequest/{}?path={}", - org_url, - project, - pr.repo_name, - pr.number, - percent_encode(&format!("/{}", filename)) - )) + if parts[git_index] != "_git" { + return None; + } + Some(AzureRepository { + org_url: format!("https://{}", url.host), + org, + project: parts[project_index].clone(), + repo: parts[git_index + 1].clone(), + }) } } -/// Strip a `refs/heads/` (or `refs/`) prefix from an Azure ref name. -fn strip_ref_prefix(ref_name: &str) -> String { - ref_name - .strip_prefix("refs/heads/") - .or_else(|| ref_name.strip_prefix("refs/")) - .unwrap_or(ref_name) - .to_string() -} - -/// Extract `(org_url, org, project, repo)` from the host+path segments of an -/// Azure DevOps HTTPS URL or remote. Shared by URL and remote parsing. -fn azure_coords_from_parts(parts: &[&str]) -> Option<(String, String, String, String)> { - let host = *parts.first()?; - let git_idx = parts.iter().position(|&p| p == "_git")?; - if git_idx == 0 || git_idx + 1 >= parts.len() { +fn visualstudio_org(host: &str) -> Option { + let lowercase = host.to_ascii_lowercase(); + let org = lowercase.strip_suffix(".visualstudio.com")?; + if org.is_empty() || org.contains('.') { return None; } - let project = decode_component(parts[git_idx - 1]); - let repo = decode_component(parts[git_idx + 1]); + Some(org.to_string()) +} - let (org_url, org) = if host == "dev.azure.com" { - let org = (*parts.get(1)?).to_string(); - (format!("https://dev.azure.com/{}", org), org) - } else if let Some(org) = host.strip_suffix(".visualstudio.com") { - (format!("https://{}", host), org.to_string()) +fn parse_ssh_repository(input: &str) -> Option { + let path = input + .strip_prefix("git@ssh.dev.azure.com:") + .or_else(|| input.strip_prefix("ssh://git@ssh.dev.azure.com/"))?; + let parts = decoded_path_segments(&format!("/{}", path))?; + let (org_index, project_index, repo_index) = if parts.len() == 4 && parts[0] == "v3" { + (1, 2, 3) + } else if parts.len() == 3 { + (0, 1, 2) } else { return None; }; - - Some((org_url, org, project, repo)) -} - -/// Parse an Azure DevOps PR URL into its coordinates. -/// -/// Handles `https://dev.azure.com/{org}/{project}/_git/{repo}/pullrequest/{id}` -/// and `https://{org}.visualstudio.com/{project}/_git/{repo}/pullrequest/{id}`. -fn parse_azure_url(input: &str) -> Option { - if !input.starts_with("http") { + let repo = parts[repo_index].trim_end_matches(".git").to_string(); + if repo.is_empty() { return None; } - let no_query = input.split('?').next().unwrap_or(input); - let no_scheme = no_query - .trim_start_matches("https://") - .trim_start_matches("http://") - .trim_end_matches('/'); - let parts: Vec<&str> = no_scheme.split('/').collect(); - let (org_url, org, project, repo) = azure_coords_from_parts(&parts)?; - - let id = parts - .iter() - .position(|p| p.eq_ignore_ascii_case("pullrequest")) - .and_then(|i| parts.get(i + 1)) - .and_then(|s| s.parse::().ok()); - - Some(AzureRef { - org_url, - org, - project, + Some(AzureRepository { + org_url: format!("https://dev.azure.com/{}", parts[org_index]), + org: parts[org_index].clone(), + project: parts[project_index].clone(), repo, - id, }) } -/// Parse an Azure DevOps git remote URL into repository coordinates. -/// -/// Handles HTTPS (`https://[org@]dev.azure.com/{org}/{project}/_git/{repo}`, -/// `https://{org}.visualstudio.com/[collection/]{project}/_git/{repo}`) and SSH -/// (`git@ssh.dev.azure.com:v3/{org}/{project}/{repo}`). -fn parse_azure_remote(remote: &str) -> Option { - let remote = remote.trim().trim_end_matches(".git"); +fn strip_http_userinfo(input: &str) -> std::borrow::Cow<'_, str> { + let Some((scheme, rest)) = input.split_once("://") else { + return input.into(); + }; + let authority_end = rest.find('/').unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + let Some((_, host)) = authority.rsplit_once('@') else { + return input.into(); + }; + format!("{}://{}{}", scheme, host, &rest[authority_end..]).into() +} - // SSH: git@ssh.dev.azure.com:v3/org/project/repo - if let Some(rest) = remote.split("ssh.dev.azure.com:").nth(1) { - let mut segs = rest.trim_start_matches('/').split('/'); - // Drop a leading "v3" path component when present. - let first = segs.next()?; - let org = if first == "v3" { segs.next()? } else { first }; - let project = segs.next()?; - let repo = segs.next()?; - return Some(AzureRef { - org_url: format!("https://dev.azure.com/{}", org), - org: org.to_string(), - project: decode_component(project), - repo: decode_component(repo), - id: None, - }); - } +pub(super) fn fetch_pr_info(reference: &AzurePrReference) -> Result { + let az = &reference.repository; + let id = reference.id; + let meta = client::fetch_pr_metadata(&az.org_url, &az.project, &az.repo, id)?; + + Ok(PrInfo { + provider: PrProvider::Azure { + org_url: az.org_url.clone(), + project: az.project.clone(), + }, + number: id, + repo_owner: az.org.clone(), + repo_name: if meta.repo_name.is_empty() { + az.repo.clone() + } else { + meta.repo_name + }, + base_ref: strip_ref_prefix(&meta.target_ref), + head_ref: strip_ref_prefix(&meta.source_ref), + base_repo_owner: az.org.clone(), + head_repo_owner: Some(az.org.clone()), + }) +} - // HTTPS variants share the `/_git/` marker. - let no_scheme = remote - .trim_start_matches("https://") - .trim_start_matches("http://"); - // Strip any `user@` userinfo from the host segment. - let no_userinfo = match no_scheme.split_once('@') { - Some((_, after)) if after.contains('/') => after, - _ => no_scheme, - }; - let parts: Vec<&str> = no_userinfo.split('/').collect(); - let (org_url, org, project, repo) = azure_coords_from_parts(&parts)?; +pub(super) fn detect_current_branch_pr( + repository: &AzureRepository, + branch: &str, +) -> Result { + let id = client::detect_active_pr( + &repository.org_url, + &repository.project, + &repository.repo, + branch, + )?; + Ok(id.to_string()) +} - Some(AzureRef { +pub(super) fn load_pr_file_diffs( + org_url: &str, + project: &str, + pr: &PrInfo, +) -> Result, PrError> { + client::load_pr_file_diffs(org_url, project, &pr.repo_name, pr.number) +} + +pub(super) fn file_web_url(org_url: &str, project: &str, pr: &PrInfo, filename: &str) -> String { + format!( + "{}/{}/_git/{}/pullrequest/{}?path={}", org_url, - org, project, - repo, - id: None, - }) + pr.repo_name, + pr.number, + percent_encode(&format!("/{}", filename)) + ) } -/// Decode the small set of percent-escapes that show up in Azure path segments -/// (notably `%20` for spaces in project names). -fn decode_component(segment: &str) -> String { - segment.replace("%20", " ") +fn strip_ref_prefix(ref_name: &str) -> String { + ref_name + .strip_prefix("refs/heads/") + .or_else(|| ref_name.strip_prefix("refs/")) + .unwrap_or(ref_name) + .to_string() } #[cfg(test)] @@ -257,56 +232,70 @@ mod tests { use super::*; #[test] - fn azure_matches_pr_urls() { - assert!(AzureProvider.matches_url("https://dev.azure.com/o/p/_git/r/pullrequest/42")); - assert!(AzureProvider.matches_url("https://myorg.visualstudio.com/p/_git/r/pullrequest/7")); - assert!(!AzureProvider.matches_url("https://github.com/owner/repo/pull/123")); + fn parses_exact_azure_pr_urls() { + let reference = + parse_http_url("https://dev.azure.com/myorg/MyProject/_git/myrepo/pullrequest/55") + .and_then(parse_pr_url) + .expect("should parse"); + assert_eq!(reference.repository.org_url, "https://dev.azure.com/myorg"); + assert_eq!(reference.repository.project, "MyProject"); + assert_eq!(reference.repository.repo, "myrepo"); + assert_eq!(reference.id, 55); + + assert!(parse_http_url( + "https://dev.azure.com.evil.test/myorg/MyProject/_git/myrepo/pullrequest/55", + ) + .and_then(parse_pr_url) + .is_none()); + assert!(parse_http_url( + "https://dev.azure.com/myorg/MyProject/_git/myrepo/pullrequest/55/files", + ) + .and_then(parse_pr_url) + .is_none()); } #[test] - fn parse_azure_devazure_url() { - let r = parse_azure_url("https://dev.azure.com/myorg/MyProject/_git/myrepo/pullrequest/55") - .expect("should parse"); - assert_eq!(r.org_url, "https://dev.azure.com/myorg"); - assert_eq!(r.project, "MyProject"); - assert_eq!(r.repo, "myrepo"); - assert_eq!(r.id, Some(55)); + fn azure_repository_url_is_not_a_pr_reference() { + let url = "https://dev.azure.com/org/project/_git/repo"; + assert!(parse_http_url(url).and_then(parse_pr_url).is_none()); + assert!(parse_repository(url).is_some()); } #[test] - fn parse_azure_visualstudio_url() { - let r = - parse_azure_url("https://myorg.visualstudio.com/MyProject/_git/myrepo/pullrequest/9") + fn fully_decodes_azure_path_segments() { + let reference = + parse_http_url("https://dev.azure.com/org/My%2BProject/_git/caf%C3%A9/pullrequest/1") + .and_then(parse_pr_url) .expect("should parse"); - assert_eq!(r.org_url, "https://myorg.visualstudio.com"); - assert_eq!(r.project, "MyProject"); - assert_eq!(r.repo, "myrepo"); - assert_eq!(r.id, Some(9)); - } - - #[test] - fn parse_azure_url_with_encoded_project() { - let r = parse_azure_url("https://dev.azure.com/org/My%20Project/_git/repo/pullrequest/1") - .expect("should parse"); - assert_eq!(r.project, "My Project"); + assert_eq!(reference.repository.project, "My+Project"); + assert_eq!(reference.repository.repo, "café"); } #[test] - fn parse_azure_https_remote() { - let r = parse_azure_remote("https://myorg@dev.azure.com/myorg/MyProject/_git/myrepo") - .expect("should parse"); - assert_eq!(r.org_url, "https://dev.azure.com/myorg"); - assert_eq!(r.project, "MyProject"); - assert_eq!(r.repo, "myrepo"); + fn parses_visualstudio_pr_url() { + let reference = + parse_http_url("https://myorg.visualstudio.com/MyProject/_git/myrepo/pullrequest/9") + .and_then(parse_pr_url) + .expect("should parse"); + assert_eq!( + reference.repository.org_url, + "https://myorg.visualstudio.com" + ); + assert_eq!(reference.repository.project, "MyProject"); + assert_eq!(reference.id, 9); } #[test] - fn parse_azure_ssh_remote() { - let r = parse_azure_remote("git@ssh.dev.azure.com:v3/myorg/MyProject/myrepo") - .expect("should parse"); - assert_eq!(r.org_url, "https://dev.azure.com/myorg"); - assert_eq!(r.project, "MyProject"); - assert_eq!(r.repo, "myrepo"); + fn parses_azure_https_and_ssh_repositories() { + let https = + parse_repository("https://myorg@dev.azure.com/myorg/My%20Project/_git/myrepo.git") + .expect("https"); + let ssh = + parse_repository("git@ssh.dev.azure.com:v3/myorg/My%20Project/myrepo").expect("ssh"); + assert_eq!(https.project, "My Project"); + assert_eq!(https.repo, "myrepo"); + assert_eq!(ssh.project, "My Project"); + assert_eq!(ssh.repo, "myrepo"); } #[test] @@ -315,9 +304,4 @@ mod tests { assert_eq!(strip_ref_prefix("refs/tags/v1"), "tags/v1"); assert_eq!(strip_ref_prefix("feature/x"), "feature/x"); } - - #[test] - fn encodes_path_query() { - assert_eq!(percent_encode("/src/main.rs"), "%2Fsrc%2Fmain.rs"); - } } diff --git a/src/command/diff/pr_provider/github.rs b/src/command/diff/pr_provider/github.rs index be8c72ba..c128d29f 100644 --- a/src/command/diff/pr_provider/github.rs +++ b/src/command/diff/pr_provider/github.rs @@ -13,7 +13,9 @@ use crate::command::diff::git::build_file_diff; use crate::command::diff::types::FileDiff; use crate::command::diff::PrInfo; -use super::{PrError, PrProvider, ProviderData, ViewedSync}; +use super::{ + decoded_path_segments, parse_http_url, strip_http_userinfo, HttpUrl, PrError, PrProvider, +}; /// Max concurrent `gh api` requests when fetching PR file contents. /// GitHub's documented secondary rate limit caps concurrent requests at 100 @@ -21,112 +23,91 @@ use super::{PrError, PrProvider, ProviderData, ViewedSync}; /// still giving a large speedup over serial fetching. const PR_FETCH_CONCURRENCY: usize = 8; -pub struct GitHubProvider; - -impl PrProvider for GitHubProvider { - fn matches_url(&self, input: &str) -> bool { - input.starts_with("http") && input.contains("/pull/") +const VIEWED_FILES_QUERY: &str = r#" +query($owner: String!, $name: String!, $number: Int!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + files(first: 100, after: $after) { + nodes { path viewerViewedState } + pageInfo { hasNextPage endCursor } + } } + } +} +"#; - fn matches_origin(&self, origin: &str) -> bool { - origin.contains("github.com") - } +#[derive(Clone, Debug)] +pub(super) struct GitHubRepository { + owner: String, + repo: String, +} - fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result { - fetch_pr_info(input, repo_override) +impl GitHubRepository { + pub(super) fn with_number(&self, number: u64) -> GitHubPrReference { + GitHubPrReference { + repository: self.clone(), + number, + } } +} - fn detect_current_branch_pr(&self, _repo_override: Option<&str>) -> Result { - detect_current_branch_pr() - } +#[derive(Clone, Debug)] +pub(super) struct GitHubPrReference { + repository: GitHubRepository, + number: u64, +} - fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, PrError> { - load_pr_file_diffs(pr) +pub(super) fn parse_pr_url(url: HttpUrl<'_>) -> Option { + if !url.host.eq_ignore_ascii_case("github.com") { + return None; } - - fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option { - Some(format!( - "https://github.com/{}/{}/pull/{}/files#diff-{}", - pr.repo_owner, - pr.repo_name, - pr.number, - file_anchor(filename) - )) + let parts = decoded_path_segments(url.path)?; + if parts.len() < 4 || parts.len() > 5 || parts[2] != "pull" { + return None; } - - fn viewed_sync(&self) -> Option<&dyn ViewedSync> { - Some(self) + if parts.len() == 5 && parts[4] != "files" && parts[4] != "commits" { + return None; } + Some(GitHubPrReference { + repository: GitHubRepository { + owner: parts[0].clone(), + repo: parts[1].clone(), + }, + number: parts[3].parse().ok()?, + }) } -impl ViewedSync for GitHubProvider { - fn fetch(&self, pr: &PrInfo) -> Result, PrError> { - fetch_viewed_files(pr) - } - - fn set(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), PrError> { - let ProviderData::GitHub { node_id } = &pr.data else { - return Ok(()); // not a GitHub PR; nothing to sync - }; - if viewed { - mark_file_as_viewed_sync(node_id, path) - } else { - unmark_file_as_viewed_sync(node_id, path) +pub(super) fn parse_repository(input: &str) -> Option { + let input = input.trim().trim_end_matches('/'); + let normalized = strip_http_userinfo(input); + if let Some(url) = parse_http_url(normalized.as_ref()) { + if !url.host.eq_ignore_ascii_case("github.com") { + return None; } + return repository_from_path(url.path); } -} -fn parse_pr_input(input: &str) -> Option<(Option, Option, u64)> { - if input.starts_with("http://") || input.starts_with("https://") { - // Format: https://github.com/owner/repo/pull/123 - let parts: Vec<&str> = input.trim_end_matches('/').split('/').collect(); - if parts.len() >= 2 { - if let Some(pos) = parts.iter().position(|&p| p == "pull") { - if pos + 1 < parts.len() { - if let Ok(num) = parts[pos + 1].parse::() { - if pos >= 2 { - let owner = parts[pos - 2].to_string(); - let repo = parts[pos - 1].to_string(); - return Some((Some(owner), Some(repo), num)); - } - return Some((None, None, num)); - } - } - } - } - None - } else { - input.parse::().ok().map(|num| (None, None, num)) + if let Some(path) = input.strip_prefix("git@github.com:") { + return repository_from_path(&format!("/{}", path)); } + if let Some(path) = input.strip_prefix("ssh://git@github.com/") { + return repository_from_path(&format!("/{}", path)); + } + + repository_from_path(&format!("/{}", input)) } -fn resolve_origin_repo() -> Result { - let output = Command::new("git") - .args(["remote", "get-url", "origin"]) - .output() - .map_err(|e| format!("Failed to run git: {}", e))?; - if !output.status.success() { - return Err( - "Could not determine repository. Set origin remote or use --origin owner/repo" - .to_string(), - ); +fn repository_from_path(path: &str) -> Option { + let mut parts = decoded_path_segments(path)?; + if parts.len() != 2 { + return None; } - let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let url = url.strip_suffix(".git").unwrap_or(&url); - let path = url - .split("github.com") - .nth(1) - .ok_or_else(|| format!("Origin URL is not a GitHub URL: {}", url))?; - let path = path.trim_start_matches(':').trim_start_matches('/'); - let parts: Vec<&str> = path.split('/').collect(); - if parts.len() >= 2 { - Ok(format!("{}/{}", parts[0], parts[1])) - } else { - Err(format!( - "Could not parse owner/repo from origin URL: {}", - url - )) + let repo = parts.pop()?.trim_end_matches(".git").to_string(); + let owner = parts.pop()?; + if owner.is_empty() || repo.is_empty() { + return None; } + Some(GitHubRepository { owner, repo }) } /// The `gh api graphql` response envelope: `{ "data": { ... } }`. @@ -166,33 +147,10 @@ struct Owner { login: String, } -fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result { - let (owner, repo, number) = parse_pr_input(pr_input).ok_or_else(|| { - PrError::InvalidRef(format!( - "Invalid PR reference: {}. Use a PR number or URL.", - pr_input - )) - })?; - - let repo_full = match (&owner, &repo, repo_override) { - (Some(o), Some(r), _) => format!("{}/{}", o, r), - (_, _, Some(r)) => r.to_string(), - _ => resolve_origin_repo()?, - }; - - let (repo_owner, repo_name) = { - let parts: Vec<&str> = repo_full.split('/').collect(); - if parts.len() != 2 { - return Err(PrError::InvalidRef(format!( - "Invalid repo format: {}", - repo_full - ))); - } - ( - owner.unwrap_or_else(|| parts[0].to_string()), - repo.unwrap_or_else(|| parts[1].to_string()), - ) - }; +pub(super) fn fetch_pr_info(reference: &GitHubPrReference) -> Result { + let number = reference.number; + let repo_owner = reference.repository.owner.clone(); + let repo_name = reference.repository.repo.clone(); // Use GraphQL to get the PR node ID, branch refs, and repo owners let query = format!( @@ -222,7 +180,7 @@ fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result) -> Result Result { +pub(super) fn detect_current_branch_pr( + repository: &GitHubRepository, + branch: &str, +) -> Result { + let repo = format!("{}/{}", repository.owner, repository.repo); let output = Command::new("gh") - .args(["pr", "view", "--json", "number", "-q", ".number"]) + .args([ + "pr", "view", branch, "--repo", &repo, "--json", "number", "-q", ".number", + ]) .output() .map_err(|e| format!("Failed to run gh: {}", e))?; if !output.status.success() { @@ -261,6 +224,16 @@ fn detect_current_branch_pr() -> Result { Ok(number) } +pub(super) fn file_web_url(pr: &PrInfo, filename: &str) -> String { + format!( + "https://github.com/{}/{}/pull/{}/files#diff-{}", + pr.repo_owner, + pr.repo_name, + pr.number, + file_anchor(filename) + ) +} + /// SHA-256 file anchor used by GitHub's PR "Files changed" deep links /// (`#diff-`). fn file_anchor(filename: &str) -> String { @@ -276,8 +249,17 @@ struct PrFiles { } #[derive(Deserialize)] +#[serde(rename_all = "camelCase")] struct FileConnection { nodes: Vec, + page_info: PageInfo, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageInfo { + has_next_page: bool, + end_cursor: Option, } #[derive(Deserialize)] @@ -288,14 +270,27 @@ struct FileNode { } /// Fetch the list of files that are marked as viewed on GitHub -fn fetch_viewed_files(pr_info: &PrInfo) -> Result, PrError> { - let query = format!( - r#"query {{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ files(first: 100) {{ nodes {{ path viewerViewedState }} }} }} }} }}"#, - pr_info.repo_owner, pr_info.repo_name, pr_info.number - ); +pub(super) fn fetch_viewed_files(pr_info: &PrInfo) -> Result, PrError> { + fetch_all_viewed_files(|after| fetch_viewed_files_page(pr_info, after)) +} - let output = Command::new("gh") - .args(["api", "graphql", "-f", &format!("query={}", query)]) +fn fetch_viewed_files_page(pr_info: &PrInfo, after: Option<&str>) -> Result, PrError> { + let mut command = Command::new("gh"); + command + .args(["api", "graphql"]) + .arg("-f") + .arg(format!("query={VIEWED_FILES_QUERY}")) + .arg("-f") + .arg(format!("owner={}", pr_info.repo_owner)) + .arg("-f") + .arg(format!("name={}", pr_info.repo_name)) + .arg("-F") + .arg(format!("number={}", pr_info.number)); + if let Some(cursor) = after { + command.arg("-f").arg(format!("after={cursor}")); + } + + let output = command .output() .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; @@ -307,20 +302,63 @@ fn fetch_viewed_files(pr_info: &PrInfo) -> Result, PrError> { ))); } - let resp: GraphQl> = serde_json::from_slice(&output.stdout) + Ok(output.stdout) +} + +fn fetch_all_viewed_files(mut fetch_page: F) -> Result, PrError> +where + F: FnMut(Option<&str>) -> Result, PrError>, +{ + let mut viewed_paths = HashSet::new(); + let mut cursor = None; + + loop { + let body = fetch_page(cursor.as_deref())?; + let next_cursor = accumulate_viewed_files_page(&body, &mut viewed_paths)?; + if next_cursor.is_none() { + return Ok(viewed_paths); + } + if next_cursor == cursor { + return Err(PrError::Other( + "github graphql returned a repeated file-page cursor".to_string(), + )); + } + cursor = next_cursor; + } +} + +fn accumulate_viewed_files_page( + body: &[u8], + viewed_paths: &mut HashSet, +) -> Result, PrError> { + let resp: GraphQl> = serde_json::from_slice(body) .map_err(|e| PrError::Other(format!("could not parse gh graphql response: {}", e)))?; - let nodes = resp + let files = resp .data .and_then(|d| d.repository) .and_then(|r| r.pull_request) - .map(|p| p.files.nodes) - .unwrap_or_default(); + .map(|p| p.files) + .ok_or_else(|| { + PrError::NotFound( + "github graphql response did not include pull request files".to_string(), + ) + })?; + + viewed_paths.extend( + files + .nodes + .into_iter() + .filter(|node| node.viewer_viewed_state == "VIEWED") + .map(|node| node.path), + ); - Ok(nodes - .into_iter() - .filter(|n| n.viewer_viewed_state == "VIEWED") - .map(|n| n.path) - .collect()) + if files.page_info.has_next_page { + files.page_info.end_cursor.map(Some).ok_or_else(|| { + PrError::Other("github graphql file page is missing its end cursor".to_string()) + }) + } else { + Ok(None) + } } /// Mark a file as viewed on GitHub PR (blocking) @@ -363,7 +401,15 @@ fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), PrEr Ok(()) } -fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, PrError> { +pub(super) fn set_file_viewed(node_id: &str, file_path: &str, viewed: bool) -> Result<(), PrError> { + if viewed { + mark_file_as_viewed_sync(node_id, file_path) + } else { + unmark_file_as_viewed_sync(node_id, file_path) + } +} + +pub(super) fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, PrError> { let repo_arg = format!("{}/{}", pr_info.repo_owner, pr_info.repo_name); let mut spinner = Spinner::new( @@ -428,7 +474,7 @@ fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, PrError> { let file_diffs: Vec = changed_files .into_iter() - .zip(contents.into_iter()) + .zip(contents) .map(|(filename, (old_content, new_content))| { build_file_diff(filename, old_content, new_content) }) @@ -567,10 +613,8 @@ fn format_fetch_progress( ) -> String { let current = if let Some(name) = in_flight.last() { name.as_str() - } else if let Some(name) = last_finished { - name } else { - "" + last_finished.unwrap_or_default() }; if current.is_empty() { format!("Fetching files [{}/{}]", done, total) @@ -621,10 +665,51 @@ mod tests { use super::*; #[test] - fn github_matches_pull_urls() { - assert!(GitHubProvider.matches_url("https://github.com/owner/repo/pull/123")); - assert!(!GitHubProvider.matches_url("https://dev.azure.com/o/p/_git/r/pullrequest/1")); - assert!(GitHubProvider.matches_origin("git@github.com:owner/repo.git")); + fn parses_only_exact_github_pull_urls() { + let reference = parse_http_url("https://github.com/owner/repo/pull/123") + .and_then(parse_pr_url) + .expect("should parse"); + assert_eq!(reference.repository.owner, "owner"); + assert_eq!(reference.repository.repo, "repo"); + assert_eq!(reference.number, 123); + assert!(parse_http_url("https://notgithub.com/owner/repo/pull/123") + .and_then(parse_pr_url) + .is_none()); + assert!(parse_http_url("https://github.com/owner/repo/issues/123") + .and_then(parse_pr_url) + .is_none()); + assert!( + parse_http_url("https://github.com/owner/repo/pull/123/unknown") + .and_then(parse_pr_url) + .is_none() + ); + } + + #[test] + fn parses_github_pull_subpage_urls() { + for url in [ + "https://github.com/owner/repo/pull/123/files", + "https://github.com/owner/repo/pull/123/commits", + ] { + let reference = parse_http_url(url) + .and_then(parse_pr_url) + .expect("supported PR subpage"); + assert_eq!(reference.number, 123); + } + } + + #[test] + fn parses_github_https_and_ssh_repositories() { + let https = parse_repository("https://github.com/owner/repo.git").expect("https"); + let credentialed = + parse_repository("https://user:TOKEN@github.com/owner/repo.git").expect("credentials"); + let ssh = parse_repository("git@github.com:owner/repo.git").expect("ssh"); + assert_eq!(https.owner, "owner"); + assert_eq!(https.repo, "repo"); + assert_eq!(credentialed.owner, "owner"); + assert_eq!(credentialed.repo, "repo"); + assert_eq!(ssh.owner, "owner"); + assert_eq!(ssh.repo, "repo"); } #[test] @@ -651,7 +736,7 @@ mod tests { "data": { "repository": { "pullRequest": { "files": { "nodes": [ { "path": "a.rs", "viewerViewedState": "VIEWED" }, { "path": "b.rs", "viewerViewedState": "UNVIEWED" } - ]}}}} + ], "pageInfo": { "hasNextPage": false, "endCursor": null }}}}} }); let resp: GraphQl> = serde_json::from_value(body).unwrap(); let nodes = resp @@ -665,4 +750,61 @@ mod tests { .nodes; assert_eq!(nodes[0].viewer_viewed_state, "VIEWED"); } + + #[test] + fn accumulates_viewed_files_across_graphql_pages() { + let pages = [ + serde_json::json!({ + "data": { "repository": { "pullRequest": { "files": { + "nodes": [ + { "path": "a.rs", "viewerViewedState": "VIEWED" }, + { "path": "b.rs", "viewerViewedState": "UNVIEWED" } + ], + "pageInfo": { "hasNextPage": true, "endCursor": "cursor-1" } + }}}} + }) + .to_string() + .into_bytes(), + serde_json::json!({ + "data": { "repository": { "pullRequest": { "files": { + "nodes": [ + { "path": "c.rs", "viewerViewedState": "VIEWED" } + ], + "pageInfo": { "hasNextPage": false, "endCursor": "cursor-2" } + }}}} + }) + .to_string() + .into_bytes(), + ]; + let mut pages = pages.into_iter(); + let mut cursors = Vec::new(); + + let viewed = fetch_all_viewed_files(|cursor| { + cursors.push(cursor.map(str::to_owned)); + Ok(pages.next().expect("requested page")) + }) + .expect("all pages"); + + assert_eq!(cursors, vec![None, Some("cursor-1".to_string())]); + assert_eq!( + viewed, + HashSet::from(["a.rs".to_string(), "c.rs".to_string()]) + ); + } + + #[test] + fn rejects_partial_graphql_page_without_next_cursor() { + let page = serde_json::json!({ + "data": { "repository": { "pullRequest": { "files": { + "nodes": [{ "path": "a.rs", "viewerViewedState": "VIEWED" }], + "pageInfo": { "hasNextPage": true, "endCursor": null } + }}}} + }) + .to_string() + .into_bytes(); + + let error = fetch_all_viewed_files(|_| Ok(page.clone())).expect_err("missing cursor"); + + assert!(error.to_string().contains("missing its end cursor")); + } } diff --git a/src/command/diff/pr_provider/mod.rs b/src/command/diff/pr_provider/mod.rs index d66ef007..16565990 100644 --- a/src/command/diff/pr_provider/mod.rs +++ b/src/command/diff/pr_provider/mod.rs @@ -1,40 +1,28 @@ -//! Pull-request hosting provider abstraction. -//! -//! `lumen diff --pr` originally only understood GitHub (it shelled out to the -//! `gh` CLI everywhere). This module introduces a [`PrProvider`] trait so other -//! forges can be supported, with [`github`] for GitHub and [`azure`] for Azure -//! DevOps. Adding a forge (e.g. `glab` for GitLab) is one new module plus one -//! entry in [`PROVIDERS`]. +//! Pull-request hosting provider routing. mod azure; mod github; +use std::borrow::Cow; use std::collections::HashSet; -use std::process::Command; +use std::fmt; use std::thread; use super::types::FileDiff; use super::PrInfo; +use crate::vcs::VcsBackend; -use azure::AzureProvider; -use github::GitHubProvider; +use azure::{AzurePrReference, AzureRepository}; +use github::{GitHubPrReference, GitHubRepository}; -/// An error from a PR-provider operation. The variant is the kind, so the diff -/// UI can react to it (e.g. prompt for credentials on [`PrError::Auth`]). -/// [`PrError::Other`] is the catch-all for CLI/transport failures, and the -/// `From` impl lets internal string errors fall through to it. #[derive(Debug, thiserror::Error)] pub enum PrError { - /// Authentication or authorization failed (missing/insufficient token). #[error("authentication failed: {0}")] Auth(String), - /// The PR (or the current branch's PR) could not be found. #[error("not found: {0}")] NotFound(String), - /// The input couldn't be parsed as a PR reference for this provider. #[error("invalid PR reference: {0}")] InvalidRef(String), - /// Anything else: CLI invocation failure, transport error, bad output. #[error("{0}")] Other(String), } @@ -51,141 +39,162 @@ impl From<&str> for PrError { } } -/// Provider-specific data carried on [`PrInfo`]. Each variant holds exactly the -/// fields its forge needs, so adding a forge is a new variant rather than more -/// `Option`s smeared across a shared struct. +/// Provider-specific coordinates for a resolved pull request. #[derive(Clone, Debug)] -pub enum ProviderData { - GitHub { - /// PR node id, used by the viewed-file GraphQL mutations. - node_id: String, - }, - Azure { - /// Organisation base URL, e.g. `https://dev.azure.com/org`. - org_url: String, - project: String, - }, +pub enum PrProvider { + GitHub { node_id: String }, + Azure { org_url: String, project: String }, } -/// A pull-request hosting provider. Each method maps to one capability the diff -/// UI needs; the viewed-file sync methods default to no-ops so providers without -/// that concept (Azure DevOps) don't have to implement them. -/// -/// `Sync` is required so a `&'static dyn PrProvider` (stored on [`PrInfo`]) can -/// be moved into the background threads that sync viewed-file state. -pub trait PrProvider: Sync { - /// Does this provider recognise `input` as one of its PR URLs? - fn matches_url(&self, input: &str) -> bool; - - /// Does this provider recognise `origin` (a git remote URL) as one of its - /// repositories? Used to pick a provider for bare PR numbers. - fn matches_origin(&self, origin: &str) -> bool; - - /// Resolve a PR number/URL into full metadata. - fn fetch_pr_info(&self, input: &str, repo_override: Option<&str>) -> Result; - - /// Find the PR associated with the current branch. - fn detect_current_branch_pr(&self, repo_override: Option<&str>) -> Result; +#[derive(Clone, Debug)] +enum Repository { + GitHub(GitHubRepository), + Azure(AzureRepository), +} - /// Load the file diffs for a PR. - fn load_pr_file_diffs(&self, pr: &PrInfo) -> Result, PrError>; +/// Repository information read once from the selected VCS backend. +#[derive(Clone, Debug)] +pub struct RepositoryContext { + origin: Option, + repository: Option, + current_branch: Option, +} - /// Build a browser URL for `filename` within the PR. - fn file_web_url(&self, pr: &PrInfo, filename: &str) -> Option; +impl RepositoryContext { + pub fn resolve(backend: Option<&dyn VcsBackend>, repository_override: Option<&str>) -> Self { + let origin = repository_override + .map(str::to_owned) + .or_else(|| backend.and_then(|backend| backend.origin_url().ok().flatten())); + let repository = origin.as_deref().and_then(parse_repository); + let current_branch = + backend.and_then(|backend| backend.get_current_branch().ok().flatten()); + + Self { + origin, + repository, + current_branch, + } + } - /// Per-file "viewed" state sync, if this provider supports it. Returning - /// `Some` *is* the capability — there's no separate boolean flag that can - /// drift out of step with the implementation. - fn viewed_sync(&self) -> Option<&dyn ViewedSync> { - None + #[cfg(test)] + fn from_sources( + repository_override: Option<&str>, + backend_origin: Option<&str>, + current_branch: Option<&str>, + ) -> Self { + let origin = repository_override + .map(str::to_owned) + .or_else(|| backend_origin.map(str::to_owned)); + let repository = origin.as_deref().and_then(parse_repository); + Self { + origin, + repository, + current_branch: current_branch.map(str::to_owned), + } } } -/// Syncing per-file "viewed" state with the forge (e.g. GitHub's PR file -/// checkboxes). Providers without the concept simply don't return one from -/// [`PrProvider::viewed_sync`]. -pub trait ViewedSync { - /// Fetch the set of paths currently marked as viewed. - fn fetch(&self, pr: &PrInfo) -> Result, PrError>; +#[derive(Clone, Debug)] +enum PrReference { + GitHub(GitHubPrReference), + Azure(AzurePrReference), + Number(u64), +} - /// Mark/unmark a file as viewed (blocking). - fn set(&self, pr: &PrInfo, path: &str, viewed: bool) -> Result<(), PrError>; +fn parse_pr_reference(input: &str) -> Option { + if let Ok(number) = input.parse::() { + return Some(PrReference::Number(number)); + } + let url = parse_http_url(input)?; + github::parse_pr_url(url) + .map(PrReference::GitHub) + .or_else(|| azure::parse_pr_url(url).map(PrReference::Azure)) } -/// All compiled-in providers. Detection iterates this; adding a forge is one -/// new module plus one entry here. Both providers are zero-sized, so the -/// `&'static` references cost nothing. -static PROVIDERS: &[&dyn PrProvider] = &[&GitHubProvider, &AzureProvider]; +fn parse_repository(input: &str) -> Option { + azure::parse_repository(input) + .map(Repository::Azure) + .or_else(|| github::parse_repository(input).map(Repository::GitHub)) +} -/// Used when no provider matches a bare PR number's remote. -const DEFAULT_PROVIDER: &dyn PrProvider = &GitHubProvider; +fn resolve_pr_reference(input: &str, context: &RepositoryContext) -> Result { + match parse_pr_reference(input) { + Some(PrReference::Number(number)) => match &context.repository { + Some(Repository::GitHub(repo)) => Ok(PrReference::GitHub(repo.with_number(number))), + Some(Repository::Azure(repo)) => Ok(PrReference::Azure(repo.with_number(number))), + None => Err(PrError::InvalidRef(match &context.origin { + Some(origin) => { + format!("unsupported repository origin: {}", safe_diagnostic(origin)) + } + None => { + "could not determine repository; configure origin or pass --origin".to_string() + } + })), + }, + Some(reference) => Ok(reference), + None => Err(PrError::InvalidRef(format!( + "{}. Use a PR number or an exact GitHub/Azure DevOps PR URL.", + safe_diagnostic(input) + ))), + } +} -/// True if `input` looks like a PR reference (a known PR URL or a bare number). pub fn is_pr_reference(input: &str) -> bool { - PROVIDERS.iter().any(|p| p.matches_url(input)) || input.parse::().is_ok() + parse_pr_reference(input).is_some() } -fn read_origin_url() -> Option { - let output = Command::new("git") - .args(["remote", "get-url", "origin"]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if url.is_empty() { - None - } else { - Some(url) - } -} - -/// Pick a provider from the git `origin` remote (and any `--origin` override), -/// defaulting to GitHub when nothing matches. -fn provider_for_origin(repo_override: Option<&str>) -> &'static dyn PrProvider { - let candidates = [repo_override.map(|s| s.to_string()), read_origin_url()]; - for candidate in candidates.into_iter().flatten() { - if let Some(p) = PROVIDERS - .iter() - .copied() - .find(|p| p.matches_origin(&candidate)) - { - return p; +pub fn fetch_pr_info(input: &str, context: &RepositoryContext) -> Result { + match resolve_pr_reference(input, context)? { + PrReference::GitHub(reference) => github::fetch_pr_info(&reference), + PrReference::Azure(reference) => azure::fetch_pr_info(&reference), + PrReference::Number(_) => { + unreachable!("bare PR numbers are resolved using repository context") } } - DEFAULT_PROVIDER } -/// Pick a provider from a PR URL/number, falling back to origin detection. -fn provider_for_input(input: &str, repo_override: Option<&str>) -> &'static dyn PrProvider { - if let Some(p) = PROVIDERS.iter().copied().find(|p| p.matches_url(input)) { - return p; +pub fn detect_current_branch_pr(context: &RepositoryContext) -> Result { + let branch = context.current_branch.as_deref().ok_or_else(|| { + PrError::NotFound("could not determine the current branch or bookmark".to_string()) + })?; + match &context.repository { + Some(Repository::GitHub(repo)) => github::detect_current_branch_pr(repo, branch), + Some(Repository::Azure(repo)) => azure::detect_current_branch_pr(repo, branch), + None => Err(PrError::InvalidRef(match &context.origin { + Some(origin) => { + format!("unsupported repository origin: {}", safe_diagnostic(origin)) + } + None => "could not determine repository; configure origin or pass --origin".to_string(), + })), } - provider_for_origin(repo_override) -} - -pub fn fetch_pr_info(input: &str, repo_override: Option<&str>) -> Result { - provider_for_input(input, repo_override).fetch_pr_info(input, repo_override) -} - -pub fn detect_current_branch_pr(repo_override: Option<&str>) -> Result { - provider_for_origin(repo_override).detect_current_branch_pr(repo_override) } pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, PrError> { - pr.provider.load_pr_file_diffs(pr) + match &pr.provider { + PrProvider::GitHub { .. } => github::load_pr_file_diffs(pr), + PrProvider::Azure { org_url, project } => azure::load_pr_file_diffs(org_url, project, pr), + } } -pub fn fetch_viewed_files(pr: &PrInfo) -> Result, PrError> { - match pr.provider.viewed_sync() { - Some(vs) => vs.fetch(pr), - None => Ok(HashSet::new()), +/// `None` means the provider does not support per-file viewed state. +pub fn fetch_viewed_files(pr: &PrInfo) -> Result>, PrError> { + match &pr.provider { + PrProvider::GitHub { .. } => github::fetch_viewed_files(pr).map(Some), + PrProvider::Azure { .. } => Ok(None), } } +pub fn supports_viewed_files(pr: &PrInfo) -> bool { + matches!(&pr.provider, PrProvider::GitHub { .. }) +} + pub fn pr_file_web_url(pr: &PrInfo, filename: &str) -> Option { - pr.provider.file_web_url(pr, filename) + match &pr.provider { + PrProvider::GitHub { .. } => Some(github::file_web_url(pr, filename)), + PrProvider::Azure { org_url, project } => { + Some(azure::file_web_url(org_url, project, pr, filename)) + } + } } pub fn mark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { @@ -197,29 +206,187 @@ pub fn unmark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { } fn set_file_viewed_async(pr: &PrInfo, file_path: &str, viewed: bool) { - if pr.provider.viewed_sync().is_none() { + if !matches!(&pr.provider, PrProvider::GitHub { .. }) { return; } let pr = pr.clone(); let path = file_path.to_string(); thread::spawn(move || { - if let Some(vs) = pr.provider.viewed_sync() { - let _ = vs.set(&pr, &path, viewed); - } + let PrProvider::GitHub { node_id } = &pr.provider else { + return; + }; + let _ = github::set_file_viewed(node_id, &path, viewed); }); } +#[derive(Clone, Copy)] +pub(super) struct HttpUrl<'a> { + pub host: &'a str, + pub path: &'a str, +} + +pub(super) fn strip_http_userinfo(input: &str) -> Cow<'_, str> { + let Some((scheme, rest)) = input.split_once("://") else { + return input.into(); + }; + if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { + return input.into(); + } + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + let Some((_, host)) = authority.rsplit_once('@') else { + return input.into(); + }; + format!("{}://{}{}", scheme, host, &rest[authority_end..]).into() +} + +struct SafeDiagnostic<'a>(&'a str); + +impl fmt::Display for SafeDiagnostic<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(strip_http_userinfo(self.0).as_ref()) + } +} + +fn safe_diagnostic(input: &str) -> SafeDiagnostic<'_> { + SafeDiagnostic(input) +} + +pub(super) fn parse_http_url(input: &str) -> Option> { + let (scheme, rest) = input.split_once("://")?; + if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { + return None; + } + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + if authority.is_empty() || authority.contains('@') || authority.contains(':') { + return None; + } + let suffix = &rest[authority_end..]; + let path_end = suffix.find(['?', '#']).unwrap_or(suffix.len()); + let path = &suffix[..path_end]; + Some(HttpUrl { + host: authority, + path, + }) +} + +pub(super) fn decoded_path_segments(path: &str) -> Option> { + let path = path.strip_prefix('/')?; + let path = path.strip_suffix('/').unwrap_or(path); + if path.is_empty() { + return Some(Vec::new()); + } + path.split('/').map(decode_path_segment).collect() +} + +fn decode_path_segment(segment: &str) -> Option { + if segment.is_empty() { + return None; + } + let bytes = segment.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' { + let high = hex_value(*bytes.get(index + 1)?)?; + let low = hex_value(*bytes.get(index + 2)?)?; + decoded.push((high << 4) | low); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; #[test] - fn is_pr_reference_detects_forms() { + fn routes_only_exact_provider_urls() { assert!(is_pr_reference("123")); assert!(is_pr_reference("https://github.com/o/r/pull/1")); assert!(is_pr_reference( "https://dev.azure.com/o/p/_git/r/pullrequest/1" )); - assert!(!is_pr_reference("main..feature")); + assert!(!is_pr_reference("https://github.com.evil.test/o/r/pull/1")); + assert!(!is_pr_reference( + "https://evil.test/dev.azure.com/o/p/_git/r/pullrequest/1" + )); + assert!(!is_pr_reference("https://github.com/o/r/issues/1")); + } + + #[test] + fn repository_override_replaces_backend_origin() { + let context = RepositoryContext::from_sources( + Some("https://dev.azure.com/org/project/_git/repo"), + Some("git@github.com:owner/repo.git"), + Some("feature"), + ); + + assert!(matches!(context.repository, Some(Repository::Azure(_)))); + assert_eq!(context.current_branch.as_deref(), Some("feature")); + } + + #[test] + fn github_repository_origin_accepts_https_credentials() { + let context = RepositoryContext::from_sources( + None, + Some("https://user:TOKEN@github.com/owner/repo.git"), + Some("feature"), + ); + + assert!(matches!(context.repository, Some(Repository::GitHub(_)))); + } + + #[test] + fn unsupported_origin_error_redacts_https_credentials() { + let context = RepositoryContext::from_sources( + None, + Some("https://user:SECRET@example.com/owner/repo.git"), + Some("feature"), + ); + + let error = resolve_pr_reference("12", &context).expect_err("unsupported origin"); + + assert_eq!( + error.to_string(), + "invalid PR reference: unsupported repository origin: https://example.com/owner/repo.git" + ); + } + + #[test] + fn invalid_reference_error_redacts_https_credentials() { + let context = RepositoryContext::from_sources(None, None, None); + + let error = resolve_pr_reference( + "https://user:SECRET@example.com/owner/repo/pull/1", + &context, + ) + .expect_err("unknown provider"); + + assert!(!error.to_string().contains("SECRET")); + } + + #[test] + fn decodes_all_percent_encoded_utf8_bytes() { + assert_eq!( + decode_path_segment("My%2BProject").as_deref(), + Some("My+Project") + ); + assert_eq!(decode_path_segment("caf%C3%A9").as_deref(), Some("café")); + assert!(decode_path_segment("bad%2").is_none()); } } diff --git a/src/config/cli.rs b/src/config/cli.rs index a6295d93..e7b90026 100644 --- a/src/config/cli.rs +++ b/src/config/cli.rs @@ -142,7 +142,7 @@ pub enum Commands { #[arg(long)] focus: Option, - /// Origin repository in owner/repo format (default: origin git remote) + /// Repository override: GitHub owner/repo, or a full Azure DevOps repository remote URL #[arg(long)] origin: Option, diff --git a/src/main.rs b/src/main.rs index b335bf5e..e8a2fa2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,7 @@ async fn run() -> Result<(), LumenError> { // Get VCS backend based on CLI override or auto-detection let cwd = std::env::current_dir()?; let vcs_override = cli.vcs.map(VcsBackendType::from); - let backend = vcs::get_backend(&cwd, vcs_override)?; + let backend = vcs::get_backend(&cwd, vcs_override); match cli.command { Commands::Explain { @@ -49,6 +49,7 @@ async fn run() -> Result<(), LumenError> { query, list, } => { + let backend = backend?; let git_entity = if list { let sha = LumenCommand::get_sha_from_fzf(backend.as_ref())?; let info = backend.get_commit(&sha)?; @@ -98,6 +99,7 @@ async fn run() -> Result<(), LumenError> { .await?; } Commands::List => { + let backend = backend?; eprintln!("Warning: 'lumen list' is deprecated. Use 'lumen explain --list' instead."); command .execute(command::CommandType::List { @@ -106,6 +108,7 @@ async fn run() -> Result<(), LumenError> { .await? } Commands::Draft { context } => { + let backend = backend?; // Draft always uses staged diff (git convention) let diff = backend.get_working_tree_diff(true)?; let git_entity = GitEntity::Diff(Diff::from_working_tree_diff(diff, true)?); @@ -134,6 +137,11 @@ async fn run() -> Result<(), LumenError> { origin, wrap, } => { + let backend = match backend { + Ok(backend) => Some(backend), + Err(vcs::VcsError::NotARepository) => None, + Err(error) => return Err(error.into()), + }; let options = command::diff::DiffOptions { reference, pr, @@ -146,7 +154,7 @@ async fn run() -> Result<(), LumenError> { origin, wrap: wrap || config.wrap.unwrap_or(false), }; - command::diff::run_diff_ui(options, backend.as_ref())?; + command::diff::run_diff_ui(options, backend.as_deref())?; } Commands::Configure => { command::configure::ConfigureCommand::execute()?; diff --git a/src/vcs/backend.rs b/src/vcs/backend.rs index 43743a90..d27fd7e8 100644 --- a/src/vcs/backend.rs +++ b/src/vcs/backend.rs @@ -83,6 +83,10 @@ pub trait VcsBackend { /// Get current branch name (or bookmark for jj). fn get_current_branch(&self) -> Result, VcsError>; + /// Get the canonical URL of the `origin` remote, when the backend exposes + /// remotes. Backends without a remote concept return `None`. + fn origin_url(&self) -> Result, VcsError>; + /// Get commit log formatted for fzf selection. fn get_commit_log_for_fzf(&self) -> Result; diff --git a/src/vcs/git.rs b/src/vcs/git.rs index 4c9a6997..78a1815f 100644 --- a/src/vcs/git.rs +++ b/src/vcs/git.rs @@ -137,16 +137,15 @@ impl GitBackend { /// Open a git repository at the given path. /// Uses git2::Repository::discover to find the repo from any subdirectory. pub fn new(path: &Path) -> Result { - let discover_result = Repository::discover(path);//map_err(|_| VcsError::NotARepository)?; + let discover_result = Repository::discover(path); //map_err(|_| VcsError::NotARepository)?; return match discover_result { - Ok(repo) => Ok(GitBackend { repo }), + Ok(repo) => Ok(GitBackend { repo }), Err(error) => { // Print libgit2 so there is a chance to diagnose any errors with git println!("Error on repository discovery: {error:?}"); - return Err(VcsError::NotARepository) - }, + return Err(VcsError::NotARepository); + } }; - } /// Open a git repository from the current working directory. @@ -537,6 +536,17 @@ impl VcsBackend for GitBackend { } } + fn origin_url(&self) -> Result, VcsError> { + match self.repo.find_remote("origin") { + Ok(remote) => Ok(remote.url().map(str::to_owned)), + Err(error) if error.code() == git2::ErrorCode::NotFound => Ok(None), + Err(error) => Err(VcsError::Other(format!( + "failed to read origin remote: {}", + error + ))), + } + } + fn get_commit_log_for_fzf(&self) -> Result { let mut revwalk = self .repo @@ -843,6 +853,21 @@ mod tests { assert!(branch.is_some()); } + #[test] + fn test_origin_url_reads_origin_remote() { + let repo = RepoGuard::new(); + let raw_repo = Repository::open(&repo.dir).expect("should open repo"); + raw_repo + .remote("origin", "git@github.com:owner/repo.git") + .expect("should add origin"); + let backend = GitBackend::from_cwd().expect("should open repo"); + + assert_eq!( + backend.origin_url().expect("should read origin").as_deref(), + Some("git@github.com:owner/repo.git") + ); + } + #[test] fn test_get_file_content_at_ref() { let _repo = RepoGuard::new(); diff --git a/src/vcs/jj.rs b/src/vcs/jj.rs index 3b95af7b..7b04d0a0 100644 --- a/src/vcs/jj.rs +++ b/src/vcs/jj.rs @@ -23,8 +23,8 @@ use jj_lib::repo::{ReadonlyRepo, Repo, StoreFactories}; use jj_lib::repo_path::RepoPath; use jj_lib::repo_path::RepoPathUiConverter; use jj_lib::revset::{ - RevsetAliasesMap, RevsetDiagnostics, RevsetExtensions, RevsetParseContext, - RevsetWorkspaceContext, SymbolResolver, SymbolResolverExtension, + ResolvedRevsetExpression, RevsetAliasesMap, RevsetDiagnostics, RevsetExtensions, + RevsetParseContext, RevsetWorkspaceContext, SymbolResolver, SymbolResolverExtension, }; use jj_lib::settings::UserSettings; use jj_lib::time_util::DatePatternContext; @@ -715,22 +715,92 @@ impl VcsBackend for JjBackend { } fn get_current_branch(&self) -> Result, VcsError> { - // jj uses "bookmarks" instead of "branches" - // Find a bookmark that points to the working copy commit (@) let wc_commit = self.resolve_single_commit("@")?; let wc_commit_id = wc_commit.id(); - - // Iterate local bookmarks to find one pointing to @ - for (name, target) in self.repo.view().local_bookmarks() { - if let Some(commit_id) = target.as_normal() { - if commit_id == wc_commit_id { - return Ok(Some(name.as_str().to_string())); - } + let local_bookmarks: Vec<_> = self + .repo + .view() + .local_bookmarks() + .filter_map(|(name, target)| { + target + .as_normal() + .map(|commit_id| (name.as_str().to_string(), commit_id.clone())) + }) + .collect(); + + let select_unique = |mut names: Vec, location: &str| { + names.sort(); + match names.as_slice() { + [] => Ok(None), + [name] => Ok(Some(name.clone())), + _ => Err(VcsError::Other(format!( + "ambiguous current jj bookmark: {} {}. Move or delete bookmarks so only one candidate remains, or specify the PR explicitly", + location, + names.join(", ") + ))), } + }; + + let exact_names = local_bookmarks + .iter() + .filter(|(_, commit_id)| commit_id == wc_commit_id) + .map(|(name, _)| name.clone()) + .collect::>(); + if !exact_names.is_empty() { + return select_unique(exact_names, "local bookmarks pointing exactly at @:"); } - // No bookmark points to @ - Ok(None) + if local_bookmarks.is_empty() { + return Ok(None); + } + + let bookmark_commit_ids = local_bookmarks + .iter() + .map(|(_, commit_id)| commit_id.clone()) + .collect(); + let nearest_bookmarks = ResolvedRevsetExpression::commit(wc_commit_id.clone()) + .ancestors() + .intersection(&ResolvedRevsetExpression::commits(bookmark_commit_ids)) + .heads() + .evaluate(self.repo.as_ref()) + .map_err(|error| { + VcsError::Other(format!( + "failed to find the nearest local jj bookmark: {}", + error + )) + })?; + let nearest_commit_ids = nearest_bookmarks + .iter() + .collect::, _>>() + .map_err(|error| { + VcsError::Other(format!( + "failed to inspect the nearest local jj bookmark: {}", + error + )) + })?; + let nearest_names = local_bookmarks + .into_iter() + .filter(|(_, commit_id)| nearest_commit_ids.contains(commit_id)) + .map(|(name, _)| name) + .collect(); + + select_unique(nearest_names, "nearest local bookmarks reachable from @:") + } + + fn origin_url(&self) -> Result, VcsError> { + let backend = jj_lib::git::get_git_backend(self.repo.store()) + .map_err(|error| VcsError::Other(error.to_string()))?; + let repository = git2::Repository::open(backend.git_repo_path()) + .map_err(|error| VcsError::Other(format!("failed to open jj Git store: {}", error)))?; + let origin = match repository.find_remote("origin") { + Ok(remote) => Ok(remote.url().map(str::to_owned)), + Err(error) if error.code() == git2::ErrorCode::NotFound => Ok(None), + Err(error) => Err(VcsError::Other(format!( + "failed to read jj origin remote: {}", + error + ))), + }; + origin } fn resolve_ref(&self, reference: &str) -> Result { @@ -1307,10 +1377,114 @@ mod tests { // Default jj repo has no bookmarks assert!( branch.unwrap().is_none(), - "should return None when no bookmark points to @" + "should return None when no bookmark is reachable from @" + ); + } + + #[test] + fn test_get_current_branch_prefers_unique_bookmark_at_working_copy() { + let Some(repo) = JjRepoGuard::new() else { + eprintln!("Skipping test: jj not available"); + return; + }; + + assert!(crate::vcs::test_utils::jj( + &repo.dir, + &["bookmark", "create", "parent"] + )); + assert!(crate::vcs::test_utils::jj(&repo.dir, &["new"])); + assert!(crate::vcs::test_utils::jj( + &repo.dir, + &["bookmark", "create", "current"] + )); + + let backend = JjBackend::new(&repo.dir).expect("should load backend"); + assert_eq!( + backend.get_current_branch().expect("should get bookmark"), + Some("current".to_string()) ); } + #[test] + fn test_get_current_branch_finds_nearest_unique_ancestor_bookmark() { + let Some(repo) = JjRepoGuard::new() else { + eprintln!("Skipping test: jj not available"); + return; + }; + + assert!(crate::vcs::test_utils::jj( + &repo.dir, + &["bookmark", "create", "older"] + )); + assert!(crate::vcs::test_utils::jj(&repo.dir, &["new"])); + assert!(crate::vcs::test_utils::jj( + &repo.dir, + &["bookmark", "create", "feature"] + )); + assert!(crate::vcs::test_utils::jj(&repo.dir, &["new"])); + + let backend = JjBackend::new(&repo.dir).expect("should load backend"); + assert_eq!( + backend.get_current_branch().expect("should get bookmark"), + Some("feature".to_string()) + ); + } + + #[test] + fn test_get_current_branch_rejects_ambiguous_exact_bookmarks() { + let Some(repo) = JjRepoGuard::new() else { + eprintln!("Skipping test: jj not available"); + return; + }; + + assert!(crate::vcs::test_utils::jj( + &repo.dir, + &["bookmark", "create", "feature-b"] + )); + assert!(crate::vcs::test_utils::jj( + &repo.dir, + &["bookmark", "create", "feature-a"] + )); + + let backend = JjBackend::new(&repo.dir).expect("should load backend"); + let error = backend + .get_current_branch() + .expect_err("ambiguous bookmarks should fail"); + let VcsError::Other(message) = error else { + panic!("expected VcsError::Other, got {error:?}"); + }; + assert!(message.contains("feature-a, feature-b"), "{message}"); + assert!(message.contains("pointing exactly at @"), "{message}"); + } + + #[test] + fn test_get_current_branch_rejects_ambiguous_nearest_bookmarks() { + let Some(repo) = JjRepoGuard::new() else { + eprintln!("Skipping test: jj not available"); + return; + }; + + assert!(crate::vcs::test_utils::jj( + &repo.dir, + &["bookmark", "create", "feature-b"] + )); + assert!(crate::vcs::test_utils::jj( + &repo.dir, + &["bookmark", "create", "feature-a"] + )); + assert!(crate::vcs::test_utils::jj(&repo.dir, &["new"])); + + let backend = JjBackend::new(&repo.dir).expect("should load backend"); + let error = backend + .get_current_branch() + .expect_err("ambiguous bookmarks should fail"); + let VcsError::Other(message) = error else { + panic!("expected VcsError::Other, got {error:?}"); + }; + assert!(message.contains("feature-a, feature-b"), "{message}"); + assert!(message.contains("nearest local bookmarks"), "{message}"); + } + #[test] fn test_diff_excludes_lock_files() { use std::fs; From 100b78d9c6292a2de8fd329b5bbaabfe5e30eaa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=ABl=20Kuijper?= Date: Wed, 15 Jul 2026 23:55:13 +0200 Subject: [PATCH 13/13] refactor(diff): simplify PR provider architecture --- src/command/diff/app.rs | 218 +++++---- src/command/diff/app_mode.rs | 51 +++ src/command/diff/git.rs | 17 - src/command/diff/mod.rs | 34 +- src/command/diff/pr_provider/azure/client.rs | 177 +++++--- src/command/diff/pr_provider/azure/mod.rs | 96 ++-- src/command/diff/pr_provider/github.rs | 450 ++++++++++++------- src/command/diff/pr_provider/mod.rs | 227 ++++++---- src/command/diff/pr_provider/viewed.rs | 162 +++++++ src/command/diff/render/footer.rs | 14 +- src/vcs/backend.rs | 7 + src/vcs/jj.rs | 27 +- 12 files changed, 982 insertions(+), 498 deletions(-) create mode 100644 src/command/diff/app_mode.rs create mode 100644 src/command/diff/pr_provider/viewed.rs diff --git a/src/command/diff/app.rs b/src/command/diff/app.rs index cb909cbc..e65dfa73 100644 --- a/src/command/diff/app.rs +++ b/src/command/diff/app.rs @@ -36,10 +36,11 @@ fn open_tui_writer() -> io::Result> { } use super::annotation::{AnnotationEditor, AnnotationEditorResult}; +use super::app_mode::AppMode; use super::coordinates::{extract_selected_text, PanelLayout}; use super::git::{get_current_branch, load_file_diffs, load_single_commit_diffs}; use super::highlight; -use super::pr_provider::{load_pr_file_diffs, pr_file_web_url, PrError}; +use super::pr_provider::{PrError, ViewedFileSync}; use super::render::{ render_diff, render_empty_state, truncate_path, FilePickerItem, KeyBind, KeyBindSection, Modal, ModalContent, ModalFileStatus, ModalResult, @@ -51,10 +52,7 @@ use super::types::{ SelectionMode, SidebarItem, }; use super::watcher::{setup_watcher, WatchEvent}; -use super::{ - fetch_viewed_files, mark_file_as_viewed_async, supports_viewed_files, - unmark_file_as_viewed_async, DiffOptions, PrInfo, -}; +use super::{DiffOptions, PrInfo}; use spinoff::{spinners, Color, Spinner}; use crate::commit_reference::CommitReference; @@ -250,23 +248,32 @@ fn format_annotation_preview(annotation: &super::state::Annotation) -> String { } } -pub fn run_app_with_pr( - options: DiffOptions, - pr_info: PrInfo, - backend: Option<&dyn VcsBackend>, -) -> io::Result<()> { - let file_diffs = load_pr_file_diffs(&pr_info) +pub fn run_app_with_pr(options: DiffOptions, pr_info: PrInfo) -> io::Result<()> { + let file_diffs = load_filtered_pr_diffs(&pr_info, options.file.as_deref()) .map_err(|error| io::Error::other(format!("failed to load PR diffs: {error}")))?; - run_app_internal(options, Some(pr_info), file_diffs, None, backend) + run_app_internal( + options, + file_diffs, + AppMode::PullRequest { + pr: Box::new(pr_info), + }, + ) } -pub fn run_app( - options: DiffOptions, - pr_info: Option, - backend: &dyn VcsBackend, -) -> io::Result<()> { +fn load_filtered_pr_diffs( + pr: &PrInfo, + filter: Option<&[String]>, +) -> Result, PrError> { + let mut diffs = pr.load_file_diffs()?; + if let Some(filter) = filter { + diffs.retain(|diff| filter.contains(&diff.filename)); + } + Ok(diffs) +} + +pub fn run_app(options: DiffOptions, backend: &dyn VcsBackend) -> io::Result<()> { let file_diffs = load_file_diffs(&options, backend); - run_app_internal(options, pr_info, file_diffs, None, Some(backend)) + run_app_internal(options, file_diffs, AppMode::Local { backend }) } pub fn run_app_stacked( @@ -277,7 +284,14 @@ pub fn run_app_stacked( // Load the first commit's diff let first_commit = &commits[0]; let file_diffs = load_single_commit_diffs(&first_commit.commit_id, &options.file, backend); - run_app_internal(options, None, file_diffs, Some(commits), Some(backend)) + run_app_internal( + options, + file_diffs, + AppMode::Stacked { + backend, + initial_commits: commits, + }, + ) } fn apply_viewed_paths( @@ -297,19 +311,18 @@ fn apply_viewed_paths( /// Sync per-file viewed state from the hosting provider into local state. /// `None` means the provider does not support viewed-file synchronization. fn sync_viewed_files_from_provider( - pr_info: &PrInfo, state: &mut AppState, + viewed_sync: Option<&ViewedFileSync>, ) -> Result, String> { - if !supports_viewed_files(pr_info) { + let Some(sync) = viewed_sync else { return Ok(None); - } - - let viewed_paths = fetch_viewed_files(pr_info).map_err(|error| error.to_string())?; - Ok(apply_viewed_paths(state, viewed_paths.as_ref())) + }; + let viewed_paths = sync.viewed_paths().map_err(|error| error.to_string())?; + Ok(apply_viewed_paths(state, Some(&viewed_paths))) } -fn sync_viewed_files_on_startup(pr_info: &PrInfo, state: &mut AppState) { - if !supports_viewed_files(pr_info) { +fn sync_viewed_files_on_startup(state: &mut AppState, viewed_sync: Option<&ViewedFileSync>) { + if viewed_sync.is_none() { return; } @@ -318,7 +331,7 @@ fn sync_viewed_files_on_startup(pr_info: &PrInfo, state: &mut AppState) { format!("Syncing viewed status for {} files", state.file_diffs.len()), Color::Cyan, ); - match sync_viewed_files_from_provider(pr_info, state) { + match sync_viewed_files_from_provider(state, viewed_sync) { Ok(Some(viewed_count)) => { spinner.success(&format!("{} files marked as viewed", viewed_count)); } @@ -343,10 +356,8 @@ fn apply_pr_reload( fn run_app_internal( options: DiffOptions, - pr_info: Option, file_diffs: Vec, - stacked_commits: Option>, - backend: Option<&dyn VcsBackend>, + mut mode: AppMode<'_>, ) -> io::Result<()> { theme::init(options.theme.as_deref()); highlight::init(); @@ -354,13 +365,15 @@ fn run_app_internal( // Initialize state before TUI so we can sync viewed files let mut state = AppState::new(file_diffs, options.focus.as_deref()); state.settings.wrap = options.wrap; - state.set_vcs_name(backend.map_or("pr", |backend| backend.name())); + state.set_vcs_name(mode.backend().map_or("pr", VcsBackend::name)); // Set diff reference for annotation export context - let diff_ref_str = if let Some(pr) = &pr_info { + let diff_ref_str = if let Some(pr) = mode.pr() { Some(format!( "PR #{} ({}...{})", - pr.number, pr.base_ref, pr.head_ref + pr.number(), + pr.base_ref(), + pr.head_ref() )) } else { options.reference.as_ref().map(|r| match r { @@ -373,13 +386,15 @@ fn run_app_internal( state.set_diff_reference(diff_ref_str); // Initialize stacked mode if commits were provided - if let Some(commits) = stacked_commits { + if let Some(commits) = mode.take_initial_commits() { state.init_stacked_mode(commits); } + let mut viewed_sync = mode.pr().and_then(ViewedFileSync::new); + // Load viewed files from the provider on startup in PR mode (before TUI starts). - if let Some(ref pr) = pr_info { - sync_viewed_files_on_startup(pr, &mut state); + if mode.pr().is_some() { + sync_viewed_files_on_startup(&mut state, viewed_sync.as_ref()); } // Now enter TUI mode. Use /dev/tty when stdout is captured so the @@ -398,8 +413,8 @@ fn run_app_internal( let mut terminal = Terminal::new(CrosstermBackend::new(tui_writer))?; - state.watching = options.watch && pr_info.is_none(); - let (mut _watch_handle, mut watch_rx) = if state.watching && pr_info.is_none() { + state.watching = options.watch && !mode.is_pull_request(); + let (mut _watch_handle, mut watch_rx) = if state.watching { if let Some((handle, rx)) = setup_watcher() { (Some(handle), Some(rx)) } else { @@ -415,8 +430,31 @@ fn run_app_internal( let mut pending_watch_event: Option = None; let mut pending_events: VecDeque = VecDeque::new(); let mut send_annotations_on_exit = false; + let branch_fallback = mode + .backend() + .map(get_current_branch) + .unwrap_or_else(|| "unknown".to_string()); 'main: loop { + if let Some(sync) = viewed_sync.as_mut() { + for completion in sync.drain() { + if let Err(error) = completion.result { + let reconciliation = + sync_viewed_files_from_provider(&mut state, Some(sync)).err(); + let reconciliation = reconciliation + .map(|error| format!("\n\nCould not refresh viewed status: {error}")) + .unwrap_or_default(); + active_modal = Some(Modal::info( + "Viewed status update failed", + format!( + "Could not update {}.\n\n{error}{reconciliation}", + completion.path + ), + )); + } + } + } + if let Some(ref rx) = watch_rx { match rx.try_recv() { Ok(event) => { @@ -431,17 +469,21 @@ fn run_app_internal( if state.needs_reload { // Clear this before loading so a provider failure does not retry every frame. state.needs_reload = false; - if let Some(ref pr) = pr_info { - // In PR mode, reload from the hosting provider - let changed_files = pending_watch_event - .as_ref() - .map(|event| &event.changed_files); - match apply_pr_reload(&mut state, load_pr_file_diffs(pr), changed_files) { - Ok(()) => { - pending_watch_event.take(); - // Re-sync viewed files from the hosting provider when supported. - if supports_viewed_files(pr) { - if let Err(error) = sync_viewed_files_from_provider(pr, &mut state) { + match &mode { + AppMode::PullRequest { pr } => { + let changed_files = pending_watch_event + .as_ref() + .map(|event| &event.changed_files); + match apply_pr_reload( + &mut state, + load_filtered_pr_diffs(pr, options.file.as_deref()), + changed_files, + ) { + Ok(()) => { + pending_watch_event.take(); + if let Err(error) = + sync_viewed_files_from_provider(&mut state, viewed_sync.as_ref()) + { active_modal = Some(Modal::info( "Viewed status sync failed", format!( @@ -450,23 +492,21 @@ fn run_app_internal( )); } } - } - Err(e) => { - active_modal = Some(Modal::info( - "Reload failed", - format!( - "Could not reload PR diffs. The current diff and review state were preserved.\n\n{e}" - ), - )); + Err(error) => { + active_modal = Some(Modal::info( + "Reload failed", + format!( + "Could not reload PR diffs. The current diff and review state were preserved.\n\n{error}" + ), + )); + } } } - } else { - let backend = backend - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "not a repository"))?; - let file_diffs = load_file_diffs(&options, backend); - // Pass changed files to reload so it can unmark them from viewed. - let changed_files = pending_watch_event.take().map(|e| e.changed_files); - state.reload(file_diffs, changed_files.as_ref()); + AppMode::Local { backend } | AppMode::Stacked { backend, .. } => { + let file_diffs = load_file_diffs(&options, *backend); + let changed_files = pending_watch_event.take().map(|event| event.changed_files); + state.reload(file_diffs, changed_files.as_ref()); + } } } @@ -493,9 +533,6 @@ fn run_app_internal( .viewed_hunks .get(&diff.filename) .unwrap_or(&empty_viewed_hunks); - let branch_fallback = backend - .map(get_current_branch) - .unwrap_or_else(|| "unknown".to_string()); let commit_ref = state.diff_reference.as_deref().unwrap_or(&branch_fallback); let row_offset = std::cell::Cell::new(0usize); let gaps_cell = std::cell::RefCell::new(Vec::new()); @@ -529,7 +566,7 @@ fn run_app_internal( state.diff_fullscreen, &state.search_state, commit_ref, - pr_info.as_ref(), + mode.pr(), state.focused_hunk, hunks, state.stacked_mode, @@ -1010,7 +1047,7 @@ fn run_app_internal( // Left arrow click (first 4 columns to cover " < ") if mouse.column < 4 && state.current_commit_index > 0 { let new_index = state.current_commit_index - 1; - if let Some(backend) = backend { + if let Some(backend) = mode.stacked_backend() { navigate_stacked_commit( &mut state, new_index, &options, backend, ); @@ -1022,7 +1059,7 @@ fn run_app_internal( < state.stacked_commits.len().saturating_sub(1) { let new_index = state.current_commit_index + 1; - if let Some(backend) = backend { + if let Some(backend) = mode.stacked_backend() { navigate_stacked_commit( &mut state, new_index, &options, backend, ); @@ -1395,7 +1432,7 @@ fn run_app_internal( && state.current_commit_index < state.stacked_commits.len() - 1 { let new_index = state.current_commit_index + 1; - if let Some(backend) = backend { + if let Some(backend) = mode.stacked_backend() { navigate_stacked_commit( &mut state, new_index, &options, backend, ); @@ -1406,7 +1443,7 @@ fn run_app_internal( KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { if state.stacked_mode && state.current_commit_index > 0 { let new_index = state.current_commit_index - 1; - if let Some(backend) = backend { + if let Some(backend) = mode.stacked_backend() { navigate_stacked_commit( &mut state, new_index, &options, backend, ); @@ -1522,7 +1559,7 @@ fn run_app_internal( } } KeyCode::Char('w') => { - if pr_info.is_some() { + if mode.is_pull_request() { // PR mode doesn't support watching } else { state.watching = !state.watching; @@ -1612,13 +1649,8 @@ fn run_app_internal( state.viewed_files.insert(file_idx); } - // Fire off async API call if in PR mode - if let Some(ref pr) = pr_info { - if was_viewed { - unmark_file_as_viewed_async(pr, &filename); - } else { - mark_file_as_viewed_async(pr, &filename); - } + if let Some(sync) = viewed_sync.as_mut() { + sync.set(&filename, !was_viewed); } } SidebarItem::Directory { path, .. } => { @@ -1656,15 +1688,10 @@ fn run_app_internal( } } - // Fire off async API calls if in PR mode - if let Some(ref pr) = pr_info { + if let Some(sync) = viewed_sync.as_mut() { for &idx in &child_indices { let filename = &state.file_diffs[idx].filename; - if all_viewed { - unmark_file_as_viewed_async(pr, filename); - } else { - mark_file_as_viewed_async(pr, filename); - } + sync.set(filename, !all_viewed); } } } @@ -1723,13 +1750,8 @@ fn run_app_internal( } } - // Fire off async API call if in PR mode - if let Some(ref pr) = pr_info { - if was_viewed { - unmark_file_as_viewed_async(pr, &filename); - } else { - mark_file_as_viewed_async(pr, &filename); - } + if let Some(sync) = viewed_sync.as_mut() { + sync.set(&filename, !was_viewed); } } } @@ -2031,12 +2053,10 @@ fn run_app_internal( } } KeyCode::Char('o') => { - if let Some(ref pr) = pr_info { + if let Some(pr) = mode.pr() { if !state.file_diffs.is_empty() { let filename = &state.file_diffs[state.current_file].filename; - if let Some(file_url) = pr_file_web_url(pr, filename) { - let _ = open_url(&file_url); - } + let _ = open_url(&pr.file_web_url(filename)); } } } diff --git a/src/command/diff/app_mode.rs b/src/command/diff/app_mode.rs new file mode 100644 index 00000000..9d091cb3 --- /dev/null +++ b/src/command/diff/app_mode.rs @@ -0,0 +1,51 @@ +use super::PrInfo; +use crate::vcs::{StackedCommitInfo, VcsBackend}; + +pub(super) enum AppMode<'a> { + Local { + backend: &'a dyn VcsBackend, + }, + PullRequest { + pr: Box, + }, + Stacked { + backend: &'a dyn VcsBackend, + initial_commits: Vec, + }, +} + +impl<'a> AppMode<'a> { + pub fn pr(&self) -> Option<&PrInfo> { + match self { + Self::PullRequest { pr } => Some(pr.as_ref()), + Self::Local { .. } | Self::Stacked { .. } => None, + } + } + + pub fn backend(&self) -> Option<&'a dyn VcsBackend> { + match self { + Self::Local { backend } | Self::Stacked { backend, .. } => Some(*backend), + Self::PullRequest { .. } => None, + } + } + + pub fn stacked_backend(&self) -> Option<&'a dyn VcsBackend> { + match self { + Self::Stacked { backend, .. } => Some(*backend), + Self::Local { .. } | Self::PullRequest { .. } => None, + } + } + + pub fn is_pull_request(&self) -> bool { + matches!(self, Self::PullRequest { .. }) + } + + pub fn take_initial_commits(&mut self) -> Option> { + match self { + Self::Stacked { + initial_commits, .. + } => Some(std::mem::take(initial_commits)), + Self::Local { .. } | Self::PullRequest { .. } => None, + } + } +} diff --git a/src/command/diff/git.rs b/src/command/diff/git.rs index 65a0e8f7..8a957bcf 100644 --- a/src/command/diff/git.rs +++ b/src/command/diff/git.rs @@ -149,23 +149,6 @@ pub fn build_file_diff(filename: String, old_content: String, new_content: Strin } } -/// Percent-encode one URL path/query segment, keeping RFC 3986 unreserved chars. -pub(crate) fn percent_encode(segment: &str) -> String { - use std::fmt::Write; - let mut out = String::with_capacity(segment.len()); - for b in segment.bytes() { - match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - out.push(b as char) - } - _ => { - let _ = write!(out, "%{:02X}", b); - } - } - } - out -} - pub fn load_file_diffs(options: &DiffOptions, backend: &dyn VcsBackend) -> Vec { let refs = DiffRefs::from_options(options, backend); get_changed_files(options, backend) diff --git a/src/command/diff/mod.rs b/src/command/diff/mod.rs index e995f8fc..d7b7b108 100644 --- a/src/command/diff/mod.rs +++ b/src/command/diff/mod.rs @@ -1,5 +1,6 @@ mod annotation; mod app; +mod app_mode; mod context; mod coordinates; mod diff_algo; @@ -24,10 +25,7 @@ use spinoff::{spinners, Color, Spinner}; use crate::commit_reference::CommitReference; use crate::vcs::VcsBackend; -pub use pr_provider::{ - fetch_viewed_files, mark_file_as_viewed_async, supports_viewed_files, - unmark_file_as_viewed_async, PrProvider, -}; +pub use pr_provider::PrInfo; pub struct DiffOptions { pub reference: Option, @@ -42,19 +40,7 @@ pub struct DiffOptions { pub wrap: bool, } -#[derive(Clone)] -pub struct PrInfo { - pub provider: PrProvider, - pub number: u64, - pub repo_owner: String, - pub repo_name: String, - pub base_ref: String, - pub head_ref: String, - pub base_repo_owner: String, - pub head_repo_owner: Option, // None if head repo was deleted (fork deleted) -} - -pub fn run_diff_ui(mut options: DiffOptions, backend: Option<&dyn VcsBackend>) -> io::Result<()> { +pub fn run_diff_ui(options: DiffOptions, backend: Option<&dyn VcsBackend>) -> io::Result<()> { let repository_context = pr_provider::RepositoryContext::resolve(backend, options.origin.as_deref()); @@ -65,10 +51,10 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: Option<&dyn VcsBackend>) - "Detecting PR for current branch", Color::Cyan, ); - match pr_provider::detect_current_branch_pr(&repository_context) { - Ok(number) => { - spinner.success(&format!("Detected PR #{}", number)); - options.pr = Some(number); + match pr_provider::detect_current_branch_pr(&repository_context, backend) { + Ok(pr_info) => { + spinner.success(&format!("Detected PR #{}", pr_info.number())); + return app::run_app_with_pr(options, pr_info); } Err(e) => { spinner.fail(&e.to_string()); @@ -83,7 +69,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: Option<&dyn VcsBackend>) - match pr_provider::fetch_pr_info(pr_input, &repository_context) { Ok(pr_info) => { spinner.success("Fetched PR metadata"); - return app::run_app_with_pr(options, pr_info, backend); + return app::run_app_with_pr(options, pr_info); } Err(e) => { spinner.fail(&e.to_string()); @@ -99,7 +85,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: Option<&dyn VcsBackend>) - match pr_provider::fetch_pr_info(input, &repository_context) { Ok(pr_info) => { spinner.success("Fetched PR metadata"); - return app::run_app_with_pr(options, pr_info, backend); + return app::run_app_with_pr(options, pr_info); } Err(e) => { spinner.fail(&e.to_string()); @@ -152,7 +138,7 @@ pub fn run_diff_ui(mut options: DiffOptions, backend: Option<&dyn VcsBackend>) - } } - app::run_app(options, None, require_backend(backend)?) + app::run_app(options, require_backend(backend)?) } fn require_backend(backend: Option<&dyn VcsBackend>) -> io::Result<&dyn VcsBackend> { diff --git a/src/command/diff/pr_provider/azure/client.rs b/src/command/diff/pr_provider/azure/client.rs index 14c03287..0ba945f6 100644 --- a/src/command/diff/pr_provider/azure/client.rs +++ b/src/command/diff/pr_provider/azure/client.rs @@ -23,8 +23,7 @@ use std::thread; use reqwest::header::ACCEPT; use serde::Deserialize; -use crate::command::diff::git::percent_encode; -use crate::command::diff::pr_provider::PrError; +use crate::command::diff::pr_provider::{percent_encode, PrError}; use crate::command::diff::types::{is_binary_content, FileDiff, FileStatus}; const API_VERSION: &str = "7.1"; @@ -51,7 +50,7 @@ enum AdoAuth { } #[derive(Clone)] -struct AdoClient { +pub(super) struct AdoClient { http: reqwest::blocking::Client, /// Organisation base URL, e.g. `https://dev.azure.com/org`. base: String, @@ -80,6 +79,15 @@ impl AdoClient { } } + fn with_repo(mut self, repo: &str) -> Self { + self.repo = repo.to_string(); + self + } + + fn refreshed(&self) -> Result { + Self::new(&self.base, &self.project, &self.repo) + } + /// `{base}/{project}/_apis/git/...` with project URL-encoded. fn git_url(&self, suffix: &str) -> String { format!( @@ -294,6 +302,37 @@ impl AdoClient { }) .collect() } + + fn fetch_pr_metadata(&self, pr_id: u64) -> Result { + let url = format!( + "{}?api-version={}", + self.git_url(&format!("pullrequests/{}", pr_id)), + API_VERSION + ); + let detail: PrDetail = self.get(&url)?; + Ok(AzurePrMeta { + source_ref: detail.source_ref_name, + target_ref: detail.target_ref_name, + repo_name: detail + .repository + .and_then(|repository| repository.name) + .unwrap_or_else(|| self.repo.clone()), + }) + } + + fn detect_active_pr(&self, branch: &str) -> Result { + let url = format!( + "{}?searchCriteria.status=active&searchCriteria.sourceRefName={}&api-version={}", + self.git_url(&format!( + "repositories/{}/pullrequests", + percent_encode(&self.repo) + )), + percent_encode(&format!("refs/heads/{}", branch)), + API_VERSION + ); + let list: PrList = self.get(&url)?; + unique_active_pr(branch, &list.value) + } } /// The PR iterations list; we only need each iteration's numeric id. @@ -472,33 +511,7 @@ struct RepoRef { name: Option, } -pub fn fetch_pr_metadata( - org_url: &str, - project: &str, - repo: &str, - pr_id: u64, -) -> Result { - on_http_thread(|| { - let client = AdoClient::new(org_url, project, repo)?; - // PR detail is project-scoped (not repo-scoped) in the REST API. - let url = format!( - "{}?api-version={}", - client.git_url(&format!("pullrequests/{}", pr_id)), - API_VERSION - ); - let detail: PrDetail = client.get(&url)?; - Ok(AzurePrMeta { - source_ref: detail.source_ref_name, - target_ref: detail.target_ref_name, - repo_name: detail - .repository - .and_then(|r| r.name) - .unwrap_or_else(|| repo.to_string()), - }) - }) -} - -/// The active-PR search result; we take the first match's id. +/// Active PRs matching a source branch. #[derive(Deserialize)] struct PrList { value: Vec, @@ -508,40 +521,73 @@ struct PrList { #[serde(rename_all = "camelCase")] struct PrId { pull_request_id: u64, + target_ref_name: Option, } -pub fn detect_active_pr( +fn unique_active_pr(branch: &str, prs: &[PrId]) -> Result { + match prs { + [] => Err(PrError::NotFound(format!( + "No active PR found for branch {}", + branch + ))), + [pr] => Ok(pr.pull_request_id), + prs => Err(PrError::Other(format!( + "multiple active PRs found for branch {}: {}. Specify the PR explicitly", + branch, + prs.iter() + .map(|pr| match pr.target_ref_name.as_deref() { + Some(target) => format!("#{} ({})", pr.pull_request_id, target), + None => format!("#{}", pr.pull_request_id), + }) + .collect::>() + .join(", ") + ))), + } +} + +pub fn resolve_pr( org_url: &str, project: &str, repo: &str, - branch: &str, -) -> Result { + pr_id: u64, +) -> Result<(AdoClient, AzurePrMeta), PrError> { on_http_thread(|| { let client = AdoClient::new(org_url, project, repo)?; - let url = format!( - "{}?searchCriteria.status=active&searchCriteria.sourceRefName={}&api-version={}", - client.git_url(&format!( - "repositories/{}/pullrequests", - percent_encode(repo) - )), - percent_encode(&format!("refs/heads/{}", branch)), - API_VERSION - ); - let list: PrList = client.get(&url)?; - list.value - .first() - .map(|pr| pr.pull_request_id) - .ok_or_else(|| PrError::NotFound(format!("No active PR found for branch {}", branch))) + let metadata = client.fetch_pr_metadata(pr_id)?; + let client = if metadata.repo_name.is_empty() || metadata.repo_name == repo { + client + } else { + client.with_repo(&metadata.repo_name) + }; + Ok((client, metadata)) }) } -pub fn load_pr_file_diffs( +pub fn detect_active_pr( org_url: &str, project: &str, repo: &str, - pr_id: u64, -) -> Result, PrError> { - on_http_thread(|| AdoClient::new(org_url, project, repo)?.load_file_diffs(pr_id)) + branch: &str, +) -> Result<(AdoClient, u64, AzurePrMeta), PrError> { + on_http_thread(|| { + let client = AdoClient::new(org_url, project, repo)?; + let pr_id = client.detect_active_pr(branch)?; + let metadata = client.fetch_pr_metadata(pr_id)?; + let client = if metadata.repo_name.is_empty() || metadata.repo_name == repo { + client + } else { + client.with_repo(&metadata.repo_name) + }; + Ok((client, pr_id, metadata)) + }) +} + +pub fn load_pr_file_diffs(client: &AdoClient, pr_id: u64) -> Result, PrError> { + let client = client.clone(); + on_http_thread(move || match client.load_file_diffs(pr_id) { + Err(PrError::Auth(_)) => client.refreshed()?.load_file_diffs(pr_id), + result => result, + }) } /// Keep reqwest's blocking client outside any transitive async runtime. @@ -800,6 +846,35 @@ mod tests { assert_eq!(diff.status, FileStatus::Deleted); } + #[test] + fn active_pr_detection_rejects_ambiguous_matches() { + let prs = [ + PrId { + pull_request_id: 12, + target_ref_name: Some("refs/heads/main".to_string()), + }, + PrId { + pull_request_id: 18, + target_ref_name: Some("refs/heads/release".to_string()), + }, + ]; + + let error = unique_active_pr("feature", &prs).expect_err("ambiguous PRs"); + + assert!(error.to_string().contains("#12 (refs/heads/main)")); + assert!(error.to_string().contains("#18 (refs/heads/release)")); + } + + #[test] + fn active_pr_detection_accepts_one_match() { + let prs = [PrId { + pull_request_id: 12, + target_ref_name: None, + }]; + + assert_eq!(unique_active_pr("feature", &prs).unwrap(), 12); + } + #[test] fn encodes_segments() { assert_eq!(percent_encode("My Project"), "My%20Project"); diff --git a/src/command/diff/pr_provider/azure/mod.rs b/src/command/diff/pr_provider/azure/mod.rs index 6ccf1f32..aa5b4fba 100644 --- a/src/command/diff/pr_provider/azure/mod.rs +++ b/src/command/diff/pr_provider/azure/mod.rs @@ -2,11 +2,11 @@ mod client; -use crate::command::diff::git::percent_encode; use crate::command::diff::types::FileDiff; -use crate::command::diff::PrInfo; -use super::{decoded_path_segments, parse_http_url, HttpUrl, PrError, PrProvider}; +use super::{ + decoded_path_segments, parse_http_url, percent_encode, strip_http_userinfo, HttpUrl, PrError, +}; #[derive(Clone, Debug)] pub(super) struct AzureRepository { @@ -31,6 +31,42 @@ pub(super) struct AzurePrReference { id: u64, } +#[derive(Clone)] +pub(crate) struct AzurePr { + client: client::AdoClient, + pub(super) number: u64, + pub(super) org: String, + pub(super) repo_name: String, + pub(super) base_ref: String, + pub(super) head_ref: String, + org_url: String, + project: String, +} + +impl AzurePr { + fn resolved( + repository: &AzureRepository, + client: client::AdoClient, + number: u64, + meta: client::AzurePrMeta, + ) -> Self { + Self { + client, + number, + org: repository.org.clone(), + repo_name: if meta.repo_name.is_empty() { + repository.repo.clone() + } else { + meta.repo_name + }, + base_ref: strip_ref_prefix(&meta.target_ref), + head_ref: strip_ref_prefix(&meta.source_ref), + org_url: repository.org_url.clone(), + project: repository.project.clone(), + } + } +} + pub(super) fn parse_pr_url(url: HttpUrl<'_>) -> Option { let parts = decoded_path_segments(url.path)?; let (repository, pullrequest_index) = if url.host.eq_ignore_ascii_case("dev.azure.com") { @@ -151,68 +187,36 @@ fn parse_ssh_repository(input: &str) -> Option { }) } -fn strip_http_userinfo(input: &str) -> std::borrow::Cow<'_, str> { - let Some((scheme, rest)) = input.split_once("://") else { - return input.into(); - }; - let authority_end = rest.find('/').unwrap_or(rest.len()); - let authority = &rest[..authority_end]; - let Some((_, host)) = authority.rsplit_once('@') else { - return input.into(); - }; - format!("{}://{}{}", scheme, host, &rest[authority_end..]).into() -} - -pub(super) fn fetch_pr_info(reference: &AzurePrReference) -> Result { +pub(super) fn fetch_pr_info(reference: &AzurePrReference) -> Result { let az = &reference.repository; let id = reference.id; - let meta = client::fetch_pr_metadata(&az.org_url, &az.project, &az.repo, id)?; + let (client, meta) = client::resolve_pr(&az.org_url, &az.project, &az.repo, id)?; - Ok(PrInfo { - provider: PrProvider::Azure { - org_url: az.org_url.clone(), - project: az.project.clone(), - }, - number: id, - repo_owner: az.org.clone(), - repo_name: if meta.repo_name.is_empty() { - az.repo.clone() - } else { - meta.repo_name - }, - base_ref: strip_ref_prefix(&meta.target_ref), - head_ref: strip_ref_prefix(&meta.source_ref), - base_repo_owner: az.org.clone(), - head_repo_owner: Some(az.org.clone()), - }) + Ok(AzurePr::resolved(az, client, id, meta)) } pub(super) fn detect_current_branch_pr( repository: &AzureRepository, branch: &str, -) -> Result { - let id = client::detect_active_pr( +) -> Result { + let (client, id, meta) = client::detect_active_pr( &repository.org_url, &repository.project, &repository.repo, branch, )?; - Ok(id.to_string()) + Ok(AzurePr::resolved(repository, client, id, meta)) } -pub(super) fn load_pr_file_diffs( - org_url: &str, - project: &str, - pr: &PrInfo, -) -> Result, PrError> { - client::load_pr_file_diffs(org_url, project, &pr.repo_name, pr.number) +pub(super) fn load_pr_file_diffs(pr: &AzurePr) -> Result, PrError> { + client::load_pr_file_diffs(&pr.client, pr.number) } -pub(super) fn file_web_url(org_url: &str, project: &str, pr: &PrInfo, filename: &str) -> String { +pub(super) fn file_web_url(pr: &AzurePr, filename: &str) -> String { format!( "{}/{}/_git/{}/pullrequest/{}?path={}", - org_url, - project, + pr.org_url, + pr.project, pr.repo_name, pr.number, percent_encode(&format!("/{}", filename)) diff --git a/src/command/diff/pr_provider/github.rs b/src/command/diff/pr_provider/github.rs index c128d29f..1e3e3363 100644 --- a/src/command/diff/pr_provider/github.rs +++ b/src/command/diff/pr_provider/github.rs @@ -9,13 +9,10 @@ use std::thread; use serde::Deserialize; use spinoff::{spinners, Color, Spinner}; -use crate::command::diff::git::build_file_diff; -use crate::command::diff::types::FileDiff; -use crate::command::diff::PrInfo; - use super::{ - decoded_path_segments, parse_http_url, strip_http_userinfo, HttpUrl, PrError, PrProvider, + decoded_path_segments, parse_http_url, percent_encode, strip_http_userinfo, HttpUrl, PrError, }; +use crate::command::diff::types::{is_binary_content, FileDiff, FileStatus}; /// Max concurrent `gh api` requests when fetching PR file contents. /// GitHub's documented secondary rate limit caps concurrent requests at 100 @@ -36,6 +33,22 @@ query($owner: String!, $name: String!, $number: Int!, $after: String) { } "#; +const MARK_FILE_VIEWED_MUTATION: &str = r#" +mutation($pullRequestId: ID!, $path: String!) { + markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) { + clientMutationId + } +} +"#; + +const UNMARK_FILE_VIEWED_MUTATION: &str = r#" +mutation($pullRequestId: ID!, $path: String!) { + unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) { + clientMutationId + } +} +"#; + #[derive(Clone, Debug)] pub(super) struct GitHubRepository { owner: String, @@ -57,15 +70,24 @@ pub(super) struct GitHubPrReference { number: u64, } +#[derive(Clone)] +pub(crate) struct GitHubPr { + pub(super) node_id: String, + pub(super) number: u64, + pub(super) repo_owner: String, + pub(super) repo_name: String, + pub(super) base_ref: String, + pub(super) head_ref: String, + pub(super) base_repo_owner: String, + pub(super) head_repo_owner: Option, +} + pub(super) fn parse_pr_url(url: HttpUrl<'_>) -> Option { if !url.host.eq_ignore_ascii_case("github.com") { return None; } let parts = decoded_path_segments(url.path)?; - if parts.len() < 4 || parts.len() > 5 || parts[2] != "pull" { - return None; - } - if parts.len() == 5 && parts[4] != "files" && parts[4] != "commits" { + if parts.len() < 4 || parts[2] != "pull" { return None; } Some(GitHubPrReference { @@ -147,7 +169,7 @@ struct Owner { login: String, } -pub(super) fn fetch_pr_info(reference: &GitHubPrReference) -> Result { +pub(super) fn fetch_pr_info(reference: &GitHubPrReference) -> Result { let number = reference.number; let repo_owner = reference.repository.owner.clone(); let repo_name = reference.repository.repo.clone(); @@ -179,8 +201,8 @@ pub(super) fn fetch_pr_info(reference: &GitHubPrReference) -> Result Result Result { +) -> Result { let repo = format!("{}/{}", repository.owner, repository.repo); let output = Command::new("gh") .args([ @@ -221,10 +243,13 @@ pub(super) fn detect_current_branch_pr( "No PR found for the current branch".to_string(), )); } - Ok(number) + let number = number + .parse() + .map_err(|error| PrError::Other(format!("invalid PR number from gh: {error}")))?; + Ok(repository.with_number(number)) } -pub(super) fn file_web_url(pr: &PrInfo, filename: &str) -> String { +pub(super) fn file_web_url(pr: &GitHubPr, filename: &str) -> String { format!( "https://github.com/{}/{}/pull/{}/files#diff-{}", pr.repo_owner, @@ -270,11 +295,11 @@ struct FileNode { } /// Fetch the list of files that are marked as viewed on GitHub -pub(super) fn fetch_viewed_files(pr_info: &PrInfo) -> Result, PrError> { +pub(super) fn fetch_viewed_files(pr_info: &GitHubPr) -> Result, PrError> { fetch_all_viewed_files(|after| fetch_viewed_files_page(pr_info, after)) } -fn fetch_viewed_files_page(pr_info: &PrInfo, after: Option<&str>) -> Result, PrError> { +fn fetch_viewed_files_page(pr_info: &GitHubPr, after: Option<&str>) -> Result, PrError> { let mut command = Command::new("gh"); command .args(["api", "graphql"]) @@ -361,15 +386,20 @@ fn accumulate_viewed_files_page( } } -/// Mark a file as viewed on GitHub PR (blocking) -fn mark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), PrError> { - let mutation = format!( - r#"mutation {{ markFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, - node_id, file_path - ); - +pub(super) fn set_file_viewed(node_id: &str, file_path: &str, viewed: bool) -> Result<(), PrError> { + let mutation = if viewed { + MARK_FILE_VIEWED_MUTATION + } else { + UNMARK_FILE_VIEWED_MUTATION + }; let output = Command::new("gh") - .args(["api", "graphql", "-f", &format!("query={}", mutation)]) + .args(["api", "graphql"]) + .arg("-f") + .arg(format!("query={mutation}")) + .arg("-f") + .arg(format!("pullRequestId={node_id}")) + .arg("-f") + .arg(format!("path={file_path}")) .output() .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; @@ -381,37 +411,84 @@ fn mark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), PrErro Ok(()) } -/// Unmark a file as viewed on GitHub PR (blocking) -fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), PrError> { - let mutation = format!( - r#"mutation {{ unmarkFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#, - node_id, file_path - ); - - let output = Command::new("gh") - .args(["api", "graphql", "-f", &format!("query={}", mutation)]) - .output() - .map_err(|e| format!("Failed to run gh api graphql: {}", e))?; +#[derive(Clone, Deserialize)] +struct ChangedFile { + filename: String, + status: String, + previous_filename: Option, +} + +impl ChangedFile { + fn file_status(&self) -> Result { + match self.status.as_str() { + "added" => Ok(FileStatus::Added), + "removed" => Ok(FileStatus::Deleted), + "modified" | "renamed" | "copied" | "changed" | "unchanged" => Ok(FileStatus::Modified), + status => Err(PrError::Other(format!( + "github returned unsupported file status {status:?} for {}", + self.filename + ))), + } + } - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(PrError::Other(stderr.trim().to_string())); + fn old_path(&self) -> Result, PrError> { + match self.status.as_str() { + "added" => Ok(None), + "renamed" => self.previous_filename.as_deref().map(Some).ok_or_else(|| { + PrError::Other(format!( + "github returned renamed file {} without previous_filename", + self.filename + )) + }), + "copied" => Ok(self.previous_filename.as_deref()), + _ => Ok(Some(&self.filename)), + } } - Ok(()) + fn new_path(&self) -> Option<&str> { + (self.status != "removed").then_some(self.filename.as_str()) + } } -pub(super) fn set_file_viewed(node_id: &str, file_path: &str, viewed: bool) -> Result<(), PrError> { - if viewed { - mark_file_as_viewed_sync(node_id, file_path) - } else { - unmark_file_as_viewed_sync(node_id, file_path) +fn fetch_changed_files(pr: &GitHubPr) -> Result, PrError> { + let endpoint = format!( + "repos/{}/{}/pulls/{}/files?per_page=100", + pr.repo_owner, pr.repo_name, pr.number + ); + let output = Command::new("gh") + .args(["api", &endpoint, "--paginate", "--slurp"]) + .output() + .map_err(|error| PrError::Other(format!("failed to list GitHub PR files: {error}")))?; + if !output.status.success() { + return Err(PrError::Other(format!( + "failed to list GitHub PR files: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); } + let pages: Vec> = serde_json::from_slice(&output.stdout) + .map_err(|error| PrError::Other(format!("invalid GitHub PR files response: {error}")))?; + Ok(pages.into_iter().flatten().collect()) +} + +fn build_file_diff( + file: ChangedFile, + old_content: Option, + new_content: Option, +) -> Result { + let status = file.file_status()?; + let old_content = old_content.unwrap_or_default(); + let new_content = new_content.unwrap_or_default(); + let is_binary = is_binary_content(&old_content) || is_binary_content(&new_content); + Ok(FileDiff { + filename: file.filename, + old_content, + new_content, + status, + is_binary, + }) } -pub(super) fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, PrError> { - let repo_arg = format!("{}/{}", pr_info.repo_owner, pr_info.repo_name); - +pub(super) fn load_pr_file_diffs(pr_info: &GitHubPr) -> Result, PrError> { let mut spinner = Spinner::new( spinners::Dots, format!( @@ -421,36 +498,15 @@ pub(super) fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, PrEr Color::Cyan, ); - let output = Command::new("gh") - .args([ - "pr", - "diff", - &pr_info.number.to_string(), - "--repo", - &repo_arg, - ]) - .output(); - - let output = match output { - Ok(o) => o, - Err(e) => { - let msg = format!("Failed to run gh pr diff: {}", e); + let changed_files = match fetch_changed_files(pr_info) { + Ok(files) => files, + Err(error) => { + let msg = error.to_string(); spinner.fail(&msg); - return Err(PrError::Other(msg)); + return Err(error); } }; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let msg = format!("gh pr diff failed: {}", stderr.trim()); - spinner.fail(&msg); - return Err(PrError::Other(msg)); - } - - let diff_output = String::from_utf8_lossy(&output.stdout); - let changed_files = parse_changed_files_from_diff(&diff_output); let n = changed_files.len(); - if n == 0 { spinner.success("PR has no changed files"); return Ok(Vec::new()); @@ -462,23 +518,27 @@ pub(super) fn load_pr_file_diffs(pr_info: &PrInfo) -> Result, PrEr .as_ref() .map(|owner| format!("{}/{}", owner, pr_info.repo_name)) .unwrap_or_else(|| base_repo.clone()); - - let contents = fetch_pr_file_contents_parallel( + let contents = match fetch_pr_file_contents_parallel( &changed_files, &base_repo, &pr_info.base_ref, &head_repo, &pr_info.head_ref, &mut spinner, - ); + ) { + Ok(contents) => contents, + Err(error) => { + let msg = error.to_string(); + spinner.fail(&msg); + return Err(error); + } + }; - let file_diffs: Vec = changed_files + let file_diffs = changed_files .into_iter() .zip(contents) - .map(|(filename, (old_content, new_content))| { - build_file_diff(filename, old_content, new_content) - }) - .collect(); + .map(|(file, contents)| build_file_diff(file, contents.old, contents.new)) + .collect::, PrError>>()?; spinner.success(&format!("Fetched {} files", n)); Ok(file_diffs) @@ -498,58 +558,62 @@ struct FetchTask { side: Side, } +struct FileContents { + old: Option, + new: Option, +} + enum FetchEvent { Started(String), Finished { idx: usize, side: Side, filename: String, - content: String, + content: Result, }, } -/// Fetch (old, new) contents for every changed file using a bounded worker -/// pool, updating `spinner` with live progress. fn fetch_pr_file_contents_parallel( - files: &[String], + files: &[ChangedFile], base_repo: &str, base_ref: &str, head_repo: &str, head_ref: &str, spinner: &mut Spinner, -) -> Vec<(String, String)> { - let n = files.len(); - let mut tasks: Vec = Vec::with_capacity(2 * n); - for (idx, filename) in files.iter().enumerate() { - tasks.push(FetchTask { - idx, - filename: filename.clone(), - repo: base_repo.to_string(), - git_ref: base_ref.to_string(), - side: Side::Old, - }); - tasks.push(FetchTask { - idx, - filename: filename.clone(), - repo: head_repo.to_string(), - git_ref: head_ref.to_string(), - side: Side::New, - }); +) -> Result, PrError> { + let mut tasks = Vec::with_capacity(2 * files.len()); + for (idx, file) in files.iter().enumerate() { + if let Some(path) = file.old_path()? { + tasks.push(FetchTask { + idx, + filename: path.to_string(), + repo: base_repo.to_string(), + git_ref: base_ref.to_string(), + side: Side::Old, + }); + } + if let Some(path) = file.new_path() { + tasks.push(FetchTask { + idx, + filename: path.to_string(), + repo: head_repo.to_string(), + git_ref: head_ref.to_string(), + side: Side::New, + }); + } } - // Pop from the back, so process files in listed order. tasks.reverse(); let total = tasks.len(); let queue = Arc::new(Mutex::new(tasks)); let (tx, rx) = mpsc::channel::(); - let worker_count = PR_FETCH_CONCURRENCY.min(total); let mut handles = Vec::with_capacity(worker_count); for _ in 0..worker_count { let queue = Arc::clone(&queue); let tx = tx.clone(); handles.push(thread::spawn(move || loop { - let task = { queue.lock().unwrap().pop() }; + let task = { queue.lock().expect("GitHub fetch queue poisoned").pop() }; let Some(task) = task else { break }; let _ = tx.send(FetchEvent::Started(task.filename.clone())); let content = fetch_file_content_from_github(&task.repo, &task.git_ref, &task.filename); @@ -563,28 +627,35 @@ fn fetch_pr_file_contents_parallel( } drop(tx); - let mut contents: Vec<(String, String)> = vec![(String::new(), String::new()); n]; + let mut contents = std::iter::repeat_with(|| FileContents { + old: None, + new: None, + }) + .take(files.len()) + .collect::>(); + let mut failures = Vec::new(); let mut done = 0usize; - let mut in_flight: Vec = Vec::new(); - let mut last_finished: Option = None; + let mut in_flight = Vec::new(); + let mut last_finished = None; - while let Ok(ev) = rx.recv() { - match ev { - FetchEvent::Started(name) => { - in_flight.push(name); - } + while let Ok(event) = rx.recv() { + match event { + FetchEvent::Started(name) => in_flight.push(name), FetchEvent::Finished { idx, side, filename, content, } => { - if let Some(pos) = in_flight.iter().position(|f| f == &filename) { - in_flight.swap_remove(pos); + if let Some(position) = in_flight.iter().position(|path| path == &filename) { + in_flight.swap_remove(position); } - match side { - Side::Old => contents[idx].0 = content, - Side::New => contents[idx].1 = content, + match content { + Ok(content) => match side { + Side::Old => contents[idx].old = Some(content), + Side::New => contents[idx].new = Some(content), + }, + Err(error) => failures.push(error), } done += 1; last_finished = Some(filename); @@ -598,11 +669,24 @@ fn fetch_pr_file_contents_parallel( )); } - for h in handles { - let _ = h.join(); + for handle in handles { + if handle.join().is_err() { + failures.push(PrError::Other( + "GitHub file-content worker panicked".to_string(), + )); + } + } + if !failures.is_empty() { + return Err(PrError::Other( + failures + .into_iter() + .map(|error| error.to_string()) + .collect::>() + .join("; "), + )); } - contents + Ok(contents) } fn format_fetch_progress( @@ -611,11 +695,11 @@ fn format_fetch_progress( in_flight: &[String], last_finished: Option<&str>, ) -> String { - let current = if let Some(name) = in_flight.last() { - name.as_str() - } else { - last_finished.unwrap_or_default() - }; + let current = in_flight + .last() + .map(String::as_str) + .or(last_finished) + .unwrap_or_default(); if current.is_empty() { format!("Fetching files [{}/{}]", done, total) } else { @@ -623,8 +707,22 @@ fn format_fetch_progress( } } -fn fetch_file_content_from_github(repo: &str, git_ref: &str, path: &str) -> String { - let api_path = format!("repos/{}/contents/{}?ref={}", repo, path, git_ref); +fn fetch_file_content_from_github( + repo: &str, + git_ref: &str, + path: &str, +) -> Result { + let encoded_path = path + .split('/') + .map(percent_encode) + .collect::>() + .join("/"); + let api_path = format!( + "repos/{}/contents/{}?ref={}", + repo, + encoded_path, + percent_encode(git_ref) + ); let output = Command::new("gh") .args([ "api", @@ -632,32 +730,15 @@ fn fetch_file_content_from_github(repo: &str, git_ref: &str, path: &str) -> Stri "-H", "Accept: application/vnd.github.raw+json", ]) - .output(); - - match output { - Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(), - _ => String::new(), - } -} - -fn parse_changed_files_from_diff(diff: &str) -> Vec { - let mut files = Vec::new(); - - for line in diff.lines() { - if line.starts_with("diff --git") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 4 { - let b_path = parts[3]; - if let Some(filename) = b_path.strip_prefix("b/") { - files.push(filename.to_string()); - } else { - files.push(b_path.to_string()); - } - } - } + .output() + .map_err(|error| PrError::Other(format!("failed to fetch GitHub file {path}: {error}")))?; + if !output.status.success() { + return Err(PrError::Other(format!( + "failed to fetch GitHub file {path}: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); } - - files + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } #[cfg(test)] @@ -679,9 +760,14 @@ mod tests { .and_then(parse_pr_url) .is_none()); assert!( - parse_http_url("https://github.com/owner/repo/pull/123/unknown") + parse_http_url("https://github.com/owner/repo/pull/123/checks") + .and_then(parse_pr_url) + .is_some() + ); + assert!( + parse_http_url("https://github.com/owner/repo/pull/123/commits/abc") .and_then(parse_pr_url) - .is_none() + .is_some() ); } @@ -807,4 +893,54 @@ mod tests { assert!(error.to_string().contains("missing its end cursor")); } + + #[test] + fn empty_added_file_keeps_added_status() { + let file = ChangedFile { + filename: "empty.txt".to_string(), + status: "added".to_string(), + previous_filename: None, + }; + + let diff = build_file_diff(file, None, Some(String::new())).unwrap(); + + assert_eq!(diff.status, FileStatus::Added); + } + + #[test] + fn empty_removed_file_keeps_deleted_status() { + let file = ChangedFile { + filename: "empty.txt".to_string(), + status: "removed".to_string(), + previous_filename: None, + }; + + let diff = build_file_diff(file, Some(String::new()), None).unwrap(); + + assert_eq!(diff.status, FileStatus::Deleted); + } + + #[test] + fn renamed_file_uses_previous_path_for_old_side() { + let file = ChangedFile { + filename: "new.rs".to_string(), + status: "renamed".to_string(), + previous_filename: Some("old.rs".to_string()), + }; + + assert_eq!(file.old_path().unwrap(), Some("old.rs")); + assert_eq!(file.new_path(), Some("new.rs")); + } + + #[test] + fn copied_file_without_previous_path_has_no_old_side() { + let file = ChangedFile { + filename: "copy.rs".to_string(), + status: "copied".to_string(), + previous_filename: None, + }; + + assert_eq!(file.old_path().unwrap(), None); + assert_eq!(file.new_path(), Some("copy.rs")); + } } diff --git a/src/command/diff/pr_provider/mod.rs b/src/command/diff/pr_provider/mod.rs index 16565990..f4bbb7f1 100644 --- a/src/command/diff/pr_provider/mod.rs +++ b/src/command/diff/pr_provider/mod.rs @@ -2,18 +2,18 @@ mod azure; mod github; +mod viewed; use std::borrow::Cow; use std::collections::HashSet; use std::fmt; -use std::thread; use super::types::FileDiff; -use super::PrInfo; use crate::vcs::VcsBackend; use azure::{AzurePrReference, AzureRepository}; use github::{GitHubPrReference, GitHubRepository}; +pub(crate) use viewed::ViewedFileSync; #[derive(Debug, thiserror::Error)] pub enum PrError { @@ -39,11 +39,83 @@ impl From<&str> for PrError { } } -/// Provider-specific coordinates for a resolved pull request. -#[derive(Clone, Debug)] -pub enum PrProvider { - GitHub { node_id: String }, - Azure { org_url: String, project: String }, +#[derive(Clone)] +pub enum PrInfo { + GitHub(github::GitHubPr), + Azure(azure::AzurePr), +} + +impl PrInfo { + pub fn number(&self) -> u64 { + match self { + Self::GitHub(pr) => pr.number, + Self::Azure(pr) => pr.number, + } + } + + pub fn base_ref(&self) -> &str { + match self { + Self::GitHub(pr) => &pr.base_ref, + Self::Azure(pr) => &pr.base_ref, + } + } + + pub fn head_ref(&self) -> &str { + match self { + Self::GitHub(pr) => &pr.head_ref, + Self::Azure(pr) => &pr.head_ref, + } + } + + pub fn base_repo_owner(&self) -> &str { + match self { + Self::GitHub(pr) => &pr.base_repo_owner, + Self::Azure(pr) => &pr.org, + } + } + + pub fn head_repo_owner(&self) -> Option<&str> { + match self { + Self::GitHub(pr) => pr.head_repo_owner.as_deref(), + Self::Azure(pr) => Some(&pr.org), + } + } + + pub fn load_file_diffs(&self) -> Result, PrError> { + match self { + Self::GitHub(pr) => github::load_pr_file_diffs(pr), + Self::Azure(pr) => azure::load_pr_file_diffs(pr), + } + } + + fn viewed_file_provider(&self) -> Option { + match self { + Self::GitHub(pr) => Some(ViewedFileProvider { pr: pr.clone() }), + Self::Azure(_) => None, + } + } + + pub fn file_web_url(&self, filename: &str) -> String { + match self { + Self::GitHub(pr) => github::file_web_url(pr, filename), + Self::Azure(pr) => azure::file_web_url(pr, filename), + } + } +} + +#[derive(Clone)] +struct ViewedFileProvider { + pr: github::GitHubPr, +} + +impl ViewedFileProvider { + fn fetch(&self) -> Result, PrError> { + github::fetch_viewed_files(&self.pr) + } + + fn set(&self, path: &str, viewed: bool) -> Result<(), PrError> { + github::set_file_viewed(&self.pr.node_id, path, viewed) + } } #[derive(Clone, Debug)] @@ -57,31 +129,29 @@ enum Repository { pub struct RepositoryContext { origin: Option, repository: Option, - current_branch: Option, + origin_error: Option, } impl RepositoryContext { pub fn resolve(backend: Option<&dyn VcsBackend>, repository_override: Option<&str>) -> Self { - let origin = repository_override - .map(str::to_owned) - .or_else(|| backend.and_then(|backend| backend.origin_url().ok().flatten())); + let (origin, origin_error) = match repository_override { + Some(origin) => (Some(origin.to_owned()), None), + None => match backend.map(VcsBackend::origin_url).transpose() { + Ok(origin) => (origin.flatten(), None), + Err(error) => (None, Some(error.to_string())), + }, + }; let repository = origin.as_deref().and_then(parse_repository); - let current_branch = - backend.and_then(|backend| backend.get_current_branch().ok().flatten()); Self { origin, repository, - current_branch, + origin_error, } } #[cfg(test)] - fn from_sources( - repository_override: Option<&str>, - backend_origin: Option<&str>, - current_branch: Option<&str>, - ) -> Self { + fn from_sources(repository_override: Option<&str>, backend_origin: Option<&str>) -> Self { let origin = repository_override .map(str::to_owned) .or_else(|| backend_origin.map(str::to_owned)); @@ -89,7 +159,7 @@ impl RepositoryContext { Self { origin, repository, - current_branch: current_branch.map(str::to_owned), + origin_error: None, } } } @@ -122,14 +192,7 @@ fn resolve_pr_reference(input: &str, context: &RepositoryContext) -> Result match &context.repository { Some(Repository::GitHub(repo)) => Ok(PrReference::GitHub(repo.with_number(number))), Some(Repository::Azure(repo)) => Ok(PrReference::Azure(repo.with_number(number))), - None => Err(PrError::InvalidRef(match &context.origin { - Some(origin) => { - format!("unsupported repository origin: {}", safe_diagnostic(origin)) - } - None => { - "could not determine repository; configure origin or pass --origin".to_string() - } - })), + None => Err(PrError::InvalidRef(repository_context_error(context))), }, Some(reference) => Ok(reference), None => Err(PrError::InvalidRef(format!( @@ -145,78 +208,47 @@ pub fn is_pr_reference(input: &str) -> bool { pub fn fetch_pr_info(input: &str, context: &RepositoryContext) -> Result { match resolve_pr_reference(input, context)? { - PrReference::GitHub(reference) => github::fetch_pr_info(&reference), - PrReference::Azure(reference) => azure::fetch_pr_info(&reference), + PrReference::GitHub(reference) => github::fetch_pr_info(&reference).map(PrInfo::GitHub), + PrReference::Azure(reference) => azure::fetch_pr_info(&reference).map(PrInfo::Azure), PrReference::Number(_) => { unreachable!("bare PR numbers are resolved using repository context") } } } -pub fn detect_current_branch_pr(context: &RepositoryContext) -> Result { - let branch = context.current_branch.as_deref().ok_or_else(|| { +pub fn detect_current_branch_pr( + context: &RepositoryContext, + backend: Option<&dyn VcsBackend>, +) -> Result { + let backend = backend.ok_or_else(|| { PrError::NotFound("could not determine the current branch or bookmark".to_string()) })?; + let branch = backend + .get_pr_source_branch() + .map_err(|error| PrError::Other(error.to_string()))? + .ok_or_else(|| { + PrError::NotFound("could not determine the current branch or bookmark".to_string()) + })?; match &context.repository { - Some(Repository::GitHub(repo)) => github::detect_current_branch_pr(repo, branch), - Some(Repository::Azure(repo)) => azure::detect_current_branch_pr(repo, branch), - None => Err(PrError::InvalidRef(match &context.origin { - Some(origin) => { - format!("unsupported repository origin: {}", safe_diagnostic(origin)) - } - None => "could not determine repository; configure origin or pass --origin".to_string(), - })), - } -} - -pub fn load_pr_file_diffs(pr: &PrInfo) -> Result, PrError> { - match &pr.provider { - PrProvider::GitHub { .. } => github::load_pr_file_diffs(pr), - PrProvider::Azure { org_url, project } => azure::load_pr_file_diffs(org_url, project, pr), - } -} - -/// `None` means the provider does not support per-file viewed state. -pub fn fetch_viewed_files(pr: &PrInfo) -> Result>, PrError> { - match &pr.provider { - PrProvider::GitHub { .. } => github::fetch_viewed_files(pr).map(Some), - PrProvider::Azure { .. } => Ok(None), - } -} - -pub fn supports_viewed_files(pr: &PrInfo) -> bool { - matches!(&pr.provider, PrProvider::GitHub { .. }) -} - -pub fn pr_file_web_url(pr: &PrInfo, filename: &str) -> Option { - match &pr.provider { - PrProvider::GitHub { .. } => Some(github::file_web_url(pr, filename)), - PrProvider::Azure { org_url, project } => { - Some(azure::file_web_url(org_url, project, pr, filename)) + Some(Repository::GitHub(repo)) => { + let reference = github::detect_current_branch_pr(repo, &branch)?; + github::fetch_pr_info(&reference).map(PrInfo::GitHub) + } + Some(Repository::Azure(repo)) => { + azure::detect_current_branch_pr(repo, &branch).map(PrInfo::Azure) } + None => Err(PrError::InvalidRef(repository_context_error(context))), } } -pub fn mark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { - set_file_viewed_async(pr, file_path, true); -} - -pub fn unmark_file_as_viewed_async(pr: &PrInfo, file_path: &str) { - set_file_viewed_async(pr, file_path, false); -} - -fn set_file_viewed_async(pr: &PrInfo, file_path: &str, viewed: bool) { - if !matches!(&pr.provider, PrProvider::GitHub { .. }) { - return; +fn repository_context_error(context: &RepositoryContext) -> String { + match (&context.origin, &context.origin_error) { + (Some(origin), _) => format!("unsupported repository origin: {}", safe_diagnostic(origin)), + (None, Some(error)) => format!("could not read repository origin: {error}"), + (None, None) => { + "could not determine repository; configure origin or pass --origin".to_string() + } } - let pr = pr.clone(); - let path = file_path.to_string(); - thread::spawn(move || { - let PrProvider::GitHub { node_id } = &pr.provider else { - return; - }; - let _ = github::set_file_viewed(node_id, &path, viewed); - }); } #[derive(Clone, Copy)] @@ -280,6 +312,23 @@ pub(super) fn decoded_path_segments(path: &str) -> Option> { path.split('/').map(decode_path_segment).collect() } +pub(super) fn percent_encode(segment: &str) -> String { + use std::fmt::Write; + + let mut encoded = String::with_capacity(segment.len()); + for byte in segment.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char) + } + _ => { + let _ = write!(encoded, "%{byte:02X}"); + } + } + } + encoded +} + fn decode_path_segment(segment: &str) -> Option { if segment.is_empty() { return None; @@ -333,11 +382,9 @@ mod tests { let context = RepositoryContext::from_sources( Some("https://dev.azure.com/org/project/_git/repo"), Some("git@github.com:owner/repo.git"), - Some("feature"), ); assert!(matches!(context.repository, Some(Repository::Azure(_)))); - assert_eq!(context.current_branch.as_deref(), Some("feature")); } #[test] @@ -345,7 +392,6 @@ mod tests { let context = RepositoryContext::from_sources( None, Some("https://user:TOKEN@github.com/owner/repo.git"), - Some("feature"), ); assert!(matches!(context.repository, Some(Repository::GitHub(_)))); @@ -356,7 +402,6 @@ mod tests { let context = RepositoryContext::from_sources( None, Some("https://user:SECRET@example.com/owner/repo.git"), - Some("feature"), ); let error = resolve_pr_reference("12", &context).expect_err("unsupported origin"); @@ -369,7 +414,7 @@ mod tests { #[test] fn invalid_reference_error_redacts_https_credentials() { - let context = RepositoryContext::from_sources(None, None, None); + let context = RepositoryContext::from_sources(None, None); let error = resolve_pr_reference( "https://user:SECRET@example.com/owner/repo/pull/1", diff --git a/src/command/diff/pr_provider/viewed.rs b/src/command/diff/pr_provider/viewed.rs new file mode 100644 index 00000000..f46c47b0 --- /dev/null +++ b/src/command/diff/pr_provider/viewed.rs @@ -0,0 +1,162 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::{self, JoinHandle}; + +use super::{PrError, PrInfo, ViewedFileProvider}; + +struct ViewedUpdate { + sequence: u64, + path: String, + viewed: bool, +} + +fn coalesce_updates( + first: ViewedUpdate, + queued: impl Iterator, +) -> Vec { + let mut batch = vec![first]; + for queued in queued { + if let Some(update) = batch.iter_mut().find(|update| update.path == queued.path) { + *update = queued; + } else { + batch.push(queued); + } + } + batch +} + +pub(crate) struct ViewedCompletion { + pub path: String, + pub viewed: bool, + pub result: Result<(), PrError>, +} + +pub(crate) struct ViewedFileSync { + provider: ViewedFileProvider, + updates: Option>, + completions: Receiver<(u64, ViewedCompletion)>, + pending: HashMap, + next_sequence: u64, + local_completions: VecDeque, + worker: Option>, +} + +impl ViewedFileSync { + pub fn new(pr: &PrInfo) -> Option { + let provider = pr.viewed_file_provider()?; + let worker_provider = provider.clone(); + let (update_tx, update_rx) = mpsc::channel::(); + let (completion_tx, completion_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + while let Ok(first) = update_rx.recv() { + let batch = coalesce_updates(first, update_rx.try_iter()); + for update in batch { + let result = worker_provider.set(&update.path, update.viewed); + let completion = ViewedCompletion { + path: update.path, + viewed: update.viewed, + result, + }; + let _ = completion_tx.send((update.sequence, completion)); + } + } + }); + Some(Self { + provider, + updates: Some(update_tx), + completions: completion_rx, + pending: HashMap::new(), + next_sequence: 0, + local_completions: VecDeque::new(), + worker: Some(worker), + }) + } + + pub fn set(&mut self, path: &str, viewed: bool) { + let path = path.to_string(); + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.wrapping_add(1); + self.pending.insert(path.clone(), (sequence, viewed)); + let send_failed = self.updates.as_ref().is_none_or(|updates| { + updates + .send(ViewedUpdate { + sequence, + path: path.clone(), + viewed, + }) + .is_err() + }); + if send_failed { + self.pending.remove(&path); + self.local_completions.push_back(ViewedCompletion { + path, + viewed, + result: Err(PrError::Other( + "viewed status synchronization worker stopped".to_string(), + )), + }); + } + } + + pub fn viewed_paths(&self) -> Result, PrError> { + let mut paths = self.provider.fetch()?; + for (path, (_, viewed)) in &self.pending { + if *viewed { + paths.insert(path.clone()); + } else { + paths.remove(path); + } + } + Ok(paths) + } + + pub fn drain(&mut self) -> Vec { + let mut current = self.local_completions.drain(..).collect::>(); + while let Ok((sequence, completion)) = self.completions.try_recv() { + if self.pending.get(&completion.path) == Some(&(sequence, completion.viewed)) { + self.pending.remove(&completion.path); + current.push(completion); + } + } + current + } +} + +impl Drop for ViewedFileSync { + fn drop(&mut self) { + self.updates.take(); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn update(sequence: u64, path: &str, viewed: bool) -> ViewedUpdate { + ViewedUpdate { + sequence, + path: path.to_string(), + viewed, + } + } + + #[test] + fn coalesces_queued_updates_to_latest_value_per_path() { + let batch = coalesce_updates( + update(1, "a.rs", true), + [ + update(2, "b.rs", true), + update(3, "a.rs", false), + update(4, "a.rs", true), + ] + .into_iter(), + ); + + assert_eq!(batch.len(), 2); + assert_eq!((batch[0].sequence, batch[0].viewed), (4, true)); + assert_eq!((batch[1].sequence, batch[1].path.as_str()), (2, "b.rs")); + } +} diff --git a/src/command/diff/render/footer.rs b/src/command/diff/render/footer.rs index 47610cbb..af3e58ce 100644 --- a/src/command/diff/render/footer.rs +++ b/src/command/diff/render/footer.rs @@ -144,21 +144,21 @@ pub fn render_footer(frame: &mut Frame, footer_area: Rect, data: FooterData) { let left_spans = if let Some(pr) = data.pr_info { // PR mode: show "base <- head #123" or "owner:base <- owner:head #123" for forks - let is_fork = pr.head_repo_owner.as_ref() != Some(&pr.base_repo_owner); + let is_fork = pr.head_repo_owner() != Some(pr.base_repo_owner()); let base_label = if is_fork { - format!(" {}:{} ", pr.base_repo_owner, pr.base_ref) + format!(" {}:{} ", pr.base_repo_owner(), pr.base_ref()) } else { - format!(" {} ", pr.base_ref) + format!(" {} ", pr.base_ref()) }; let head_label = if is_fork { - match &pr.head_repo_owner { - Some(owner) => format!(" {}:{} ", owner, pr.head_ref), - None => format!(" {} ", pr.head_ref), // Fork was deleted + match pr.head_repo_owner() { + Some(owner) => format!(" {}:{} ", owner, pr.head_ref()), + None => format!(" {} ", pr.head_ref()), // Fork was deleted } } else { - format!(" {} ", pr.head_ref) + format!(" {} ", pr.head_ref()) }; let mut spans = vec![ diff --git a/src/vcs/backend.rs b/src/vcs/backend.rs index d27fd7e8..6fa7aa84 100644 --- a/src/vcs/backend.rs +++ b/src/vcs/backend.rs @@ -83,6 +83,13 @@ pub trait VcsBackend { /// Get current branch name (or bookmark for jj). fn get_current_branch(&self) -> Result, VcsError>; + /// Resolve the source branch used to detect a pull request. + /// Backends may infer a nearby branch when the working copy is detached + /// from the branch ref itself. + fn get_pr_source_branch(&self) -> Result, VcsError> { + self.get_current_branch() + } + /// Get the canonical URL of the `origin` remote, when the backend exposes /// remotes. Backends without a remote concept return `None`. fn origin_url(&self) -> Result, VcsError>; diff --git a/src/vcs/jj.rs b/src/vcs/jj.rs index 7b04d0a0..4b68db93 100644 --- a/src/vcs/jj.rs +++ b/src/vcs/jj.rs @@ -715,6 +715,21 @@ impl VcsBackend for JjBackend { } fn get_current_branch(&self) -> Result, VcsError> { + let wc_commit = self.resolve_single_commit("@")?; + let wc_commit_id = wc_commit.id(); + Ok(self + .repo + .view() + .local_bookmarks() + .find_map(|(name, target)| { + target + .as_normal() + .filter(|commit_id| *commit_id == wc_commit_id) + .map(|_| name.as_str().to_string()) + })) + } + + fn get_pr_source_branch(&self) -> Result, VcsError> { let wc_commit = self.resolve_single_commit("@")?; let wc_commit_id = wc_commit.id(); let local_bookmarks: Vec<_> = self @@ -1406,7 +1421,7 @@ mod tests { } #[test] - fn test_get_current_branch_finds_nearest_unique_ancestor_bookmark() { + fn test_get_pr_source_branch_finds_nearest_unique_ancestor_bookmark() { let Some(repo) = JjRepoGuard::new() else { eprintln!("Skipping test: jj not available"); return; @@ -1425,13 +1440,13 @@ mod tests { let backend = JjBackend::new(&repo.dir).expect("should load backend"); assert_eq!( - backend.get_current_branch().expect("should get bookmark"), + backend.get_pr_source_branch().expect("should get bookmark"), Some("feature".to_string()) ); } #[test] - fn test_get_current_branch_rejects_ambiguous_exact_bookmarks() { + fn test_get_pr_source_branch_rejects_ambiguous_exact_bookmarks() { let Some(repo) = JjRepoGuard::new() else { eprintln!("Skipping test: jj not available"); return; @@ -1448,7 +1463,7 @@ mod tests { let backend = JjBackend::new(&repo.dir).expect("should load backend"); let error = backend - .get_current_branch() + .get_pr_source_branch() .expect_err("ambiguous bookmarks should fail"); let VcsError::Other(message) = error else { panic!("expected VcsError::Other, got {error:?}"); @@ -1458,7 +1473,7 @@ mod tests { } #[test] - fn test_get_current_branch_rejects_ambiguous_nearest_bookmarks() { + fn test_get_pr_source_branch_rejects_ambiguous_nearest_bookmarks() { let Some(repo) = JjRepoGuard::new() else { eprintln!("Skipping test: jj not available"); return; @@ -1476,7 +1491,7 @@ mod tests { let backend = JjBackend::new(&repo.dir).expect("should load backend"); let error = backend - .get_current_branch() + .get_pr_source_branch() .expect_err("ambiguous bookmarks should fail"); let VcsError::Other(message) = error else { panic!("expected VcsError::Other, got {error:?}");