Skip to main content

travelagent_core/
forge.rs

1//! Forge backend trait for remote platform integration (GitHub, GitLab, etc.)
2
3use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8use crate::model::{DiffFile, LineSide};
9use crate::vcs::CommitInfo;
10
11/// Identifies a PR/MR on a remote forge
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct PrId {
14    pub owner: String,
15    pub repo: String,
16    pub number: u64,
17}
18
19/// PR/MR metadata from a remote forge
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct PrMetadata {
22    pub title: String,
23    pub body: String,
24    pub author: String,
25    pub state: PrState,
26    pub base_branch: String,
27    pub head_branch: String,
28    pub head_sha: String,
29    pub created_at: DateTime<Utc>,
30    pub mergeable: Option<MergeableStatus>,
31    pub is_draft: bool,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub enum PrState {
36    Open,
37    Closed,
38    Merged,
39}
40
41impl std::fmt::Display for PrState {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            PrState::Open => write!(f, "open"),
45            PrState::Closed => write!(f, "closed"),
46            PrState::Merged => write!(f, "merged"),
47        }
48    }
49}
50
51impl PrState {
52    #[must_use]
53    pub fn display(&self) -> &'static str {
54        match self {
55            PrState::Open => "open",
56            PrState::Closed => "closed",
57            PrState::Merged => "merged",
58        }
59    }
60}
61
62/// Lightweight summary of a PR/MR as returned by the forge's list endpoint.
63///
64/// This carries just the fields needed by the PR list browser UI so the row
65/// can be rendered without an extra per-PR round-trip.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct PrListItem {
68    pub number: u64,
69    pub title: String,
70    pub author: String,
71    pub state: PrState,
72    pub is_draft: bool,
73    pub base_branch: String,
74    pub head_branch: String,
75    pub updated_at: DateTime<Utc>,
76    pub comment_count: u32,
77    pub has_review_requested_from_me: bool,
78    /// Logins (GitHub) / usernames (GitLab) of users assigned to the PR/MR.
79    /// `#[serde(default)]` so old cached payloads and forge fixtures that
80    /// don't include the field still deserialize. An empty list combined with
81    /// an assignee filter yields zero matches — the intended "no match"
82    /// behavior for the client-side filter.
83    #[serde(default)]
84    pub assignees: Vec<String>,
85    /// Logins / usernames of users whose review is requested.
86    #[serde(default)]
87    pub reviewers: Vec<String>,
88}
89
90/// Filter parameters for `ForgeBackend::list_prs`.
91#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
92pub struct PrListFilter {
93    pub state: Option<PrState>,
94    pub author: Option<String>,
95    pub assignee: Option<String>,
96    pub review_requested: Option<bool>,
97    pub max: Option<u32>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
101pub enum MergeableStatus {
102    Clean,
103    Unstable,
104    Behind,
105    Blocked,
106    Dirty,
107    Draft,
108    Unknown,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
112pub enum MergeMethod {
113    Merge,
114    Squash,
115    Rebase,
116}
117
118impl std::fmt::Display for MergeMethod {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        match self {
121            MergeMethod::Merge => write!(f, "merge"),
122            MergeMethod::Squash => write!(f, "squash"),
123            MergeMethod::Rebase => write!(f, "rebase"),
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129pub enum ReviewVerdict {
130    Approve,
131    RequestChanges,
132    Comment,
133}
134
135impl std::fmt::Display for ReviewVerdict {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        match self {
138            ReviewVerdict::Approve => write!(f, "approve"),
139            ReviewVerdict::RequestChanges => write!(f, "request_changes"),
140            ReviewVerdict::Comment => write!(f, "comment"),
141        }
142    }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct RemoteComment {
147    pub id: u64,
148    pub author: String,
149    pub body: String,
150    pub path: Option<String>,
151    pub line: Option<u32>,
152    pub side: Option<LineSide>,
153    pub created_at: DateTime<Utc>,
154    pub in_reply_to: Option<u64>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct ReviewThread {
159    pub id: String,
160    pub is_resolved: bool,
161    pub root_comment_id: u64,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct NewComment {
166    pub path: String,
167    pub line: u32,
168    pub side: LineSide,
169    pub body: String,
170    pub start_line: Option<u32>,
171    pub commit_id: Option<String>,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct NewReview {
176    pub verdict: ReviewVerdict,
177    pub body: String,
178    pub comments: Vec<NewComment>,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
182pub enum ReactionContent {
183    ThumbsUp,
184    ThumbsDown,
185    Laugh,
186    Hooray,
187    Confused,
188    Heart,
189    Rocket,
190    Eyes,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum ReactionTarget {
195    IssueComment(u64),
196    ReviewComment(u64),
197    Review(String),
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct User {
202    pub login: String,
203    pub id: u64,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct Permissions {
208    pub can_push: bool,
209    pub can_merge: bool,
210    pub allowed_merge_methods: Vec<MergeMethod>,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
214#[must_use]
215pub enum ForgeType {
216    GitHub,
217    GitLab,
218}
219
220impl std::fmt::Display for ForgeType {
221    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222        match self {
223            ForgeType::GitHub => write!(f, "GitHub"),
224            ForgeType::GitLab => write!(f, "GitLab"),
225        }
226    }
227}
228
229/// Callback invoked by a forge client when it needs to surface a non-fatal
230/// warning (e.g. pagination truncation). Consumers that run inside a TUI
231/// alternate-screen install one of these so the warning reaches the status
232/// bar / error-log ring instead of being lost to an invisible stderr.
233///
234/// Clients without a handler installed fall back to `eprintln!` so CLI-only
235/// paths (the MCP server, tests) still see the message.
236pub type ForgeWarnHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;
237
238// ---------------------------------------------------------------------------
239// Capability traits (Interface Segregation Principle — Phase H5)
240//
241// `ForgeBackend` used to be a single 24-method fat trait. Callers that only
242// needed e.g. `list_prs` were forced to depend on `merge`, `add_reaction`,
243// and the entire mutation surface. The trait is now split into five
244// role-shaped capability traits. Implementations (GitHubForge, GitLabForge,
245// test mocks) split into one `impl` block per capability. A supertrait alias
246// `ForgeBackend` preserves "full backend" ergonomics for existing callers.
247// ---------------------------------------------------------------------------
248
249/// Read-only access to PR/MR metadata, diffs, user info, and permissions.
250///
251/// Implementations live in separate crates (travelagent-forge-github,
252/// travelagent-forge-gitlab).
253#[async_trait]
254pub trait ForgeRead: Send + Sync {
255    fn forge_type(&self) -> ForgeType;
256
257    // PR/MR metadata
258    async fn get_pr(&self, id: &PrId) -> Result<PrMetadata>;
259    async fn get_pr_commits(&self, id: &PrId) -> Result<Vec<CommitInfo>>;
260    async fn get_pr_files(&self, id: &PrId) -> Result<Vec<DiffFile>>;
261    async fn get_commit_diff(&self, id: &PrId, commit_sha: &str) -> Result<Vec<DiffFile>>;
262
263    /// List open PRs/MRs on a repository matching the given filter.
264    ///
265    /// Returns lightweight `PrListItem` rows suitable for a browse UI. The
266    /// implementation may paginate internally; `filter.max` bounds the total
267    /// number of rows returned (defaults per-backend, capped at 100).
268    async fn list_prs(
269        &self,
270        owner: &str,
271        repo: &str,
272        filter: &PrListFilter,
273    ) -> Result<Vec<PrListItem>>;
274
275    // Auth & permissions
276    async fn current_user(&self) -> Result<User>;
277    async fn check_permissions(&self, id: &PrId) -> Result<Permissions>;
278}
279
280/// Comment and review-thread read/write operations.
281#[async_trait]
282pub trait ForgeComments: Send + Sync {
283    async fn get_comments(&self, id: &PrId) -> Result<Vec<RemoteComment>>;
284    async fn get_review_threads(&self, id: &PrId) -> Result<Vec<ReviewThread>>;
285    async fn post_comment(&self, id: &PrId, comment: NewComment) -> Result<RemoteComment>;
286    async fn post_reply(&self, id: &PrId, thread_id: &str, body: &str) -> Result<RemoteComment>;
287    async fn edit_comment(&self, id: &PrId, comment_id: u64, body: &str) -> Result<RemoteComment>;
288    async fn delete_comment(&self, id: &PrId, comment_id: u64) -> Result<()>;
289    async fn resolve_thread(&self, thread_id: &str) -> Result<()>;
290    async fn unresolve_thread(&self, thread_id: &str) -> Result<()>;
291}
292
293/// Review submission (approve / request-changes / comment).
294#[async_trait]
295pub trait ForgeReview: Send + Sync {
296    async fn submit_review(&self, id: &PrId, review: NewReview) -> Result<()>;
297}
298
299/// Merge, close, and reopen operations.
300#[async_trait]
301pub trait ForgeMerge: Send + Sync {
302    async fn merge(&self, id: &PrId, method: MergeMethod) -> Result<()>;
303    async fn close(&self, id: &PrId) -> Result<()>;
304    async fn reopen(&self, id: &PrId) -> Result<()>;
305}
306
307/// Emoji reactions on comments and reviews.
308#[async_trait]
309pub trait ForgeReactions: Send + Sync {
310    async fn add_reaction(&self, target: &ReactionTarget, content: ReactionContent) -> Result<()>;
311    async fn remove_reaction(
312        &self,
313        target: &ReactionTarget,
314        content: ReactionContent,
315    ) -> Result<()>;
316}
317
318/// Full forge backend: supertrait alias covering every capability.
319///
320/// Callers that need the full surface (the TUI app, the `create_forge`
321/// factory) should continue using `Box<dyn ForgeBackend>`. Callers that only
322/// read data should prefer the narrower traits above.
323pub trait ForgeBackend:
324    ForgeRead + ForgeComments + ForgeReview + ForgeMerge + ForgeReactions
325{
326}
327
328impl<T: ForgeRead + ForgeComments + ForgeReview + ForgeMerge + ForgeReactions + ?Sized> ForgeBackend
329    for T
330{
331}
332
333/// Read + comments convenience: "look at a PR and its discussion, but
334/// nothing else." Used by consumers that want to browse and reply without
335/// touching merges or reactions.
336pub trait ForgeReadComments: ForgeRead + ForgeComments {}
337
338impl<T: ForgeRead + ForgeComments + ?Sized> ForgeReadComments for T {}
339
340/// MCP server surface: read + comments + review submission, but no merge
341/// or reaction capabilities. The MCP tool set exposes `trv_submit_review`
342/// so `ForgeReadComments` alone isn't wide enough.
343pub trait ForgeMcp: ForgeRead + ForgeComments + ForgeReview {}
344
345impl<T: ForgeRead + ForgeComments + ForgeReview + ?Sized> ForgeMcp for T {}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn pr_id_construction() {
353        let id = PrId {
354            owner: "octocat".into(),
355            repo: "hello-world".into(),
356            number: 42,
357        };
358        assert_eq!(id.owner, "octocat");
359        assert_eq!(id.number, 42);
360    }
361
362    #[test]
363    fn pr_id_equality() {
364        let a = PrId {
365            owner: "o".into(),
366            repo: "r".into(),
367            number: 1,
368        };
369        let b = PrId {
370            owner: "o".into(),
371            repo: "r".into(),
372            number: 1,
373        };
374        assert_eq!(a, b);
375    }
376
377    #[test]
378    fn pr_metadata_construction() {
379        let meta = PrMetadata {
380            title: "feat: add login".into(),
381            body: String::new(),
382            author: "alice".into(),
383            state: PrState::Open,
384            base_branch: "main".into(),
385            head_branch: "feat-login".into(),
386            head_sha: "abc123".into(),
387            created_at: Utc::now(),
388            mergeable: Some(MergeableStatus::Clean),
389            is_draft: false,
390        };
391        assert_eq!(meta.state, PrState::Open);
392        assert!(!meta.is_draft);
393    }
394
395    #[test]
396    fn new_comment_equality() {
397        let c1 = NewComment {
398            path: "src/lib.rs".into(),
399            line: 10,
400            side: LineSide::New,
401            body: "fix this".into(),
402            start_line: None,
403            commit_id: None,
404        };
405        let c2 = NewComment {
406            path: "src/lib.rs".into(),
407            line: 10,
408            side: LineSide::New,
409            body: "fix this".into(),
410            start_line: None,
411            commit_id: None,
412        };
413        assert_eq!(c1, c2);
414    }
415
416    #[test]
417    fn debug_output_is_reasonable() {
418        let state = PrState::Merged;
419        let dbg = format!("{state:?}");
420        assert_eq!(dbg, "Merged");
421    }
422
423    #[test]
424    fn reaction_content_all_variants() {
425        let variants = [
426            ReactionContent::ThumbsUp,
427            ReactionContent::ThumbsDown,
428            ReactionContent::Laugh,
429            ReactionContent::Hooray,
430            ReactionContent::Confused,
431            ReactionContent::Heart,
432            ReactionContent::Rocket,
433            ReactionContent::Eyes,
434        ];
435        assert_eq!(variants.len(), 8);
436        // Each variant is distinct
437        for (i, a) in variants.iter().enumerate() {
438            for (j, b) in variants.iter().enumerate() {
439                assert_eq!(i == j, a == b);
440            }
441        }
442    }
443
444    #[test]
445    fn forge_type_variants() {
446        assert_ne!(ForgeType::GitHub, ForgeType::GitLab);
447        let gh = ForgeType::GitHub;
448        let gl = ForgeType::GitLab;
449        assert_eq!(gh, ForgeType::GitHub);
450        assert_eq!(gl, ForgeType::GitLab);
451    }
452
453    #[test]
454    fn user_equality() {
455        let a = User {
456            login: "alice".into(),
457            id: 1,
458        };
459        let b = User {
460            login: "alice".into(),
461            id: 1,
462        };
463        assert_eq!(a, b);
464    }
465
466    #[test]
467    fn pr_list_filter_default_is_empty() {
468        let f = PrListFilter::default();
469        assert!(f.state.is_none());
470        assert!(f.author.is_none());
471        assert!(f.assignee.is_none());
472        assert!(f.review_requested.is_none());
473        assert!(f.max.is_none());
474    }
475
476    #[test]
477    fn pr_list_item_construction() {
478        let item = PrListItem {
479            number: 7,
480            title: "feat: do the thing".into(),
481            author: "alice".into(),
482            state: PrState::Open,
483            is_draft: false,
484            base_branch: "main".into(),
485            head_branch: "feat".into(),
486            updated_at: Utc::now(),
487            comment_count: 2,
488            has_review_requested_from_me: true,
489            assignees: vec![],
490            reviewers: vec![],
491        };
492        assert_eq!(item.number, 7);
493        assert_eq!(item.state, PrState::Open);
494        assert!(item.has_review_requested_from_me);
495    }
496}