travelagent 1.11.1

Agent-first TUI code review tool
use travelagent_core::error::{Result, TrvError};
use travelagent_core::forge::{MergeMethod, NewComment, NewReview, ReviewVerdict};
use travelagent_core::model::LineSide;

use super::App;

impl App {
    /// Check if we're in remote mode with a forge backend.
    pub fn has_forge(&self) -> bool {
        self.remote().is_some_and(|r| r.forge.is_some())
    }

    /// Guard for remote-only actions that need to POST to a forge.
    ///
    /// Returns `true` when a forge backend is attached. When no forge is
    /// attached (e.g., `--demo` mode) this sets a warning message describing
    /// the no-op and returns `false`, so callers can short-circuit without
    /// surfacing an error to the user.
    pub fn forge_required(&mut self, action: &str) -> bool {
        if self.remote().is_some_and(|r| r.forge.is_some()) {
            true
        } else {
            self.set_warning(format!("Demo mode: would {action}"));
            false
        }
    }

    /// Get the forge type if in remote mode.
    pub fn forge_type(&self) -> Option<travelagent_core::forge::ForgeType> {
        self.remote()
            .and_then(|r| r.forge.as_ref().map(|f| f.forge_type()))
    }

    /// Whether the current forge supports "Request Changes" verdict.
    /// GitLab has no equivalent — it only supports Comment and Approve.
    pub fn supports_request_changes(&self) -> bool {
        self.forge_type() != Some(travelagent_core::forge::ForgeType::GitLab)
    }

    /// Get the browser URL for the current PR.
    pub fn get_browser_url(&self) -> Option<String> {
        let r = self.remote()?;
        let pr_id = &r.pr_id;
        let forge_type = self.forge_type()?;

        match forge_type {
            travelagent_core::forge::ForgeType::GitHub => {
                let host = r.forge_host.as_deref().unwrap_or("github.com");
                Some(format!(
                    "https://{}/{}/{}/pull/{}",
                    host, pr_id.owner, pr_id.repo, pr_id.number
                ))
            }
            travelagent_core::forge::ForgeType::GitLab => {
                let host = r.forge_host.as_deref().unwrap_or("gitlab.com");
                Some(format!(
                    "https://{}/{}/{}/-/merge_requests/{}",
                    host, pr_id.owner, pr_id.repo, pr_id.number
                ))
            }
        }
    }

    /// Post an inline comment to the remote forge.
    /// Returns Ok(Some(remote_id)) if posted, Ok(None) if not in remote mode.
    pub fn post_remote_comment(
        &mut self,
        path: &str,
        line: u32,
        side: LineSide,
        body: &str,
    ) -> Result<Option<u64>> {
        let r = match self.remote() {
            Some(r) => r,
            None => return Ok(None),
        };
        let forge = match r.forge.as_ref() {
            Some(f) => f,
            None => return Ok(None),
        };
        let pr_id = &r.pr_id;
        let rt = &self.runtime_handle;
        let commit_id = r.pr_commits.last().map(|c| c.id.clone());
        let start_line = self
            .comment
            .line_range
            .as_ref()
            .map(|(range, _)| range.start);
        let comment = NewComment {
            path: path.to_string(),
            line,
            side,
            body: body.to_string(),
            start_line,
            commit_id,
        };
        // TODO(async): This blocks the event loop. Move to background task in Phase 9.
        let remote_comment = rt.block_on(forge.post_comment(pr_id, comment))?;
        let remote_id = remote_comment.id;
        self.remote_mut()
            .expect("post_remote_comment verified remote mode")
            .remote_comments
            .push(remote_comment);
        Ok(Some(remote_id))
    }

    /// Submit a review to the remote forge.
    ///
    /// If the backend reports a partial submit (e.g. GitLab posting some but
    /// not all inline comments before a network error), this logs the posted
    /// discussions at warn level so the partial state is recoverable from
    /// logs even though the UI only renders the error summary.
    pub fn submit_remote_review(&mut self, verdict: ReviewVerdict, body: &str) -> Result<bool> {
        let r = match self.remote() {
            Some(r) => r,
            None => return Ok(false),
        };
        let forge = match r.forge.as_ref() {
            Some(f) => f,
            None => return Ok(false),
        };
        let pr_id = &r.pr_id;
        let rt = &self.runtime_handle;
        let review = NewReview {
            verdict,
            body: body.to_string(),
            comments: vec![],
        };
        // TODO(async): This blocks the event loop. Move to background task in Phase 9.
        match rt.block_on(forge.submit_review(pr_id, review)) {
            Ok(()) => Ok(true),
            Err(TrvError::PartialReviewSubmit {
                posted,
                remaining,
                approval_posted,
                cause,
            }) => {
                // Surface partial state on stderr so operators can reconcile
                // even though the UI only shows the summary message.
                eprintln!(
                    "submit_review partial failure: {posted_count} of {total} comments posted{approval}; cause: {cause}",
                    posted_count = posted.len(),
                    total = posted.len() + remaining,
                    approval = if approval_posted {
                        " (approval posted)"
                    } else {
                        ""
                    },
                );
                for p in &posted {
                    eprintln!(
                        "  posted[{}] note_id={} discussion_id={}",
                        p.index, p.note_id, p.discussion_id,
                    );
                }
                Err(TrvError::PartialReviewSubmit {
                    posted,
                    remaining,
                    approval_posted,
                    cause,
                })
            }
            Err(e) => Err(e),
        }
    }

    /// Merge the PR on the remote forge with a caller-specified method.
    #[allow(dead_code)]
    pub fn merge_remote(&mut self, method: MergeMethod) -> Result<bool> {
        let r = match self.remote() {
            Some(r) => r,
            None => return Ok(false),
        };
        let forge = match r.forge.as_ref() {
            Some(f) => f,
            None => return Ok(false),
        };
        let pr_id = &r.pr_id;
        let rt = &self.runtime_handle;
        // TODO(async): This blocks the event loop. Move to background task in Phase 9.
        rt.block_on(forge.merge(pr_id, method))?;
        Ok(true)
    }

    /// Pick the first merge method allowed by the repo from the given preference order.
    /// Returns an error if `allowed` is empty (repo forbids all merge methods).
    pub(crate) fn pick_merge_method(allowed: &[MergeMethod]) -> Result<MergeMethod> {
        // Preference: Squash → Merge → Rebase (respects common squash-only convention).
        for preferred in [MergeMethod::Squash, MergeMethod::Merge, MergeMethod::Rebase] {
            if allowed.contains(&preferred) {
                return Ok(preferred);
            }
        }
        Err(TrvError::UnsupportedOperation(
            "Repository does not allow any merge method".into(),
        ))
    }

    /// Merge the PR, auto-selecting a method allowed by the repo's permissions.
    /// Returns `Ok(None)` if not in remote mode. Returns the chosen method on success.
    pub fn merge_remote_auto(&mut self) -> Result<Option<MergeMethod>> {
        let r = match self.remote() {
            Some(r) => r,
            None => return Ok(None),
        };
        let forge = match r.forge.as_ref() {
            Some(f) => f,
            None => return Ok(None),
        };
        let pr_id = &r.pr_id;
        let rt = &self.runtime_handle;
        // TODO(async): These block the event loop. Move to background task in Phase 9.
        let permissions = rt.block_on(forge.check_permissions(pr_id))?;
        let method = Self::pick_merge_method(&permissions.allowed_merge_methods)?;
        rt.block_on(forge.merge(pr_id, method))?;
        Ok(Some(method))
    }

    /// Refresh the remote PR data: metadata, files, commits, comments, and
    /// review threads. Replaces the stored fields on success. Invalidates the
    /// local PR cache so subsequent runs pick up fresh data.
    ///
    /// Returns `Ok(())` regardless of whether the app is in remote mode; a
    /// non-remote call is a no-op but still returns success so callers can
    /// unconditionally invoke it from a command handler.
    pub fn refresh_remote(&mut self) -> anyhow::Result<()> {
        let r = match self.remote() {
            Some(r) => r,
            None => return Ok(()),
        };
        let forge = match r.forge.as_ref() {
            Some(f) => f,
            None => return Ok(()),
        };
        let pr_id = r.pr_id.clone();
        let rt = &self.runtime_handle;

        // Fetch fresh data in a single async block so one runtime.block_on
        // covers all calls. Review threads are fetched separately because
        // some backends (e.g., self-hosted forges with older API versions)
        // may not support them; in that case we keep the existing threads
        // rather than silently dropping them.
        let (metadata, files, commits, comments, review_threads_result) = rt.block_on(async {
            let metadata = forge.get_pr(&pr_id).await?;
            let files = forge.get_pr_files(&pr_id).await?;
            let commits = forge.get_pr_commits(&pr_id).await?;
            let comments = forge.get_comments(&pr_id).await?;
            let review_threads_result = forge.get_review_threads(&pr_id).await;
            Ok::<_, travelagent_core::error::TrvError>((
                metadata,
                files,
                commits,
                comments,
                review_threads_result,
            ))
        })?;
        let forge_type_for_host = forge.forge_type();
        let r_mut = self
            .remote_mut()
            .expect("refresh_remote verified remote mode");
        let (review_threads, review_threads_warning) = match review_threads_result {
            Ok(threads) => (threads, None),
            Err(e) => {
                let warning = format!("Kept previous review threads (refresh failed: {e})");
                (std::mem::take(&mut r_mut.review_threads), Some(warning))
            }
        };

        // Invalidate the on-disk PR cache so the next `trv <pr>` run re-fetches
        // instead of using a possibly stale cached copy.
        let host = r_mut
            .forge_host
            .clone()
            .unwrap_or_else(|| match forge_type_for_host {
                travelagent_core::forge::ForgeType::GitHub => "github.com".to_string(),
                travelagent_core::forge::ForgeType::GitLab => "gitlab.com".to_string(),
            });
        let cache_path = std::env::temp_dir()
            .join("travelagent")
            .join(&host)
            .join(&pr_id.owner)
            .join(&pr_id.repo)
            .join(format!("pr-{}.json", pr_id.number));
        let _ = std::fs::remove_file(&cache_path);

        // Replace stored fields.
        r_mut.pr_metadata = Some(metadata);
        r_mut.pr_commits = commits;
        r_mut.remote_comments = comments;
        r_mut.review_threads = review_threads;
        r_mut.last_refreshed_at = Some(chrono::Utc::now());
        self.diff_files = files;

        // Re-register files in the session so annotations match the new diff.
        self.engine.apply_diff_files(&self.diff_files);
        self.clear_expanded_gaps();
        self.sort_files_by_directory(false);
        self.rebuild_annotations();
        // Comments may have been removed upstream; keep the conversation
        // cursor inside the new thread list.
        crate::ui::conversation_panel::clamp_conversation_cursor(self);
        if let Some(msg) = review_threads_warning {
            self.set_warning(msg);
        }
        // Phase I3 invariant: if blind mode is on, hide the
        // configured paths again now that `diff_files` was
        // repopulated from the forge. See `reload_diff_files` for
        // the mirror comment on the local path.
        self.apply_blind_filter();
        Ok(())
    }

    /// Human-facing host label for the current forge (for status messages).
    pub fn forge_host_label(&self) -> String {
        if let Some(r) = self.remote()
            && let Some(ref host) = r.forge_host
        {
            return host.clone();
        }
        match self.forge_type() {
            Some(travelagent_core::forge::ForgeType::GitHub) => "github.com".to_string(),
            Some(travelagent_core::forge::ForgeType::GitLab) => "gitlab.com".to_string(),
            None => "remote".to_string(),
        }
    }

    /// Resolve or unresolve a review thread.
    #[allow(dead_code)]
    pub fn toggle_thread_resolve(&mut self, thread_id: &str, resolve: bool) -> Result<bool> {
        let r = match self.remote() {
            Some(r) => r,
            None => return Ok(false),
        };
        let forge = match r.forge.as_ref() {
            Some(f) => f,
            None => return Ok(false),
        };
        let rt = &self.runtime_handle;
        if resolve {
            rt.block_on(forge.resolve_thread(thread_id))?;
        } else {
            rt.block_on(forge.unresolve_thread(thread_id))?;
        }
        Ok(true)
    }
}