diffler 0.11.1

Terminal code review for AI coding agents
Documentation
//! The forge seam: one async trait per forge adapter covering CI acquisition
//! and pull-request review. The host holds a `Box<dyn ForgeProvider + Send>`
//! chosen at runtime by detection.

use async_trait::async_trait;

use crate::ci::error::{CiError, Result};
use crate::ci::model::{
    Capabilities, CiRun, DagSource, JobId, LogChunk, LogMode, PrComment, PullRequest, RunDetail,
    RunExtras, RunId,
};

/// Which forge an adapter talks to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderKind {
    GitHub,
    GitLab,
    Forgejo,
}

/// The capabilities of a provider kind: a pure function of `kind()`, so it's
/// exposed standalone: UI code can gate an affordance from just the detected
/// kind (a [`CiRemote`](crate::app::CiRemote)'s `detected.kind`) without
/// constructing a full provider first.
pub fn capabilities_for(kind: ProviderKind) -> Capabilities {
    match kind {
        ProviderKind::GitHub => Capabilities {
            dag: DagSource::ConfigFile,
            logs: LogMode::Dump,
            resolve_threads: true,
        },
        ProviderKind::GitLab => Capabilities {
            dag: DagSource::RunApi,
            logs: LogMode::Poll,
            resolve_threads: true,
        },
        ProviderKind::Forgejo => Capabilities {
            dag: DagSource::None,
            logs: LogMode::None,
            resolve_threads: false,
        },
    }
}

// `Sync` (not just `Send`) is required so the async-trait macro can generate a
// boxed future for the PR-review methods' default bodies below without
// knowing the concrete `Self`: every adapter is already `Sync` (their fields
// are plain owned data plus a `Box<dyn CommandRunner>`, itself `Send + Sync`).
#[async_trait]
pub trait ForgeProvider: Send + Sync {
    fn kind(&self) -> ProviderKind;

    /// What this provider can do, so the UI degrades honestly. A pure
    /// function of `kind()` for every adapter, so the default covers them all.
    fn capabilities(&self) -> Capabilities {
        capabilities_for(self.kind())
    }

    /// The most recent runs, newest first, capped at `limit`.
    async fn list_runs(&self, limit: usize) -> Result<Vec<CiRun>>;

    /// A run's jobs and dependency edges.
    async fn run_detail(&self, run: &RunId) -> Result<RunDetail>;

    /// A slice of a job's log starting at `offset`. For `LogMode::Dump`
    /// providers the whole log returns once the job completes.
    async fn job_log(&self, run: &RunId, job: &JobId, offset: u64) -> Result<LogChunk>;

    /// A run's artifacts and annotations, for the graph page's extras panel.
    /// A provider that exposes neither returns the empty default.
    async fn run_extras(&self, run: &RunId) -> Result<RunExtras>;

    /// The pull/merge request for the checked-out branch, if one is open.
    /// `None` when there's no PR or the provider can't resolve one.
    async fn current_pr(&self) -> Result<Option<PullRequest>>;

    /// The repo's open pull/merge requests, newest first.
    async fn list_prs(&self) -> Result<Vec<PullRequest>>;

    /// Line-anchored review comments on a PR; empty when the provider has no
    /// review API. Default: no review API, so no comments.
    async fn pr_comments(&self, _number: u64) -> Result<Vec<PrComment>> {
        Ok(Vec::new())
    }

    /// Post a new line comment on the PR head, returning the forge's record.
    /// Default: unsupported.
    async fn post_pr_comment(&self, _new: &NewPrComment) -> Result<PrComment> {
        Err(CiError::Unsupported("posting PR comments"))
    }

    /// Reply to an existing PR review comment thread. Default: unsupported.
    async fn reply_pr_comment(
        &self,
        _number: u64,
        _remote_id: &str,
        _body: &str,
    ) -> Result<PrComment> {
        Err(CiError::Unsupported("replying to PR comments"))
    }

    /// Submit a batch of line comments as one review, so the forge sends a
    /// single notification instead of one per comment. Default: unsupported.
    async fn submit_pr_review(&self, _review: &NewPrReview) -> Result<()> {
        Err(CiError::Unsupported("submitting PR reviews"))
    }

    /// Resolve or unresolve a review thread. `thread_id` is the forge's
    /// thread handle carried on the root comment from `pr_comments`.
    /// Default: unsupported.
    async fn resolve_pr_thread(
        &self,
        _number: u64,
        _thread_id: &str,
        _resolved: bool,
    ) -> Result<()> {
        Err(CiError::Unsupported("resolving PR threads"))
    }

    /// Rewrite the body of one of our own review comments. `number` is carried
    /// because a forge can scope the edit route to the PR. Default: unsupported.
    async fn update_pr_comment(&self, _number: u64, _remote_id: &str, _body: &str) -> Result<()> {
        Err(CiError::Unsupported("editing PR comments"))
    }

    /// Delete one of our own review comments. `number` is carried because a
    /// forge can scope the delete route to the PR. Default: unsupported.
    async fn delete_pr_comment(&self, _number: u64, _remote_id: &str) -> Result<()> {
        Err(CiError::Unsupported("deleting PR comments"))
    }

    /// The PR as the forge sees it right now, for spotting a force-push
    /// while the review is open. Default: unsupported.
    async fn pr(&self, _number: u64) -> Result<PullRequest> {
        Err(CiError::Unsupported("PR lookup"))
    }

    /// Open a pull request. Default: unsupported.
    async fn create_pr(&self, _new: &NewPullRequest) -> Result<PullRequest> {
        Err(CiError::Unsupported("opening a pull request"))
    }
}

/// A pull request to open: `head` merges into `base` on the forge the provider
/// speaks to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewPullRequest {
    pub base: String,
    pub head: String,
    pub title: String,
    pub body: String,
    pub draft: bool,
}

/// The event a submitted review carries on the forge.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewVerdict {
    Approve,
    RequestChanges,
    Comment,
}

/// A whole review to submit at once: the verdict, an optional top-level
/// body, and every pending line comment.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewPrReview {
    pub number: u64,
    pub head_oid: String,
    pub verdict: ReviewVerdict,
    /// Shown above the line comments on the forge; empty means none.
    pub body: String,
    pub comments: Vec<NewPrComment>,
}

/// A comment to post, anchored to a diff line of `path` at `head_oid`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewPrComment {
    pub number: u64,
    pub head_oid: String,
    pub path: String,
    /// 1-based line on the side the comment anchors to; for a multi-line
    /// comment this is the range's last line.
    pub line: u32,
    /// First line of a multi-line comment; `None` anchors a single line.
    pub start_line: Option<u32>,
    /// Anchored to the new side (`RIGHT`) or the old side of the diff.
    pub new_side: bool,
    /// The same row's number on the other side, set only for a line both sides
    /// share. GitLab rejects an unchanged line named from one side alone.
    pub counterpart: Option<u32>,
    pub body: String,
}