travelagent-core 1.11.1

Core library for travelagent code review tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
//! Forge backend trait for remote platform integration (GitHub, GitLab, etc.)

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::error::Result;
use crate::model::{DiffFile, LineSide};
use crate::vcs::CommitInfo;

/// Identifies a PR/MR on a remote forge
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrId {
    pub owner: String,
    pub repo: String,
    pub number: u64,
}

/// PR/MR metadata from a remote forge
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrMetadata {
    pub title: String,
    pub body: String,
    pub author: String,
    pub state: PrState,
    pub base_branch: String,
    pub head_branch: String,
    pub head_sha: String,
    pub created_at: DateTime<Utc>,
    pub mergeable: Option<MergeableStatus>,
    pub is_draft: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PrState {
    Open,
    Closed,
    Merged,
}

impl std::fmt::Display for PrState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PrState::Open => write!(f, "open"),
            PrState::Closed => write!(f, "closed"),
            PrState::Merged => write!(f, "merged"),
        }
    }
}

impl PrState {
    #[must_use]
    pub fn display(&self) -> &'static str {
        match self {
            PrState::Open => "open",
            PrState::Closed => "closed",
            PrState::Merged => "merged",
        }
    }
}

/// Lightweight summary of a PR/MR as returned by the forge's list endpoint.
///
/// This carries just the fields needed by the PR list browser UI so the row
/// can be rendered without an extra per-PR round-trip.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrListItem {
    pub number: u64,
    pub title: String,
    pub author: String,
    pub state: PrState,
    pub is_draft: bool,
    pub base_branch: String,
    pub head_branch: String,
    pub updated_at: DateTime<Utc>,
    pub comment_count: u32,
    pub has_review_requested_from_me: bool,
    /// Logins (GitHub) / usernames (GitLab) of users assigned to the PR/MR.
    /// `#[serde(default)]` so old cached payloads and forge fixtures that
    /// don't include the field still deserialize. An empty list combined with
    /// an assignee filter yields zero matches — the intended "no match"
    /// behavior for the client-side filter.
    #[serde(default)]
    pub assignees: Vec<String>,
    /// Logins / usernames of users whose review is requested.
    #[serde(default)]
    pub reviewers: Vec<String>,
}

/// Filter parameters for `ForgeBackend::list_prs`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrListFilter {
    pub state: Option<PrState>,
    pub author: Option<String>,
    pub assignee: Option<String>,
    pub review_requested: Option<bool>,
    pub max: Option<u32>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MergeableStatus {
    Clean,
    Unstable,
    Behind,
    Blocked,
    Dirty,
    Draft,
    Unknown,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MergeMethod {
    Merge,
    Squash,
    Rebase,
}

impl std::fmt::Display for MergeMethod {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            MergeMethod::Merge => write!(f, "merge"),
            MergeMethod::Squash => write!(f, "squash"),
            MergeMethod::Rebase => write!(f, "rebase"),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReviewVerdict {
    Approve,
    RequestChanges,
    Comment,
}

impl std::fmt::Display for ReviewVerdict {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ReviewVerdict::Approve => write!(f, "approve"),
            ReviewVerdict::RequestChanges => write!(f, "request_changes"),
            ReviewVerdict::Comment => write!(f, "comment"),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RemoteComment {
    pub id: u64,
    pub author: String,
    pub body: String,
    pub path: Option<String>,
    pub line: Option<u32>,
    pub side: Option<LineSide>,
    pub created_at: DateTime<Utc>,
    pub in_reply_to: Option<u64>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewThread {
    pub id: String,
    pub is_resolved: bool,
    pub root_comment_id: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewComment {
    pub path: String,
    pub line: u32,
    pub side: LineSide,
    pub body: String,
    pub start_line: Option<u32>,
    pub commit_id: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewReview {
    pub verdict: ReviewVerdict,
    pub body: String,
    pub comments: Vec<NewComment>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ReactionContent {
    ThumbsUp,
    ThumbsDown,
    Laugh,
    Hooray,
    Confused,
    Heart,
    Rocket,
    Eyes,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReactionTarget {
    IssueComment(u64),
    ReviewComment(u64),
    Review(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
    pub login: String,
    pub id: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Permissions {
    pub can_push: bool,
    pub can_merge: bool,
    pub allowed_merge_methods: Vec<MergeMethod>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[must_use]
pub enum ForgeType {
    GitHub,
    GitLab,
}

impl std::fmt::Display for ForgeType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ForgeType::GitHub => write!(f, "GitHub"),
            ForgeType::GitLab => write!(f, "GitLab"),
        }
    }
}

/// Callback invoked by a forge client when it needs to surface a non-fatal
/// warning (e.g. pagination truncation). Consumers that run inside a TUI
/// alternate-screen install one of these so the warning reaches the status
/// bar / error-log ring instead of being lost to an invisible stderr.
///
/// Clients without a handler installed fall back to `eprintln!` so CLI-only
/// paths (the MCP server, tests) still see the message.
pub type ForgeWarnHandler = std::sync::Arc<dyn Fn(String) + Send + Sync>;

// ---------------------------------------------------------------------------
// Capability traits (Interface Segregation Principle — Phase H5)
//
// `ForgeBackend` used to be a single 24-method fat trait. Callers that only
// needed e.g. `list_prs` were forced to depend on `merge`, `add_reaction`,
// and the entire mutation surface. The trait is now split into five
// role-shaped capability traits. Implementations (GitHubForge, GitLabForge,
// test mocks) split into one `impl` block per capability. A supertrait alias
// `ForgeBackend` preserves "full backend" ergonomics for existing callers.
// ---------------------------------------------------------------------------

/// Read-only access to PR/MR metadata, diffs, user info, and permissions.
///
/// Implementations live in separate crates (travelagent-forge-github,
/// travelagent-forge-gitlab).
#[async_trait]
pub trait ForgeRead: Send + Sync {
    fn forge_type(&self) -> ForgeType;

    // PR/MR metadata
    async fn get_pr(&self, id: &PrId) -> Result<PrMetadata>;
    async fn get_pr_commits(&self, id: &PrId) -> Result<Vec<CommitInfo>>;
    async fn get_pr_files(&self, id: &PrId) -> Result<Vec<DiffFile>>;
    async fn get_commit_diff(&self, id: &PrId, commit_sha: &str) -> Result<Vec<DiffFile>>;

    /// List open PRs/MRs on a repository matching the given filter.
    ///
    /// Returns lightweight `PrListItem` rows suitable for a browse UI. The
    /// implementation may paginate internally; `filter.max` bounds the total
    /// number of rows returned (defaults per-backend, capped at 100).
    async fn list_prs(
        &self,
        owner: &str,
        repo: &str,
        filter: &PrListFilter,
    ) -> Result<Vec<PrListItem>>;

    // Auth & permissions
    async fn current_user(&self) -> Result<User>;
    async fn check_permissions(&self, id: &PrId) -> Result<Permissions>;
}

/// Comment and review-thread read/write operations.
#[async_trait]
pub trait ForgeComments: Send + Sync {
    async fn get_comments(&self, id: &PrId) -> Result<Vec<RemoteComment>>;
    async fn get_review_threads(&self, id: &PrId) -> Result<Vec<ReviewThread>>;
    async fn post_comment(&self, id: &PrId, comment: NewComment) -> Result<RemoteComment>;
    async fn post_reply(&self, id: &PrId, thread_id: &str, body: &str) -> Result<RemoteComment>;
    async fn edit_comment(&self, id: &PrId, comment_id: u64, body: &str) -> Result<RemoteComment>;
    async fn delete_comment(&self, id: &PrId, comment_id: u64) -> Result<()>;
    async fn resolve_thread(&self, thread_id: &str) -> Result<()>;
    async fn unresolve_thread(&self, thread_id: &str) -> Result<()>;
}

/// Review submission (approve / request-changes / comment).
#[async_trait]
pub trait ForgeReview: Send + Sync {
    async fn submit_review(&self, id: &PrId, review: NewReview) -> Result<()>;
}

/// Merge, close, and reopen operations.
#[async_trait]
pub trait ForgeMerge: Send + Sync {
    async fn merge(&self, id: &PrId, method: MergeMethod) -> Result<()>;
    async fn close(&self, id: &PrId) -> Result<()>;
    async fn reopen(&self, id: &PrId) -> Result<()>;
}

/// Emoji reactions on comments and reviews.
#[async_trait]
pub trait ForgeReactions: Send + Sync {
    async fn add_reaction(&self, target: &ReactionTarget, content: ReactionContent) -> Result<()>;
    async fn remove_reaction(
        &self,
        target: &ReactionTarget,
        content: ReactionContent,
    ) -> Result<()>;
}

/// Full forge backend: supertrait alias covering every capability.
///
/// Callers that need the full surface (the TUI app, the `create_forge`
/// factory) should continue using `Box<dyn ForgeBackend>`. Callers that only
/// read data should prefer the narrower traits above.
pub trait ForgeBackend:
    ForgeRead + ForgeComments + ForgeReview + ForgeMerge + ForgeReactions
{
}

impl<T: ForgeRead + ForgeComments + ForgeReview + ForgeMerge + ForgeReactions + ?Sized> ForgeBackend
    for T
{
}

/// Read + comments convenience: "look at a PR and its discussion, but
/// nothing else." Used by consumers that want to browse and reply without
/// touching merges or reactions.
pub trait ForgeReadComments: ForgeRead + ForgeComments {}

impl<T: ForgeRead + ForgeComments + ?Sized> ForgeReadComments for T {}

/// MCP server surface: read + comments + review submission, but no merge
/// or reaction capabilities. The MCP tool set exposes `trv_submit_review`
/// so `ForgeReadComments` alone isn't wide enough.
pub trait ForgeMcp: ForgeRead + ForgeComments + ForgeReview {}

impl<T: ForgeRead + ForgeComments + ForgeReview + ?Sized> ForgeMcp for T {}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pr_id_construction() {
        let id = PrId {
            owner: "octocat".into(),
            repo: "hello-world".into(),
            number: 42,
        };
        assert_eq!(id.owner, "octocat");
        assert_eq!(id.number, 42);
    }

    #[test]
    fn pr_id_equality() {
        let a = PrId {
            owner: "o".into(),
            repo: "r".into(),
            number: 1,
        };
        let b = PrId {
            owner: "o".into(),
            repo: "r".into(),
            number: 1,
        };
        assert_eq!(a, b);
    }

    #[test]
    fn pr_metadata_construction() {
        let meta = PrMetadata {
            title: "feat: add login".into(),
            body: String::new(),
            author: "alice".into(),
            state: PrState::Open,
            base_branch: "main".into(),
            head_branch: "feat-login".into(),
            head_sha: "abc123".into(),
            created_at: Utc::now(),
            mergeable: Some(MergeableStatus::Clean),
            is_draft: false,
        };
        assert_eq!(meta.state, PrState::Open);
        assert!(!meta.is_draft);
    }

    #[test]
    fn new_comment_equality() {
        let c1 = NewComment {
            path: "src/lib.rs".into(),
            line: 10,
            side: LineSide::New,
            body: "fix this".into(),
            start_line: None,
            commit_id: None,
        };
        let c2 = NewComment {
            path: "src/lib.rs".into(),
            line: 10,
            side: LineSide::New,
            body: "fix this".into(),
            start_line: None,
            commit_id: None,
        };
        assert_eq!(c1, c2);
    }

    #[test]
    fn debug_output_is_reasonable() {
        let state = PrState::Merged;
        let dbg = format!("{state:?}");
        assert_eq!(dbg, "Merged");
    }

    #[test]
    fn reaction_content_all_variants() {
        let variants = [
            ReactionContent::ThumbsUp,
            ReactionContent::ThumbsDown,
            ReactionContent::Laugh,
            ReactionContent::Hooray,
            ReactionContent::Confused,
            ReactionContent::Heart,
            ReactionContent::Rocket,
            ReactionContent::Eyes,
        ];
        assert_eq!(variants.len(), 8);
        // Each variant is distinct
        for (i, a) in variants.iter().enumerate() {
            for (j, b) in variants.iter().enumerate() {
                assert_eq!(i == j, a == b);
            }
        }
    }

    #[test]
    fn forge_type_variants() {
        assert_ne!(ForgeType::GitHub, ForgeType::GitLab);
        let gh = ForgeType::GitHub;
        let gl = ForgeType::GitLab;
        assert_eq!(gh, ForgeType::GitHub);
        assert_eq!(gl, ForgeType::GitLab);
    }

    #[test]
    fn user_equality() {
        let a = User {
            login: "alice".into(),
            id: 1,
        };
        let b = User {
            login: "alice".into(),
            id: 1,
        };
        assert_eq!(a, b);
    }

    #[test]
    fn pr_list_filter_default_is_empty() {
        let f = PrListFilter::default();
        assert!(f.state.is_none());
        assert!(f.author.is_none());
        assert!(f.assignee.is_none());
        assert!(f.review_requested.is_none());
        assert!(f.max.is_none());
    }

    #[test]
    fn pr_list_item_construction() {
        let item = PrListItem {
            number: 7,
            title: "feat: do the thing".into(),
            author: "alice".into(),
            state: PrState::Open,
            is_draft: false,
            base_branch: "main".into(),
            head_branch: "feat".into(),
            updated_at: Utc::now(),
            comment_count: 2,
            has_review_requested_from_me: true,
            assignees: vec![],
            reviewers: vec![],
        };
        assert_eq!(item.number, 7);
        assert_eq!(item.state, PrState::Open);
        assert!(item.has_review_requested_from_me);
    }
}